95 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
Stone_Red 0b278ed31a Update installer and manifest to version 1.3.0.0 2026-01-21 23:35:36 +01:00
Stone_Red 346a7ad5a1 Remove unused methods from MainWindow.xaml.cs 2026-01-21 21:32:40 +01:00
Stone_Red ea07f0db3a Show loading indicator during plugin creation 2026-01-21 21:26:05 +01:00
Stone_Red 667c913e57 Make plugin creation and setup steps async 2026-01-21 21:25:44 +01:00
Stone_Red 4d0119e4b3 Cleanup plugin template 2026-01-21 21:22:40 +01:00
Stone_Red f957edd886 Add hot reload support for plugins using FileSystemWatcher 2026-01-21 21:20:27 +01:00
Stone_Red 1a6cd5a744 Add reload plugins button and open plugins in IDE support in plugin manager 2026-01-21 19:42:01 +01:00
Stone_Red deddea2888 Remove Card container for plugin logos 2026-01-20 21:11:33 +01:00
Stone_Red e18ab1ec56 Make UI structure more flat 2026-01-20 20:52:39 +01:00
Stone_Red a8236bd8da Make sure default layout gets loaded when deleting current layout 2026-01-20 19:22:09 +01:00
Stone_Red cb8df79a5f Reset CurrentLayoutName if it does not match any existing layout names 2026-01-20 15:51:48 +01:00
Stone_Red 907d743c0d Make sure newly created layout gets loaded 2026-01-20 15:51:32 +01:00
Stone_Red 835ef23719 Fix margin in MainPage 2026-01-19 22:47:44 +01:00
Stone_Red d2da426baf Update project version to 1.3.0.0 2026-01-19 22:35:06 +01:00
Stone_Red 82fe362559 Add theme-aware Modio icon and resource for PluginManager page 2026-01-17 00:39:53 +01:00
Stone_Red 439eca0ef0 Remove unused NotifyIcon field and its disposal on window close 2026-01-17 00:22:54 +01:00
Stone_Red fca25aeb59 Show error dialogs on UI thread using Dispatcher.InvokeAsync 2026-01-17 00:16:53 +01:00
Stone_Red dd9953cb7d Replace Rectangle fill with Border background for color selection UI 2026-01-17 00:16:34 +01:00
Stone_Red 990793fb01 Add ColorPicker and FileSelector settings controls and API classes 2026-01-17 00:15:57 +01:00
Stone_Red c4517fb8ac Display error message box on UI thread 2026-01-13 23:23:39 +01:00
Stone_Red 92ecd2e93d Add cancellation support to AsyncPlugin methods 2026-01-13 22:42:15 +01:00
Stone_Red 056c42d4df Update API version to 1.0.0.0 and add README and package icon to API project 2026-01-01 22:53:29 +01:00
Stone_Red c64b70eb92 Update installer script and installer 2026-01-01 22:53:07 +01:00
Stone_Red 0804738e45 Update message box style to new style 2025-12-29 00:35:23 +01:00
Stone_Red 727324ca9a Overhaul theme creation and editing UI 2025-12-28 23:20:50 +01:00
Stone_Red 389256a380 Improve performance of plugin window bitmap rendering 2025-12-28 01:47:38 +01:00
Stone_Red ec7917625c UI overhaul 2025-12-28 01:46:16 +01:00
Stone_Red 6aa107233c Add reference to WebView2 feedback issue in workaround comment 2025-12-25 20:03:26 +01:00
Stone_Red 3edb2cff84 Fix WebView2 interaction on transparent window with opaque rectangle 2025-12-25 19:57:39 +01:00
Stone_Red 0bb5b10dba Update AsyncPlugin XML docs for clarity and integration details 2025-12-25 18:20:53 +01:00
Stone_Red 2b363bcd18 Update WeatherPlugin default city 2025-12-25 18:16:27 +01:00
Stone_Red 03da9189a5 Move MouseButton to parent directory and update namespace 2025-12-25 18:16:07 +01:00
Stone_Red 0a57b8a3d6 Fix logging and dialogs in PluginData 2025-12-25 18:14:57 +01:00
Stone_Red 16c5c98f1a Add plugin state persistence via PersistStateAttribute serialization 2025-12-25 18:14:01 +01:00
Stone_Red 9e9c49214a Add support for async plugins 2025-12-25 01:23:03 +01:00
Stone_Red 43c77e6a36 Add plugin methods for changed themes, settings showing message boxes and logging 2025-12-25 01:22:39 +01:00
Stone_Red 4778f5673f Pass theme properties to web plugin 2025-12-24 23:59:08 +01:00
Stone_Red e3d6eb4c8c Add ThemeChanged event to IPluginData 2025-12-24 23:58:53 +01:00
Stone_Red c90be28f38 Add basic support for web based plugins 2025-12-24 17:49:04 +01:00
Stone_Red a564b4db5e Add better error handling and logs to plugin manager 2025-12-20 16:50:33 +01:00
Stone_Red 2cece7dbdc Update WeatherPlugin city name setting label text 2025-12-20 16:35:44 +01:00
Stone_Red ba5bfc7df3 Improve plugin metadata 2025-12-20 16:34:42 +01:00
Stone_Red d316108868 Fix Cpu Monitor plugin percentage alignment 2025-12-20 16:20:05 +01:00
Stone_Red 9a96c60af0 Add confirmation when deleting themes/layouts 2025-12-20 16:17:45 +01:00
Stone_Red a8fe2c80f1 Update plugins when opening plugin manager 2025-12-20 16:08:45 +01:00
Stone_Red ac7826898a Check "Does Not Support Unloading" tag to not use AssemblyLoadContext 2025-12-20 16:08:30 +01:00
Stone_Red 780b506103 Add collectible AssemblyLoadContext to support plugin unloading #5 2025-12-20 00:23:48 +01:00
Stone_Red 10733383d3 Add basic weather plugin 2025-12-20 00:20:31 +01:00
Stone_Red 09ce072ec6 Add try catch when stopping plugin 2025-12-09 17:55:05 +01:00
Stone_Red 980ef6473e Update installer 2025-12-09 00:09:27 +01:00
Stone_Red b380e35189 Update version 2025-12-09 00:05:27 +01:00
Stone_Red 3fcda8e64e Add per plugin theme override 2025-12-08 20:37:01 +01:00
Stone_Red 0bc7033196 Fix some issues with the new theme support 2025-12-08 20:11:12 +01:00
Stone_Red 10fe4c773b Add better theme support 2025-11-04 19:00:32 +01:00
Stone_Red b7139a8821 Update auto start step in README.md 2025-07-07 11:41:01 +02:00
86 changed files with 8212 additions and 1310 deletions
+4 -4
View File
@@ -12,8 +12,8 @@
1. Download & install the latest release
- Microsoft Store: [Store Page](https://www.microsoft.com/store/productId/9NPV8F9K1CWM?ocid=pdpshare)
- GitHub: [Releases](https://github.com/Stone-Red-Code/DesktopMagic/releases)
1. (Optional) Put a shortcut to the `DesktopMagic.exe` file in the installation directory into the `shell:startup` directory.
1. Start the app and select the widgets you want to see on your desktop.
1. (Optional) Check "Launch on startup" so DesktopMagic automatically starts when you log in.
1. Check the "Edit" Checkbox to move the widgets.
1. Open the plugin manager to install third-party plugins.
@@ -23,12 +23,12 @@
## Preview
![Screenshot 2023-12-31 164947](https://github.com/Stone-Red-Code/DesktopMagic/assets/56473591/191ac76f-264d-4e9f-8979-7a13b2a1cd12)
![Screenshot 2023-12-31 164853](https://github.com/Stone-Red-Code/DesktopMagic/assets/56473591/22de55d2-dde3-43a5-b77b-c773a7697e8c)
![Screenshot 2026-01-22 000348](https://github.com/user-attachments/assets/4617330d-4c4b-4509-80d7-9f6db413e84f)
![Screenshot 2026-01-22 000434](https://github.com/user-attachments/assets/aebeca87-b305-40a4-a942-d78fce0e9e97)
## Third party licenses
- [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)
- [NAudio](https://github.com/naudio/NAudio) - [MIT](https://github.com/naudio/NAudio/blob/master/license.txt)
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#expr Exec('cmd.exe /C ', 'dotnet build -o "' + SourcePath + '\publish" -c Release ' + SourcePath + '..\src\DesktopMagic\')
#expr Exec('cmd.exe', '/C dotnet build -o "' + SourcePath + '\publish" -c Release ' + SourcePath + '..\src\DesktopMagic\')
#define MyAppName "DesktopMagic"
#define MyAppVersion GetStringFileInfo("/publish/DesktopMagic.exe","ProductVersion")
+15 -7
View File
@@ -1,20 +1,28 @@
<Application x:Class="DesktopMagic.App"
<Application x:Class="DesktopMagic.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:md="http://schemas.lepo.co/wpfui/2022/xaml/markdown"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:helpers="clr-namespace:DesktopMagic.Helpers"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources\Strings\StringResources.en.xaml" />
<materialDesign:BundledTheme BaseTheme="Light" PrimaryColor="Grey" SecondaryColor="LightBlue" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesign3.Defaults.xaml" />
<ui:ThemesDictionary Theme="Light" />
<ui:ControlsDictionary />
<md:ThemesDictionary Theme="Light" />
<md:ControlsDictionary />
<ResourceDictionary Source="pack://application:,,,/BusyIndicator;component/Theme/Default.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.ObsoleteBrushes.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.ObsoleteStyles.xaml" />
<ResourceDictionary Source="Resources\Images\ImageResources.xaml" />
<ResourceDictionary Source="Resources\Styles\ToggleSwitchContentLeftStyle.xaml" />
</ResourceDictionary.MergedDictionaries>
<Thickness x:Key="DefaultMargin" Top="6" Left="6" Right="6" Bottom ="6"></Thickness>
<!-- Converters -->
<helpers:UnderscoreEscapingConverter x:Key="UnderscoreEscapingConverter" />
<!-- Margins -->
<Thickness x:Key="DefaultMargin" Top="5" Left="5" Right="5" Bottom ="5" />
</ResourceDictionary>
</Application.Resources>
</Application>
+12 -2
View File
@@ -5,6 +5,8 @@ using System.IO;
using System.Threading;
using System.Windows;
using Wpf.Ui;
namespace DesktopMagic;
/// <summary>
@@ -26,6 +28,8 @@ public partial class App : Application
public static string PluginsPath => Path.Combine(ApplicationDataPath, "Plugins");
public static IContentDialogService DialogService { get; } = new ContentDialogService();
public static Logger Logger { get; } = new Logger()
{
Config = new()
@@ -130,7 +134,7 @@ public partial class App : Application
Logger.LogFatal(exception + (e.IsTerminating ? "\t Process terminating!" : ""), source: exception.Source ?? "Unknown");
}
private static void Setup(bool clearLogFile)
private static async void Setup(bool clearLogFile)
{
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
@@ -156,7 +160,13 @@ public partial class App : Application
}
catch (Exception ex)
{
_ = MessageBox.Show(ex.ToString(), AppName, MessageBoxButton.OK, MessageBoxImage.Error);
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = AppName,
Content = ex.ToString(),
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
Logger.Log(ex.Message, "Setup", LogSeverity.Error);
}
@@ -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;
}
}
@@ -56,7 +56,7 @@ internal class CpuMonitorPlugin : Plugin
}
else
{
gr.DrawString(cpuUsageString, font, new SolidBrush(Application.Theme.PrimaryColor), 0, (size.Width / 2) - size.Height);
gr.DrawString(cpuUsageString, font, new SolidBrush(Application.Theme.PrimaryColor), 0, 0);
}
return bmp;
+126 -13
View File
@@ -4,35 +4,114 @@ using DesktopMagic.Api.Settings;
using System;
using System.Drawing;
using System.Drawing.Text;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
namespace DesktopMagic.BuiltInPlugins;
internal class DatePlugin : Plugin
internal partial class DatePlugin : Plugin
{
[Setting("short-date", "Short date")]
private readonly CheckBox shortDateCheckBox = new CheckBox(true);
[Setting("week-day-style", "Week Day Style")]
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 Color oldColor = Color.White;
private string oldFont = string.Empty;
private bool oldShortDateCheckBoxValue;
private bool themeChanged = false;
public override int UpdateInterval => 1000;
public override Bitmap? Main()
{
if (oldDateTime.Date == DateTime.Now.Date && oldColor == Application.Theme.PrimaryColor && oldFont == Application.Theme.Font && oldShortDateCheckBoxValue == shortDateCheckBox.Value)
if (oldDateTime.Date == DateTime.Now.Date && !themeChanged)
{
return null;
}
oldDateTime = DateTime.Now;
oldColor = Application.Theme.PrimaryColor;
oldFont = Application.Theme.Font;
oldShortDateCheckBoxValue = shortDateCheckBox.Value;
themeChanged = false;
string date = shortDateCheckBox.Value ? DateTime.Now.ToShortDateString() : DateTime.Now.ToLongDateString();
CultureInfo culture = CultureInfo.CurrentCulture;
DateTime dateTime = DateTime.Now;
Font font = new Font(Application.Theme.Font, 200);
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);
Bitmap bmp = new Bitmap(1, 1);
bmp.SetResolution(100, 100);
@@ -45,10 +124,44 @@ internal class DatePlugin : Plugin
bmp.SetResolution(100, 100);
using Graphics gr = Graphics.FromImage(bmp);
using SolidBrush brush = new SolidBrush(color);
gr.TextRenderingHint = TextRenderingHint.AntiAlias;
gr.DrawString(date, font, new SolidBrush(Application.Theme.PrimaryColor), 0, 0);
gr.DrawString(date, font, brush, 0, 0);
return bmp;
}
public override void OnThemeChanged()
{
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 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")]
private readonly CheckBox mirrorMode = new CheckBox(false);
@@ -51,8 +56,16 @@ internal class MusicVisualizerPlugin : Plugin
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;
}
@@ -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);
}
}
+17 -9
View File
@@ -13,26 +13,23 @@ internal class TimePlugin : Plugin
private readonly CheckBox displaySecondsCheckBox = new CheckBox(true);
private string oldTime = string.Empty;
private Color oldColor = Color.White;
private string oldFont = string.Empty;
private bool oldDisplaySecondsCheckBoxValue;
private bool themeChanged;
public override int UpdateInterval => 1000;
public override Bitmap? Main()
{
string time = displaySecondsCheckBox.Value ? DateTime.Now.ToLongTimeString() : DateTime.Now.ToShortTimeString();
if (oldTime == time && oldColor == Application.Theme.PrimaryColor && oldFont == Application.Theme.Font && oldDisplaySecondsCheckBoxValue == displaySecondsCheckBox.Value)
if (oldTime == time && !themeChanged)
{
return null;
}
oldTime = time;
oldColor = Application.Theme.PrimaryColor;
oldFont = Application.Theme.Font;
oldDisplaySecondsCheckBoxValue = displaySecondsCheckBox.Value;
themeChanged = false;
Font font = new Font(Application.Theme.Font, 200);
using Font font = new Font(Application.Theme.Font, 200);
Bitmap bmp = new Bitmap(1, 1);
bmp.SetResolution(100, 100);
@@ -45,10 +42,21 @@ internal class TimePlugin : Plugin
bmp.SetResolution(100, 100);
using Graphics gr = Graphics.FromImage(bmp);
using SolidBrush brush = new SolidBrush(Application.Theme.PrimaryColor);
gr.TextRenderingHint = TextRenderingHint.AntiAlias;
gr.DrawString(time, font, new SolidBrush(Application.Theme.PrimaryColor), 0, 0);
gr.DrawString(time, font, brush, 0, 0);
return bmp;
}
public override void OnThemeChanged()
{
themeChanged = true;
}
public override void OnSettingsChanged()
{
themeChanged = true;
}
}
@@ -0,0 +1,208 @@
using DesktopMagic.Api;
using DesktopMagic.Api.Settings;
using System;
using System.Diagnostics;
using System.Drawing;
using System.Globalization;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace DesktopMagic.BuiltInPlugins;
public class WeatherPlugin : AsyncPlugin
{
[Setting("city-name", "City Name")]
private readonly TextBox cityInput = new TextBox("Tokyo");
[Setting("search-btn", "Update Location")]
private readonly Button searchButton = new Button("Update Location");
[Setting("show-city", "Show City Name")]
private readonly CheckBox showCity = new CheckBox(true);
[Setting("show-temp", "Show Temperature")]
private readonly CheckBox showTemp = new CheckBox(true);
[Setting("show-status", "Show Weather Status")]
private readonly CheckBox showStatus = new CheckBox(true);
[Setting("show-time", "Show Last Updated")]
private readonly CheckBox showTime = new CheckBox(false);
[Setting("font-size", "Base Font Size")]
private readonly Slider fontSizeSlider = new Slider(10, 100, 30);
public override int UpdateInterval { get; set; } = 900000; // 15 Minutes
private readonly HttpClient httpClient = new HttpClient();
private string? cachedLat;
private string? cachedLon;
private string currentTemp = "--";
private string weatherStatus = "Enter city and search";
private string foundCityName = "No City Selected";
private string lastUpdated = "";
private bool isLoading = false;
public override async Task StartAsync(CancellationToken cancellationToken)
{
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (DesktopMagic)");
// Setup Event Handlers for instant UI updates when settings change
showCity.OnValueChanged += Application.UpdateWindow;
showTemp.OnValueChanged += Application.UpdateWindow;
showStatus.OnValueChanged += Application.UpdateWindow;
showTime.OnValueChanged += Application.UpdateWindow;
fontSizeSlider.OnValueChanged += Application.UpdateWindow;
searchButton.OnClick += () =>
{
isLoading = true;
Application.UpdateWindow();
_ = Task.Run(async () =>
{
await UpdateLocationAndWeather();
isLoading = false;
Application.UpdateWindow();
});
};
await UpdateLocationAndWeather();
Application.UpdateWindow();
}
public override async Task<Bitmap?> MainAsync(CancellationToken cancellationToken)
{
// Periodic background update
if (!isLoading && cachedLat != null && cachedLon != null)
{
await UpdateWeatherOnly(cachedLat, cachedLon);
}
Bitmap bmp = new Bitmap(1200, 800);
bmp.SetResolution(300, 300);
using (Graphics g = Graphics.FromImage(bmp))
{
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit;
g.Clear(Color.Transparent);
int lineCount = 0;
int baseSize = (int)fontSizeSlider.Value;
if (isLoading)
{
DrawLine(bmp, g, "Fetching...", ref lineCount, baseSize, FontStyle.Italic);
}
else
{
if (showCity.Value)
{
DrawLine(bmp, g, foundCityName.ToUpper(), ref lineCount, baseSize, FontStyle.Bold);
}
if (showTemp.Value)
{
DrawLine(bmp, g, currentTemp, ref lineCount, baseSize, FontStyle.Bold);
}
if (showStatus.Value)
{
DrawLine(bmp, g, weatherStatus, ref lineCount, baseSize, FontStyle.Regular);
}
if (showTime.Value && !string.IsNullOrEmpty(lastUpdated))
{
DrawLine(bmp, g, $"Updated: {lastUpdated}", ref lineCount, baseSize, FontStyle.Italic);
}
}
}
return bmp;
}
private void DrawLine(Bitmap bitmap, Graphics graphics, string text, ref int lineCount, int fontSize, FontStyle style)
{
using Font font = new Font(Application.Theme.Font, fontSize, style);
SizeF textSize = graphics.MeasureString(text, font);
float x = (bitmap.Width - textSize.Width) / 2;
// Adjust vertical spacing based on font size
float y = (lineCount == 0) ? 60 : (lineCount * (textSize.Height + 10)) + 60;
using Brush brush = new SolidBrush(Application.Theme.PrimaryColor);
graphics.DrawString(text, font, brush, x, y);
lineCount++;
}
private async Task UpdateLocationAndWeather()
{
try
{
string geoUrl = $"https://geocoding-api.open-meteo.com/v1/search?name={Uri.EscapeDataString(cityInput.Value.Trim())}&count=1&language=en&format=json";
string geoJson = await httpClient.GetStringAsync(geoUrl);
using JsonDocument geoDoc = JsonDocument.Parse(geoJson);
if (!geoDoc.RootElement.TryGetProperty("results", out JsonElement results) || results.GetArrayLength() == 0)
{
foundCityName = cityInput.Value;
currentTemp = "--";
weatherStatus = "City not found";
return;
}
JsonElement location = results[0];
cachedLat = location.GetProperty("latitude").GetDouble().ToString(CultureInfo.InvariantCulture);
cachedLon = location.GetProperty("longitude").GetDouble().ToString(CultureInfo.InvariantCulture);
foundCityName = location.GetProperty("name").GetString() ?? cityInput.Value;
// We update the textbox to the "official" name found by the API
cityInput.Value = foundCityName;
await UpdateWeatherOnly(cachedLat, cachedLon);
}
catch (Exception ex)
{
Debug.WriteLine($"Error: {ex.Message}");
weatherStatus = "Connection Error";
}
}
private async Task UpdateWeatherOnly(string lat, string lon)
{
try
{
string weatherUrl = $"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&current_weather=true";
string weatherJson = await httpClient.GetStringAsync(weatherUrl);
using JsonDocument weatherDoc = JsonDocument.Parse(weatherJson);
JsonElement current = weatherDoc.RootElement.GetProperty("current_weather");
currentTemp = $"{current.GetProperty("temperature").GetDouble()}°C";
weatherStatus = MapWeatherCode(current.GetProperty("weathercode").GetInt32());
lastUpdated = DateTime.Now.ToString("HH:mm");
}
catch { /* Ignore background update errors */ }
}
private string MapWeatherCode(int code)
{
return code switch
{
0 => "Clear Sky",
1 or 2 or 3 => "Partly Cloudy",
45 or 48 => "Foggy",
51 or 53 or 55 => "Drizzle",
61 or 63 or 65 => "Rainy",
71 or 73 or 75 => "Snowy",
95 or 96 or 99 => "Thunderstorm",
_ => "Cloudy"
};
}
}
@@ -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.Settings;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
namespace DesktopMagic.DataContexts;
@@ -11,7 +14,20 @@ internal class MainWindowDataContext : INotifyPropertyChanged
public event PropertyChangedEventHandler? PropertyChanged;
private static DesktopMagicSettings settings = new();
private static string? selectedScreenDeviceName;
private string? selectedLayoutName;
private bool isLoading = true;
private string? pluginsSearchText;
public string Title =>
#if DEBUG
$"{App.AppName} - Dev {System.Windows.Forms.Application.ProductVersion}";
#else
$"{App.AppName} - {System.Windows.Forms.Application.ProductVersion}";
#endif
public string AppName => App.AppName;
public DesktopMagicSettings Settings
{
@@ -23,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
{
get => isLoading;
@@ -33,6 +86,16 @@ internal class MainWindowDataContext : INotifyPropertyChanged
}
}
public string? PluginsSearchText
{
get => pluginsSearchText;
set
{
pluginsSearchText = value;
OnPropertyChanged();
}
}
public bool IsAutoStartEnabled
{
get => StartupManager.IsAutoStartEnabled();
@@ -55,8 +118,163 @@ internal class MainWindowDataContext : INotifyPropertyChanged
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)
{
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));
}
}
@@ -1,18 +1,19 @@
using DesktopMagic.Helpers;
using DesktopMagic.Plugins;
using MaterialDesignThemes.Wpf;
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Input;
using Wpf.Ui.Controls;
namespace DesktopMagic.DataContexts;
internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand command, PluginEntryDataContext.Mode mode, string? path = null) : INotifyPropertyChanged
internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand command, PluginEntryDataContext.Mode mode, string? path = null, string? csprojPath = null) : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
@@ -20,7 +21,7 @@ internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand co
public string Name => pluginMetadata.Name;
public string? Description => pluginMetadata.Description;
public string? Description => pluginMetadata.Description?.Trim();
public string Author => pluginMetadata.Author ?? (string)App.LanguageDictionary["unknown"];
@@ -35,22 +36,40 @@ internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand co
public string? Path => path;
public bool IsLocalPlugin => string.IsNullOrWhiteSpace(pluginMetadata.ProfileUri?.ToString());
public bool IsLocalPlugin => pluginMetadata.IsLocalPlugin;
public ICommand Command => command;
public ButtonData InstallUninstallButtonData => new(mode == Mode.Install ? PackIconKind.Download : PackIconKind.Remove, 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
{
get
{
if (string.IsNullOrWhiteSpace(pluginMetadata.ProfileUri?.ToString()))
if (File.Exists(csprojPath))
{
return new(PackIconKind.FolderOutline, (string)App.LanguageDictionary["folder"], path is not null, new CommandHandler(() => Process.Start("explorer.exe", path!)));
return new(new SymbolIcon(SymbolRegular.Code24), "IDE", true, new CommandHandler(OpenCsprojInIDE));
}
else if (string.IsNullOrWhiteSpace(pluginMetadata.ProfileUri?.ToString()))
{
return new(new SymbolIcon(SymbolRegular.Folder24), (string)App.LanguageDictionary["folder"], path is not null, new CommandHandler(() => Process.Start("explorer.exe", path!)));
}
else
{
return new(PackIconKind.ExternalLink, "mod.io", true, new CommandHandler(OpenModIoPage));
return new(new SymbolIcon(SymbolRegular.Open24), "mod.io", true, new CommandHandler(OpenModIoPage));
}
}
}
@@ -79,6 +98,34 @@ internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand co
_ = Process.Start(psi);
}
private void OpenCsprojInIDE()
{
string? associatedProgram = FileUtilities.GetAssociatedProgram(".csproj");
string pluginProjectPath = System.IO.Path.GetDirectoryName(csprojPath)!;
if (string.IsNullOrWhiteSpace(pluginProjectPath))
{
App.Logger.LogError("Could not determine plugin project directory.", source: "PluginManager");
return;
}
if (associatedProgram is null)
{
App.Logger.LogInfo($"Opening plugin project in Explorer: {pluginProjectPath}", source: "PluginManager");
_ = Process.Start("explorer.exe", pluginProjectPath);
return;
}
App.Logger.LogInfo($"Opening plugin project in IDE: {associatedProgram}", source: "PluginManager");
ProcessStartInfo psi = new ProcessStartInfo
{
FileName = associatedProgram,
Arguments = csprojPath,
};
_ = Process.Start(psi);
}
private string GetInstallUninstallButtonText()
{
return mode switch
@@ -94,7 +141,7 @@ internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand co
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public record ButtonData(PackIconKind IconKind, string Text, bool IsEnabled, ICommand Command);
public record ButtonData(SymbolIcon Icon, string Text, bool IsEnabled, ICommand Command);
public enum Mode
{
@@ -1,6 +1,10 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Media.Imaging;
using Wpf.Ui.Appearance;
namespace DesktopMagic.DataContexts;
@@ -70,6 +74,23 @@ internal class PluginManagerDataContext : INotifyPropertyChanged
}
}
public BitmapImage ModIoIcon
{
get
{
ApplicationTheme theme = ApplicationThemeManager.GetAppTheme();
if (theme == ApplicationTheme.Light)
{
return Application.Current.Resources["ModioLogoBlueDark"] as BitmapImage ?? new BitmapImage();
}
else
{
return Application.Current.Resources["ModioLogoBlueLight"] as BitmapImage ?? new BitmapImage();
}
}
}
public string LoginButtonText => isAuthenticated ? (string)App.LanguageDictionary["logout"] : (string)App.LanguageDictionary["login"];
protected void OnPropertyChanged([CallerMemberName] string? name = null)
+13 -5
View File
@@ -7,14 +7,15 @@
<ApplicationIcon>icon.ico</ApplicationIcon>
<RunPostBuildEvent>OnBuildSuccess</RunPostBuildEvent>
<Authors>Stone_Red</Authors>
<Version>1.1.0.0</Version>
<Version>1.3.3.0</Version>
<PackageProjectUrl>https://github.com/Stone-Red-Code/DesktopMagic</PackageProjectUrl>
<RepositoryUrl>https://github.com/Stone-Red-Code/DesktopMagic</RepositoryUrl>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<AssemblyVersion>1.3.3.0</AssemblyVersion>
<FileVersion>1.3.3.0</FileVersion>
<TargetFramework>net8.0-windows7.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
@@ -33,6 +34,7 @@
<None Remove="icon_Dark.ico" />
<None Remove="Resources\Images\modio-cog-blue.png" />
<None Remove="Resources\Images\modio-logo-bluedark.png" />
<None Remove="Resources\Images\modio-logo-bluelight.png" />
</ItemGroup>
<ItemGroup>
@@ -42,13 +44,18 @@
</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="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.3650.58" />
<PackageReference Include="Modio" Version="1.0.0" />
<PackageReference Include="NAudio" Version="2.2.1" />
<PackageReference Include="SkiaSharp" Version="4.148.0" />
<PackageReference Include="System.Management" Version="8.0.0" />
<PackageReference Include="WPF-UI" Version="4.2.0" />
<PackageReference Include="WPF-UI.Markdown" Version="4.0.2" />
<PackageReference Include="WPF-UI.Tray" Version="4.2.0" />
</ItemGroup>
<ItemGroup>
@@ -63,6 +70,7 @@
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Resource>
<Resource Include="Resources\Images\modio-logo-bluedark.png" />
<Resource Include="Resources\Images\modio-logo-bluelight.png" />
</ItemGroup>
<ItemGroup>
+55 -31
View File
@@ -1,50 +1,74 @@
<Window x:Class="DesktopMagic.Dialogs.ColorDialog"
<ui:FluentWindow x:Class="DesktopMagic.Dialogs.ColorDialog"
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:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:local="clr-namespace:DesktopMagic.Dialogs"
mc:Ignorable="d"
ResizeMode="NoResize"
Title="ColorDialog"
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="300"
Width="450"
MinHeight="300"
MinWidth="450"
WindowStartupLocation="CenterOwner"
SizeToContent="WidthAndHeight">
ExtendsContentIntoTitleBar="True">
<Grid>
<StackPanel Margin="15">
<Grid.RowDefinitions>
<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="15">
<Label x:Name="label" Content="Label" HorizontalAlignment="Left" VerticalAlignment="Top" />
<Grid Margin="{StaticResource DefaultMargin}">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<DockPanel>
<DockPanel Grid.Row="0">
<Label Content="A:" Width="20" />
<Slider x:Name="alphaSlider" VerticalAlignment="Center" Maximum="255" ValueChanged="ColorSliders_ValueChanged" TickFrequency="1" IsSnapToTickEnabled="True" Background="#00030000" />
<Slider x:Name="alphaSlider" VerticalAlignment="Center" Maximum="255" ValueChanged="ColorSliders_ValueChanged" TickFrequency="1" IsSnapToTickEnabled="True" />
</DockPanel>
<DockPanel>
<DockPanel Grid.Row="1">
<Label Content="R:" Width="20" />
<Slider x:Name="redSlider" VerticalAlignment="Center" Maximum="255" ValueChanged="ColorSliders_ValueChanged" TickFrequency="1" IsSnapToTickEnabled="True" Background="#00030000" />
<Slider x:Name="redSlider" VerticalAlignment="Center" Maximum="255" ValueChanged="ColorSliders_ValueChanged" TickFrequency="1" IsSnapToTickEnabled="True" />
</DockPanel>
<DockPanel>
<DockPanel Grid.Row="2">
<Label Content="G:" Width="20" />
<Slider x:Name="greenSlider" VerticalAlignment="Center" Maximum="255" ValueChanged="ColorSliders_ValueChanged" TickFrequency="1" IsSnapToTickEnabled="True" Background="#0000FF00" />
<Slider x:Name="greenSlider" VerticalAlignment="Center" Maximum="255" ValueChanged="ColorSliders_ValueChanged" TickFrequency="1" IsSnapToTickEnabled="True" />
</DockPanel>
<DockPanel>
<DockPanel Grid.Row="3">
<Label Content="B:" Width="20" />
<Slider x:Name="blueSlider" VerticalAlignment="Center" Maximum="255" ValueChanged="ColorSliders_ValueChanged" TickFrequency="1" IsSnapToTickEnabled="True" Background="#000000FF" />
<Slider x:Name="blueSlider" VerticalAlignment="Center" Maximum="255" ValueChanged="ColorSliders_ValueChanged" TickFrequency="1" IsSnapToTickEnabled="True" />
</DockPanel>
</Grid>
<DockPanel Margin="0 5 0 0">
<Border x:Name="colorRechtangle" CornerRadius="4" BorderThickness="1" BorderBrush="{DynamicResource ControlElevationBorderBrush}" Background="White" Height="30" Width="30" Margin="0,0,10,0" />
<TextBox x:Name="colorHexTextBox" MaxLength="9" CharacterCasing="Upper" TextWrapping="Wrap" TextChanged="ColorHexTextBox_TextChanged" RenderTransformOrigin="0.5,0.5" VerticalContentAlignment="Center"/>
</DockPanel>
<StackPanel Orientation="Horizontal">
<Border CornerRadius="1" BorderThickness="1.5" BorderBrush="Gray" Height="23" Width="23" Margin="0,0,5,0">
<Rectangle x:Name="colorRechtangle" Fill="White" />
</Border>
<TextBox x:Name="colorHexTextBox" MaxLength="9" CharacterCasing="Upper" Height="23" TextWrapping="Wrap" Width="350" TextChanged="ColorHexTextBox_TextChanged" RenderTransformOrigin="0.5,0.5" VerticalContentAlignment="Center">
<materialDesign:TextFieldAssist.CharacterCounterStyle>
<Style TargetType="TextBlock" />
</materialDesign:TextFieldAssist.CharacterCounterStyle>
</TextBox>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button x:Name="okButton" Content="{DynamicResource ok }" HorizontalAlignment="Left" Margin="0,0,10,0" VerticalAlignment="Top" Width="100" Click="OkButton_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>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="5">
<Button x:Name="okButton" Content="{DynamicResource ok }" HorizontalAlignment="Left" Margin="0 0 5 0" VerticalAlignment="Top" Width="100" Click="OkButton_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>
</Window>
</ui:FluentWindow>
+6 -3
View File
@@ -4,12 +4,14 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using Wpf.Ui.Controls;
namespace DesktopMagic.Dialogs;
/// <summary>
/// Interaction logic for ColorDialog.xaml
/// </summary>
public partial class ColorDialog : Window
public partial class ColorDialog : FluentWindow
{
public System.Drawing.Color ResultColor { get; private set; }
@@ -22,6 +24,7 @@ public partial class ColorDialog : Window
Resources.MergedDictionaries.Add(App.LanguageDictionary);
label.Content = content;
titleBar.Title = title;
Title = title;
alphaSlider.Value = defaultColor.A;
@@ -86,8 +89,8 @@ public partial class ColorDialog : Window
ResultBrush = brush;
ResultColor = systemColor;
colorRechtangle.Fill = brush;
colorHexTextBox.Foreground = Brushes.Black;
colorRechtangle.Background = brush;
colorHexTextBox.Foreground = FindResource("TextFillColorPrimaryBrush") as Brush;
}
else
{
@@ -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;
}
}
+37 -12
View File
@@ -1,22 +1,47 @@
<Window x:Class="DesktopMagic.Dialogs.InputDialog"
<ui:FluentWindow x:Class="DesktopMagic.Dialogs.InputDialog"
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:local="clr-namespace:DesktopMagic.Dialogs"
mc:Ignorable="d"
Title="InputDialog"
ResizeMode="NoResize"
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="200"
Width="300"
MinHeight="160"
MinWidth="300"
WindowStartupLocation="CenterOwner"
SizeToContent="WidthAndHeight">
ExtendsContentIntoTitleBar="True">
<Grid>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Margin="15">
<Grid.RowDefinitions>
<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" VerticalAlignment="Center" HorizontalAlignment="Stretch" Margin="15">
<Label x:Name="label" Content="Label" HorizontalAlignment="Left" VerticalAlignment="Top" />
<TextBox x:Name="textBox" HorizontalAlignment="Left" Height="23" TextWrapping="Wrap" VerticalAlignment="Top" Width="280" VerticalContentAlignment="Center" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button x:Name="okButton" Content="{DynamicResource ok }" HorizontalAlignment="Left" Margin="0,0,10,0" VerticalAlignment="Top" Width="100" Click="OkButton_Click" Cursor="Hand" />
<Button x:Name="cancelButton" Content="{DynamicResource cancel }" HorizontalAlignment="Left" Margin="0,0,0,0" VerticalAlignment="Top" Width="100" Click="CancelButton_Click" />
<ui:TextBox x:Name="textBox" HorizontalAlignment="Stretch" Margin="5" TextWrapping="Wrap" VerticalAlignment="Top" VerticalContentAlignment="Center" />
</StackPanel>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="5">
<Button x:Name="okButton" Content="{DynamicResource ok }" HorizontalAlignment="Left" Margin="0 0 5 0" VerticalAlignment="Top" Width="100" Click="OkButton_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>
</Window>
</ui:FluentWindow>
+2 -1
View File
@@ -2,7 +2,7 @@
namespace DesktopMagic.Dialogs;
public partial class InputDialog : Window
public partial class InputDialog : Wpf.Ui.Controls.FluentWindow
{
public string ResponseText
{
@@ -17,6 +17,7 @@ public partial class InputDialog : Window
Resources.MergedDictionaries.Add(App.LanguageDictionary);
label.Content = content;
titleBar.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;
}
}
+92
View File
@@ -0,0 +1,92 @@
<ui:FluentWindow x:Class="DesktopMagic.Dialogs.ThemeDialog"
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="290"
Width="550"
MinHeight="290"
MinWidth="550"
WindowStartupLocation="CenterOwner"
ExtendsContentIntoTitleBar="True">
<Grid>
<Grid.RowDefinitions>
<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="15">
<Label x:Name="label" Content="Label" HorizontalAlignment="Left" VerticalAlignment="Top" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="auto"/>
</Grid.ColumnDefinitions>
<Grid Grid.Column="0" Margin="{StaticResource DefaultMargin}" HorizontalAlignment="Stretch">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="Display Font: " Grid.Column="0" Grid.Row="0" VerticalAlignment="Center" Loaded="TextBlock_Loaded"/>
<ComboBox x:Name="fontComboBox" Grid.Column="1" Grid.Row="0" HorizontalAlignment="Stretch" VerticalAlignment="Top" SelectionChanged="FontComboBox_SelectionChanged" Margin="0,0,0,5" />
<TextBlock Text="Corner Radius: " Grid.Column="0" Grid.Row="1" VerticalAlignment="Center"></TextBlock>
<ui:NumberBox x:Name="cornerRadiusTextBox" Minimum="0" Grid.Column="1" Grid.Row="1" ValueChanged="CornerRadiusTextBox_ValueChanged" PlaceholderText="Enter a number" Margin="0,0,0,5" />
<TextBlock Text="Margin: " Grid.Column="0" Grid.Row="3" VerticalAlignment="Center"></TextBlock>
<ui:NumberBox x:Name="marginTextBox" Minimum="0" Grid.Column="1" Grid.Row="3" ValueChanged="MarginTextBox_ValueChanged" PlaceholderText="Enter a number"/>
</Grid>
<Grid Grid.Column="1" Margin="{StaticResource DefaultMargin}" HorizontalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<DockPanel Grid.Row="0">
<Border x:Name="primaryColorRechtangle" CornerRadius="4" BorderThickness="1" BorderBrush="{DynamicResource ControlElevationBorderBrush}" Background="White" Height="30" Width="30" Margin="0,0,10,0" />
<Button Content="Change Primary Color" FontWeight="Regular" Click="ChangePrimaryColorButton_Click" HorizontalAlignment="Stretch" />
</DockPanel>
<DockPanel Grid.Row="1">
<Border x:Name="secondaryColorRechtangle" CornerRadius="4" BorderThickness="1" BorderBrush="{DynamicResource ControlElevationBorderBrush}" Background="White" Height="30" Width="30" Margin="0,0,10,0" />
<Button Content="Change Secondary Color" FontWeight="Regular" Click="ChangeSecondaryColorButton_Click" HorizontalAlignment="Stretch" />
</DockPanel>
<DockPanel Grid.Row="2">
<Border x:Name="backgroundColorRechtangle" CornerRadius="4" BorderThickness="1" BorderBrush="{DynamicResource ControlElevationBorderBrush}" Background="White" Height="30" Width="30" Margin="0,0,10,0" />
<Button Content="Change Background Color" FontWeight="Regular" Click="ChangeBackgroundColorButton_Click" HorizontalAlignment="Stretch" />
</DockPanel>
</Grid>
</Grid>
</StackPanel>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="5">
<Button x:Name="okButton" Content="{DynamicResource ok }" HorizontalAlignment="Left" Margin="0 0 5 0" VerticalAlignment="Top" Width="100" Click="OkButton_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,132 @@
using DesktopMagic.Helpers;
using DesktopMagic.Plugins;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace DesktopMagic.Dialogs;
/// <summary>
/// Interaction logic for ColorDialog.xaml
/// </summary>
public partial class ThemeDialog : Wpf.Ui.Controls.FluentWindow
{
private readonly Theme theme;
public ThemeDialog(string content, Theme theme, string title = App.AppName)
{
this.theme = theme;
InitializeComponent();
Resources.MergedDictionaries.Add(App.LanguageDictionary);
cornerRadiusTextBox.Value = theme.CornerRadius;
marginTextBox.Value = theme.Margin;
primaryColorRechtangle.Background = new SolidColorBrush(MultiColorConverter.ConvertToMediaColor(theme.PrimaryColor));
secondaryColorRechtangle.Background = new SolidColorBrush(MultiColorConverter.ConvertToMediaColor(theme.SecondaryColor));
backgroundColorRechtangle.Background = new SolidColorBrush(MultiColorConverter.ConvertToMediaColor(theme.BackgroundColor));
label.Content = content;
titleBar.Title = title;
Title = title;
}
private void OkButton_Click(object sender, RoutedEventArgs e)
{
DialogResult = true;
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
}
private void TextBlock_Loaded(object sender, RoutedEventArgs e)
{
int index = 0;
foreach (FontFamily ff in Fonts.SystemFontFamilies)
{
ComboBoxItem comboBoxItem = new()
{
FontFamily = ff,
Content = ff.ToString()
};
_ = fontComboBox.Items.Add(comboBoxItem);
if (ff.ToString() == theme.Font)
{
fontComboBox.SelectedIndex = index;
}
index++;
}
if (fontComboBox.SelectedIndex == -1)
{
fontComboBox.SelectedIndex = 0;
}
}
private void FontComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
theme.Font = fontComboBox.SelectedValue.ToString()!.Replace("System.Windows.Controls.ComboBoxItem: ", "");
}
private void ChangePrimaryColorButton_Click(object sender, RoutedEventArgs e)
{
ColorDialog colorDialog = new ColorDialog("Set Primary Color", theme.PrimaryColor)
{
Owner = this
};
if (colorDialog.ShowDialog() == true)
{
theme.PrimaryColor = colorDialog.ResultColor;
primaryColorRechtangle.Background = colorDialog.ResultBrush;
}
}
private void ChangeSecondaryColorButton_Click(object sender, RoutedEventArgs e)
{
ColorDialog colorDialog = new ColorDialog("Set Secondary Color", theme.SecondaryColor)
{
Owner = this
};
if (colorDialog.ShowDialog() == true)
{
theme.SecondaryColor = colorDialog.ResultColor;
secondaryColorRechtangle.Background = colorDialog.ResultBrush;
}
}
private void ChangeBackgroundColorButton_Click(object sender, RoutedEventArgs e)
{
ColorDialog colorDialog = new ColorDialog("Set Background Color", theme.BackgroundColor)
{
Owner = this
};
if (colorDialog.ShowDialog() == true)
{
theme.BackgroundColor = colorDialog.ResultColor;
backgroundColorRechtangle.Background = colorDialog.ResultBrush;
}
}
private void MarginTextBox_ValueChanged(object sender, Wpf.Ui.Controls.NumberBoxValueChangedEventArgs args)
{
if (args.NewValue is >= 0)
{
theme.Margin = (int)args.NewValue;
}
}
private void CornerRadiusTextBox_ValueChanged(object sender, Wpf.Ui.Controls.NumberBoxValueChangedEventArgs args)
{
if (args.NewValue is >= 0)
{
theme.CornerRadius = (int)args.NewValue;
}
}
}
+2
View File
@@ -9,6 +9,8 @@ using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "No need to")]
[assembly: SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "<Pending>")]
[assembly: SuppressMessage("Critical Code Smell", "S2696:Instance members should not write to \"static\" fields", Justification = "<Pending>", Scope = "member", Target = "~P:DesktopMagic.DataContexts.MainWindowDataContext.Settings")]
[assembly: SuppressMessage("Critical Code Smell", "S2696:Instance members should not write to \"static\" fields", Justification = "<Pending>", Scope = "member", Target = "~P:DesktopMagic.DataContexts.MainWindowDataContext.DialogService")]
[assembly: SuppressMessage("Major Code Smell", "S3885:\"Assembly.Load\" should be used", Justification = "Assembly.Load does not load dependencies", Scope = "member", Target = "~M:DesktopMagic.PluginWindow.ExecuteSource")]
[assembly: SuppressMessage("Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "<Pending>", Scope = "member", Target = "~M:DesktopMagic.MainWindow.OpenPluginsFolderButton_Click(System.Object,System.Windows.RoutedEventArgs)")]
[assembly: SuppressMessage("Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "<Pending>", Scope = "member", Target = "~M:DesktopMagic.MainWindow.ScrollViewer_PreviewMouseWheel(System.Object,System.Windows.Input.MouseWheelEventArgs)")]
[assembly: SuppressMessage("Major Code Smell", "S6966:Awaitable method should be used", Justification = "Breaks stuff", Scope = "member", Target = "~M:DesktopMagic.PluginWindow.UpdateTimer_Elapsed(System.Object,System.Timers.ElapsedEventArgs)")]
+36 -2
View File
@@ -6,16 +6,26 @@ namespace DesktopMagic.Helpers;
internal static partial class MultiColorConverter
{
public static string ConvertToHex(System.Drawing.Color color)
public static string ConvertToHexArgb(System.Drawing.Color color)
{
return $"#{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}";
}
public static string ConvertToHex(System.Windows.Media.Color color)
public static string ConvertToHexArgb(System.Windows.Media.Color color)
{
return $"#{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}";
}
public static string ConvertToHexRgba(System.Drawing.Color color)
{
return $"#{color.R:X2}{color.G:X2}{color.B:X2}{color.A:X2}";
}
public static string ConvertToHexRgba(System.Windows.Media.Color color)
{
return $"#{color.R:X2}{color.G:X2}{color.B:X2}{color.A:X2}";
}
public static bool TryConvertToSystemColor(string hex, out System.Drawing.Color color)
{
hex = hex.Replace("#", "");
@@ -79,11 +89,35 @@ internal static partial class MultiColorConverter
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)
{
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})")]
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;
}
}
@@ -1,19 +1,19 @@
using DesktopMagic.Plugins;
using DesktopMagic.Dialogs;
using DesktopMagic.Plugins;
using Microsoft.Win32;
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace DesktopMagic.Helpers;
internal class SettingElementGenerator(ComboBox optionsComboBox)
internal class SettingElementGenerator(uint pluginId)
{
private readonly ComboBox optionsComboBox = optionsComboBox;
public void Generate(SettingElement settingElement, DockPanel dockPanel, TextBlock textBlock)
public FrameworkElement? Generate(SettingElement settingElement, System.Windows.Controls.TextBlock textBlock)
{
dockPanel.UpdateLayout();
textBlock.UpdateLayout();
if (settingElement.Input is DesktopMagic.Api.Settings.Label eLabel)
{
textBlock.Text = eLabel.Value;
@@ -32,16 +32,13 @@ internal class SettingElementGenerator(ComboBox optionsComboBox)
textBlock.Text = eLabel.Value;
});
};
return null;
}
else if (settingElement.Input is DesktopMagic.Api.Settings.Button eButton)
{
Button button = new()
Wpf.Ui.Controls.Button button = new()
{
Content = eButton.Value,
FontSize = 10,
Height = 20,
Margin = new Thickness(0, 10, 0, 10),
Padding = new Thickness(0),
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch
};
@@ -64,16 +61,18 @@ internal class SettingElementGenerator(ComboBox optionsComboBox)
});
};
_ = dockPanel.Children.Add(button);
return button;
}
else if (settingElement.Input is DesktopMagic.Api.Settings.CheckBox eCheckBox)
{
CheckBox checkBox = new()
Wpf.Ui.Controls.ToggleSwitch checkBox = new()
{
IsChecked = eCheckBox.Value,
Style = (Style)dockPanel.FindResource("MaterialDesignDarkCheckBox"),
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Right,
Height = 30,
Style = Application.Current.FindResource("ToggleSwitchContentLeftStyle") as Style
};
checkBox.Click += (_s, _e) =>
{
@@ -94,14 +93,14 @@ internal class SettingElementGenerator(ComboBox optionsComboBox)
});
};
_ = dockPanel.Children.Add(checkBox);
return checkBox;
}
else if (settingElement.Input is DesktopMagic.Api.Settings.TextBox eTextBox)
{
TextBox textBox = new()
Wpf.Ui.Controls.TextBox textBox = new()
{
Text = eTextBox.Value,
TextWrapping = TextWrapping.Wrap,
TextWrapping = TextWrapping.NoWrap,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch
};
@@ -123,26 +122,23 @@ internal class SettingElementGenerator(ComboBox optionsComboBox)
textBox.Text = eTextBox.Value;
});
};
_ = dockPanel.Children.Add(textBox);
return textBox;
}
else if (settingElement.Input is DesktopMagic.Api.Settings.IntegerUpDown eIntegerUpDown)
{
MaterialDesignThemes.Wpf.NumericUpDown numericUpDown = new()
Wpf.Ui.Controls.NumberBox numberBox = new()
{
Value = eIntegerUpDown.Value,
Minimum = eIntegerUpDown.Minimum,
Maximum = eIntegerUpDown.Maximum,
IncreaseContent = new Label() { Content = "+" },
DecreaseContent = new Label() { Content = "" },
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch,
AllowChangeOnScroll = true
HorizontalAlignment = HorizontalAlignment.Stretch
};
numericUpDown.ValueChanged += (_s, _e) =>
numberBox.ValueChanged += (_s, _e) =>
{
try
{
eIntegerUpDown.Value = numericUpDown.Value;
eIntegerUpDown.Value = (int)numberBox.Value;
}
catch (Exception ex)
{
@@ -151,12 +147,12 @@ internal class SettingElementGenerator(ComboBox optionsComboBox)
};
eIntegerUpDown.OnValueChanged += () =>
{
numericUpDown.Dispatcher.Invoke(() =>
numberBox.Dispatcher.Invoke(() =>
{
numericUpDown.Value = eIntegerUpDown.Value;
numberBox.Value = eIntegerUpDown.Value;
});
};
_ = dockPanel.Children.Add(numericUpDown);
return numberBox;
}
else if (settingElement.Input is DesktopMagic.Api.Settings.Slider eSlider)
{
@@ -168,7 +164,8 @@ internal class SettingElementGenerator(ComboBox optionsComboBox)
TickFrequency = 1,
IsSnapToTickEnabled = true,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch
HorizontalAlignment = HorizontalAlignment.Stretch,
Margin = new Thickness(0, 5, 0, 5)
};
slider.ValueChanged += (_s, _e) =>
{
@@ -189,7 +186,7 @@ internal class SettingElementGenerator(ComboBox optionsComboBox)
});
};
_ = dockPanel.Children.Add(slider);
return slider;
}
else if (settingElement.Input is DesktopMagic.Api.Settings.ComboBox eComboBox)
{
@@ -206,7 +203,8 @@ internal class SettingElementGenerator(ComboBox optionsComboBox)
{
try
{
eComboBox.Value = comboBox.SelectedItem.ToString() ?? string.Empty;
comboBox.SelectedItem ??= eComboBox.Value;
eComboBox.Value = comboBox.SelectedItem?.ToString() ?? eComboBox.Value;
}
catch (Exception ex)
{
@@ -219,17 +217,170 @@ internal class SettingElementGenerator(ComboBox optionsComboBox)
comboBox.SelectedItem = eComboBox.Value;
};
_ = dockPanel.Children.Add(comboBox);
return comboBox;
}
else if (settingElement.Input is DesktopMagic.Api.Settings.ColorPicker eColorPicker)
{
Grid panel = new()
{
HorizontalAlignment = HorizontalAlignment.Stretch
};
panel.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
panel.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
Border colorPreview = new()
{
Width = 30,
Height = 30,
CornerRadius = new CornerRadius(4),
Background = new SolidColorBrush(Color.FromArgb(eColorPicker.Value.A, eColorPicker.Value.R, eColorPicker.Value.G, eColorPicker.Value.B)),
Margin = new Thickness(0, 0, 10, 0),
BorderBrush = Application.Current.FindResource("ControlElevationBorderBrush") as Brush,
BorderThickness = new Thickness(1)
};
Grid.SetColumn(colorPreview, 0);
Wpf.Ui.Controls.Button browseButton = new()
{
Content = "Select Color",
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch
};
Grid.SetColumn(browseButton, 1);
browseButton.Click += (_s, _e) =>
{
try
{
ColorDialog colorDialog = new("Select a color", eColorPicker.Value)
{
Owner = Application.Current.MainWindow
};
if (colorDialog.ShowDialog() == true)
{
eColorPicker.Value = colorDialog.ResultColor;
colorPreview.Background = new SolidColorBrush(Color.FromArgb(eColorPicker.Value.A, eColorPicker.Value.R, eColorPicker.Value.G, eColorPicker.Value.B));
}
}
catch (Exception ex)
{
DisplayException(ex.Message);
}
};
eColorPicker.OnValueChanged += () =>
{
colorPreview.Dispatcher.Invoke(() =>
{
colorPreview.Background = new SolidColorBrush(Color.FromArgb(eColorPicker.Value.A, eColorPicker.Value.R, eColorPicker.Value.G, eColorPicker.Value.B));
});
};
_ = panel.Children.Add(colorPreview);
_ = panel.Children.Add(browseButton);
return panel;
}
else if (settingElement.Input is DesktopMagic.Api.Settings.FileSelector eFileSelector)
{
Grid grid = new()
{
HorizontalAlignment = HorizontalAlignment.Stretch
};
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
Wpf.Ui.Controls.TextBox pathTextBox = new()
{
Text = eFileSelector.Value,
IsReadOnly = true,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch,
Margin = new Thickness(0, 0, 5, 0)
};
Grid.SetColumn(pathTextBox, 0);
Wpf.Ui.Controls.Button browseButton = new()
{
Content = "Browse",
VerticalAlignment = VerticalAlignment.Center
};
Grid.SetColumn(browseButton, 1);
browseButton.Click += (_s, _e) =>
{
try
{
if (eFileSelector.SelectFolder)
{
OpenFolderDialog folderDialog = new()
{
Title = eFileSelector.Title,
InitialDirectory = string.IsNullOrEmpty(eFileSelector.Value) ? Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) : System.IO.Path.GetDirectoryName(eFileSelector.Value)
};
if (folderDialog.ShowDialog() == true)
{
eFileSelector.Value = folderDialog.FolderName;
pathTextBox.Text = eFileSelector.Value;
}
}
else
{
OpenFileDialog fileDialog = new()
{
Title = eFileSelector.Title,
Filter = eFileSelector.Filter,
InitialDirectory = string.IsNullOrEmpty(eFileSelector.Value) ? Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) : System.IO.Path.GetDirectoryName(eFileSelector.Value)
};
if (fileDialog.ShowDialog() == true)
{
eFileSelector.Value = fileDialog.FileName;
pathTextBox.Text = eFileSelector.Value;
}
}
}
catch (Exception ex)
{
DisplayException(ex.Message);
}
};
eFileSelector.OnValueChanged += () =>
{
pathTextBox.Dispatcher.Invoke(() =>
{
pathTextBox.Text = eFileSelector.Value;
});
};
_ = grid.Children.Add(pathTextBox);
_ = grid.Children.Add(browseButton);
return grid;
}
private void DisplayException(string message)
return null;
}
private async void DisplayException(string message)
{
App.Logger.LogInfo(message, source: "PluginInput");
_ = MessageBox.Show("File execution error:\n" + message, "Error", MessageBoxButton.OK, MessageBoxImage.Error);
int index = MainWindow.WindowNames.IndexOf(optionsComboBox.SelectedItem.ToString() ?? string.Empty);
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = "Error",
Content = "File execution error:\n" + message,
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
int index = Manager.Instance.PluginWindows.FindIndex(p => p.PluginMetadata.Id == pluginId);
PluginWindow window = MainWindow.Windows[index];
if (index >= 0 && index < Manager.Instance.PluginWindows.Count)
{
IPluginWindow window = Manager.Instance.PluginWindows[index];
window?.Exit();
}
}
}
+14 -7
View File
@@ -3,7 +3,6 @@
using System;
using System.IO;
using System.Reflection;
using System.Windows;
namespace DesktopMagic.Helpers;
@@ -67,16 +66,24 @@ public static class StartupManager
}
}
public static void ToggleAutoStart()
public static async void ToggleAutoStart()
{
AutoStartStatus status = IsAutoStartEnabled() ? DisableAutoStart() : EnableAutoStart();
_ = status switch
string message = status switch
{
AutoStartStatus.Success => MessageBox.Show("Auto-start setting changed successfully."),
AutoStartStatus.AlreadyEnabled => MessageBox.Show("Auto-start is already enabled."),
AutoStartStatus.AlreadyDisabled => MessageBox.Show("Auto-start is already disabled."),
_ => MessageBox.Show("Failed to change auto-start setting."),
AutoStartStatus.Success => "Auto-start setting changed successfully.",
AutoStartStatus.AlreadyEnabled => "Auto-start is already enabled.",
AutoStartStatus.AlreadyDisabled => "Auto-start is already disabled.",
_ => "Failed to change auto-start setting.",
};
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = App.AppName,
Content = message,
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
}
}
@@ -0,0 +1,23 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace DesktopMagic.Helpers;
public class UnderscoreEscapingConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is string text)
{
// Replaces single underscores with double underscores to escape them in labels
return text.Replace("_", "__");
}
return value;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
+15
View File
@@ -76,6 +76,21 @@ namespace DesktopMagic
DependencyProperty.RegisterAttached("IsHooked", typeof(WindowLockHook), typeof(WindowPos),
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 readonly Window Window;
+106 -156
View File
@@ -1,183 +1,133 @@
<Window x:Class="DesktopMagic.MainWindow"
<ui:FluentWindow x:Class="DesktopMagic.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:busyIndicator="https://github.com/moh3ngolshani/BusyIndicator"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
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:local="clr-namespace:DesktopMagic"
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"
Closing="Window_Closing"
Closed="Window_Closed"
ShowInTaskbar="True"
Icon="{StaticResource Icon}"
Background="{DynamicResource MaterialDesignPaper}"
FontFamily="{DynamicResource MaterialDesignFont}"
Height="520"
Width="570"
MinHeight="520"
MinWidth="570"
WindowState="Minimized"
Height="560"
Width="900"
MinHeight="600"
MinWidth="940"
ExtendsContentIntoTitleBar="True"
WindowState="Normal"
Loaded="Window_Loaded">
<busyIndicator:BusyMask x:Name="BusyIndicator" IsBusy="{Binding IsLoading}" IndicatorType="Cogs" BusyContent="Please wait..." BusyContentMargin="0,20,0,0" IsBusyAtStartup="False">
<Grid>
<TextBlock TextWrapping="Wrap" Text="0" VerticalAlignment="Top" FontFamily="Comic Sans MS" Loaded="TextBlock_Loaded" Foreground="Transparent" />
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="1*" />
</Grid.RowDefinitions>
<Grid Margin="{StaticResource DefaultMargin}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions>
<DockPanel Margin="0,0,2.5,0">
<Grid DockPanel.Dock="Top">
<Rectangle Grid.Row="1" Fill="#FFECECEC" Height="24" />
<CheckBox x:Name="editCheckBox" VerticalAlignment="Center" Grid.Row="1" Content="{DynamicResource edit}" Click="EditCheckBox_Click" Foreground="Black" BorderBrush="#FF323232" Style="{StaticResource MaterialDesignDarkCheckBox}" IsChecked="True" />
</Grid>
<ScrollViewer Background="#FFBBBBBB" PreviewMouseWheel="ScrollViewer_PreviewMouseWheel">
<ItemsControl ItemsSource="{Binding Settings.CurrentLayout.Plugins}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox Click="PluginCheckBox_Click" Content="{Binding Value.Name}" Tag="{Binding Key}" IsChecked="{Binding Value.Enabled}" Style="{StaticResource MaterialDesignDarkCheckBox}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
<DockPanel Grid.Column="1" Margin="2.5,0,0,0">
<ComboBox x:Name="optionsComboBox" DisplayMemberPath="Name" DockPanel.Dock="Top" Height="24" SelectedIndex="0" SelectionChanged="OptionsComboBox_SelectionChanged" Background="#FFECECEC" Padding="4" VerticalAlignment="Center" />
<ScrollViewer Background="#FFBBBBBB">
<Grid>
<StackPanel x:Name="optionsPanel" Margin="3,3,3,0" HorizontalAlignment="Stretch" Visibility="Collapsed">
</StackPanel>
</Grid>
</ScrollViewer>
</DockPanel>
</Grid>
<ui:TitleBar x:Name="TitleBar" Title="{Binding Title}">
<ui:TitleBar.Icon>
<ui:ImageIcon Source="{StaticResource Icon}" />
</ui:TitleBar.Icon>
</ui:TitleBar>
<tray:NotifyIcon Grid.Row="0" FocusOnLeftClick="True" MenuOnRightClick="True" TooltipText="{Binding AppName}" Icon="{StaticResource Icon}" LeftClick="NotifyIcon_LeftClick">
<tray:NotifyIcon.Menu>
<ContextMenu>
<MenuItem Header="Settings" Click="SettingsMenuItem_Click" />
<MenuItem Header="Edit Layout" Click="EditLayoutMenuItem_Click" />
<MenuItem Header="Quit" Click="QuitMenuItem_Click" />
</ContextMenu>
</tray:NotifyIcon.Menu>
</tray:NotifyIcon>
<Grid Grid.Row="1">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="110" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid Grid.Column="0" Margin="{StaticResource DefaultMargin}" HorizontalAlignment="Stretch">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="Display Font: " Grid.Column="0" Grid.Row="0" VerticalAlignment="Center"></TextBlock>
<ComboBox x:Name="fontComboBox" Grid.Column="1" Grid.Row="0" HorizontalAlignment="Stretch" VerticalAlignment="Top" SelectionChanged="FontComboBox_SelectionChanged" Margin="0,0,0,5" />
<ui:NavigationView x:Name="NavigationView" Grid.Row="0" IsBackButtonVisible="Collapsed" IsPaneToggleVisible="True" OpenPaneLength="200" Loaded="NavigationView_Loaded">
<ui:NavigationView.MenuItems>
<ui:NavigationViewItem Content="{DynamicResource plugins}" TargetPageType="{x:Type pages:MainPage}">
<ui:NavigationViewItem.Icon>
<ui:SymbolIcon Symbol="Apps24" />
</ui:NavigationViewItem.Icon>
</ui:NavigationViewItem>
<ui:NavigationViewItem Content="{DynamicResource themes}" TargetPageType="{x:Type pages:ThemePage}">
<ui:NavigationViewItem.Icon>
<ui:SymbolIcon Symbol="Color24" />
</ui:NavigationViewItem.Icon>
</ui:NavigationViewItem>
<ui:NavigationViewItem Content="{DynamicResource pluginManager}" TargetPageType="{x:Type plugins:PluginManager}">
<ui:NavigationViewItem.Icon>
<ui:SymbolIcon Symbol="AppsAddIn24" />
</ui:NavigationViewItem.Icon>
</ui:NavigationViewItem>
</ui:NavigationView.MenuItems>
<TextBlock Text="Corner Radius: " Grid.Column="0" Grid.Row="1" VerticalAlignment="Center"></TextBlock>
<TextBox x:Name="cornerRadiusTextBox" Grid.Column="1" Grid.Row="1" TextChanged="CornerRadiusTextBox_TextChanged" materialDesign:HintAssist.Hint="Enter a number">
<materialDesign:TextFieldAssist.CharacterCounterStyle>
<Style TargetType="TextBlock" />
</materialDesign:TextFieldAssist.CharacterCounterStyle>
</TextBox>
<ui:NavigationView.FooterMenuItems>
<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:SymbolIcon Symbol="ChatHelp24"/>
</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 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:SymbolIcon Symbol="Bug24"/>
</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:NavigationView.FooterMenuItems>
</ui:NavigationView>
<TextBlock Text="Margin: " Grid.Column="0" Grid.Row="3" VerticalAlignment="Center"></TextBlock>
<TextBox x:Name="marginTextBox" Grid.Column="1" Grid.Row="3" TextChanged="MarginTextBox_TextChanged" materialDesign:HintAssist.Hint="Enter a number">
<materialDesign:TextFieldAssist.CharacterCounterStyle>
<Style TargetType="TextBlock" />
</materialDesign:TextFieldAssist.CharacterCounterStyle>
</TextBox>
</Grid>
<StackPanel Grid.Column="1" Margin="{StaticResource DefaultMargin}" HorizontalAlignment="Stretch">
<DockPanel Margin="0,0,0,5">
<Border CornerRadius="1" BorderThickness="1.5" BorderBrush="Gray" Height="23" Width="23" Margin="0,0,5,0">
<Rectangle x:Name="primaryColorRechtangle" Fill="White" />
</Border>
<Button Content="Change Primary Color" Height="23" FontSize="12" FontWeight="Regular" Click="ChangePrimaryColorButton_Click" />
</DockPanel>
<DockPanel Margin="0,0,0,5">
<Border CornerRadius="1" BorderThickness="1.5" BorderBrush="Gray" Height="23" Width="23" Margin="0,0,5,0">
<Rectangle x:Name="secondaryColorRechtangle" Fill="White" />
</Border>
<Button Content="Change Secondary Color" Height="23" FontSize="12" FontWeight="Regular" Click="ChangeSecondaryColorButton_Click" />
</DockPanel>
<DockPanel>
<Border CornerRadius="1" BorderThickness="1.5" BorderBrush="Gray" Height="23" Width="23" Margin="0,0,5,0">
<Rectangle x:Name="backgroundColorRechtangle" Fill="White" />
</Border>
<Button Content="Change Background Color" Height="23" FontSize="12" FontWeight="Regular" Click="ChangeBackgroundColorButton_Click" />
</DockPanel>
</StackPanel>
<Grid Grid.Row="1" Grid.ColumnSpan="2" VerticalAlignment="Bottom">
<Border Grid.Row="1" Background="{DynamicResource ControlFillColorDefaultBrush}" BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}" BorderThickness="0,1,0,0" Padding="5 2 5 5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Rectangle Fill="#FFC5C5C5" Stroke="#FFC5C5C5" Grid.ColumnSpan="3" />
<StackPanel Margin="{StaticResource DefaultMargin}" VerticalAlignment="Bottom">
<ComboBox x:Name="layoutsComboBox" ItemsSource="{Binding Settings.Layouts}" DisplayMemberPath="Name" SelectedValue="{Binding Settings.CurrentLayoutName}" SelectedValuePath="Name" HorizontalAlignment="Left" Margin="0,0,0,5" Width="170" Height="20" SelectionChanged="LayoutsComboBox_SelectionChanged" />
<Button x:Name="newLayoutButton" Margin="0,0,0,5" HorizontalAlignment="Left" VerticalAlignment="Bottom" Width="170" Click="NewLayoutButton_Click" FontWeight="Regular">
<StackPanel Orientation="Horizontal">
<materialDesign:PackIcon Kind="Add" Margin="0 0 10 0" />
<TextBlock Text="{DynamicResource newLayout}" />
</StackPanel>
</Button>
<Button x:Name="removeLayoutButton" HorizontalAlignment="Left" VerticalAlignment="Bottom" Width="170" Click="RemoveLayoutButton_Click" FontWeight="Regular">
<StackPanel Orientation="Horizontal">
<materialDesign:PackIcon Kind="DeleteOutline" Margin="0 0 10 0" />
<TextBlock Text="{DynamicResource deleteLayout}" />
</StackPanel>
</Button>
</StackPanel>
<StackPanel Grid.Column="1" Margin="{StaticResource DefaultMargin}" VerticalAlignment="Bottom">
<Button x:Name="updatePluginsButton" Margin="0,0,0,5" HorizontalAlignment="Center" Width="170" Click="UpdatePluginsButton_Click" FontWeight="Regular">
<StackPanel Orientation="Horizontal">
<materialDesign:PackIcon Kind="Reload" Margin="0 0 10 0" />
<TextBlock Text="{DynamicResource updatePlugins}" />
</StackPanel>
</Button>
<Button x:Name="githubButton" HorizontalAlignment="Center" Width="170" Click="GitHubButton_Click" FontWeight="Regular" Cursor="Hand">
<StackPanel Orientation="Horizontal">
<materialDesign:PackIcon Kind="Github" Margin="0 0 10 0" />
<TextBlock Text="GitHub" />
</StackPanel>
</Button>
</StackPanel>
<StackPanel Grid.Column="2" Margin="{StaticResource DefaultMargin}" VerticalAlignment="Bottom">
<CheckBox x:Name="autoStartCheckBox" Content="{DynamicResource autoStart}" Foreground="Black" BorderBrush="#FF323232" Margin="0,0,0,5" Width="170" Height="20" Style="{StaticResource MaterialDesignDarkCheckBox}" IsChecked="{Binding IsAutoStartEnabled}" />
<Button x:Name="downloadPluginsButton" Margin="0,0,0,5" HorizontalAlignment="Right" Width="170" Click="PluginManagerButton_Click" FontWeight="Regular" Cursor="Hand">
<StackPanel Orientation="Horizontal">
<materialDesign:PackIcon Kind="ExtensionOutline" Margin="0 0 10 0" />
<TextBlock Text="{DynamicResource pluginManager}" />
</StackPanel>
</Button>
<Button x:Name="openPluginsFolderButton" HorizontalAlignment="Right" Width="170" Click="OpenPluginsFolderButton_Click" FontWeight="Regular">
<StackPanel Orientation="Horizontal">
<materialDesign:PackIcon Kind="FolderOutline" Margin="0 0 10 0" />
<TextBlock Text="{DynamicResource pluginsFolder}" />
</StackPanel>
</Button>
</StackPanel>
<ui:HyperlinkButton FontWeight="Bold" Content="{DynamicResource developedBy}" NavigateUri="https://me.stone-red.net"/>
<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>
</Border>
</Grid>
<ContentPresenter x:Name="rootContentDialog" Grid.Row="1" Grid.RowSpan="2" />
</Grid>
</Grid>
</busyIndicator:BusyMask>
</Window>
</ui:FluentWindow>
+200 -586
View File
@@ -1,285 +1,98 @@
using DesktopMagic.BuiltInPlugins;
using DesktopMagic.DataContexts;
using DesktopMagic.Dialogs;
using DesktopMagic.Helpers;
using DesktopMagic.Plugins;
using DesktopMagic.Settings;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace DesktopMagic
using Wpf.Ui.Appearance;
using Wpf.Ui.Controls;
namespace DesktopMagic;
public partial class MainWindow : FluentWindow
{
public partial class MainWindow : Window
{
private readonly System.Windows.Forms.NotifyIcon notifyIcon = new();
private readonly MainWindowDataContext mainWindowDataContext = new();
private readonly Dictionary<PluginMetadata, Type> builtInPlugins = new()
{
{new((string)App.LanguageDictionary["musicVisualizer"], 1), typeof(MusicVisualizerPlugin)},
{new((string)App.LanguageDictionary["time"],2), typeof(TimePlugin)},
{new((string)App.LanguageDictionary["date"],3), typeof(DatePlugin)},
{new((string)App.LanguageDictionary["cpuUsage"], 4), typeof(CpuMonitorPlugin)}
};
private bool loaded = false;
private bool blockWindowsClosing = true;
public static List<PluginWindow> Windows { get; } = [];
public static List<string> WindowNames { get; } = [];
private DesktopMagicSettings Settings
{
get => mainWindowDataContext.Settings;
set => mainWindowDataContext.Settings = value;
}
private readonly Manager _manager = Manager.Instance;
private readonly MainWindowDataContext _mainWindowDataContext = new();
[Obsolete]
public MainWindow()
{
DataContext = mainWindowDataContext;
try
{
Stream iconStream = Application.GetResourceStream(new Uri("pack://application:,,,/DesktopMagic;component/icon.ico")).Stream;
notifyIcon.MouseClick += NotifyIcon_MouseClick;
notifyIcon.Visible = true;
notifyIcon.Text = App.AppName;
notifyIcon.Icon = new System.Drawing.Icon(iconStream);
notifyIcon.ContextMenuStrip = new System.Windows.Forms.ContextMenuStrip()
{
Items =
{
new System.Windows.Forms.ToolStripMenuItem((string)App.LanguageDictionary["open"], null, (s, e) => RestoreWindow()),
new System.Windows.Forms.ToolStripMenuItem((string)App.LanguageDictionary["toggleEditMode"], null, (s, e) => { editCheckBox.IsChecked = !editCheckBox.IsChecked; EditCheckBox_Click(null, null); }),
new System.Windows.Forms.ToolStripMenuItem((string)App.LanguageDictionary["pluginManager"], null, (s, e) => PluginManagerButton_Click(null!, null!)),
new System.Windows.Forms.ToolStripMenuItem("GitHub", null, (s, e) => GitHubButton_Click(null!, null!)),
new System.Windows.Forms.ToolStripMenuItem((string)App.LanguageDictionary["quit"], null, (s, e) => Quit()),
}
};
SystemThemeWatcher.Watch(this);
InitializeComponent();
DataContext = _mainWindowDataContext;
Resources.MergedDictionaries.Add(App.LanguageDictionary);
#if DEBUG
Title = $"{App.AppName} - Dev {System.Windows.Forms.Application.ProductVersion}";
#else
Title = $"{App.AppName} - {System.Windows.Forms.Application.ProductVersion}";
#endif
}
catch (Exception ex)
{
_ = MessageBox.Show(ex.ToString(), App.AppName, MessageBoxButton.OK, MessageBoxImage.Error);
}
App.DialogService.SetDialogHost(rootContentDialog);
}
#region Load
private readonly Dictionary<uint, InternalPluginData> plugins = [];
private void Window_Loaded(object sender, RoutedEventArgs e)
private async void Window_Loaded(object sender, RoutedEventArgs e)
{
try
{
//Write To Log File and Load Elements
App.Logger.LogInfo("Loading application", source: "MainWindow");
App.Logger.LogInfo("Loading Plugin names", source: "Main");
LoadPlugins();
App.Logger.LogInfo("Loading Layout names", source: "Main");
LoadSettings();
App.Logger.LogInfo("Loading Layout", source: "Main");
LoadLayout();
Microsoft.Win32.SystemEvents.DisplaySettingsChanged += SystemEvents_DisplaySettingsChanged;
loaded = true;
App.Logger.LogInfo("Window Loaded", source: "Main");
}
catch (Exception ex)
_mainWindowDataContext.IsLoading = true;
// Load plugins and settings through manager
_manager.LoadSettings();
_mainWindowDataContext.Settings = _manager.Settings;
_manager.LoadPlugins();
_manager.LoadLayout();
if (_manager.Settings.IsFirstRun)
{
App.Logger.LogError(ex.Message, source: "Main");
_ = MessageBox.Show(ex.ToString(), App.AppName, MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void LoadPlugins()
{
mainWindowDataContext.IsLoading = true;
plugins.Clear();
foreach (var buildInPlugin in builtInPlugins.Keys)
{
plugins.Add(buildInPlugin.Id, new(buildInPlugin, string.Empty));
}
foreach (string directory in Directory.GetDirectories(App.PluginsPath))
{
string? pluginDllPath = Directory.GetFiles(directory, "main.dll").FirstOrDefault();
string? pluginMetadataPath = Directory.GetFiles(directory, "metadata.json").FirstOrDefault();
if (pluginDllPath is null)
{
App.Logger.LogError($"Plugin \"{directory}\" has no \"main.dll\"", source: "Main");
continue;
}
if (pluginMetadataPath is null)
{
App.Logger.LogWarn($"Plugin \"{directory}\" has no \"metadata.json\"", source: "Main");
continue;
}
PluginMetadata? pluginMetadata = JsonSerializer.Deserialize<PluginMetadata>(File.ReadAllText(pluginMetadataPath));
if (pluginMetadata is null)
{
App.Logger.LogError($"Plugin \"{directory}\" has no valid \"metadata.json\"", source: "Main");
continue;
}
if (plugins.ContainsKey(pluginMetadata.Id))
{
App.Logger.LogError($"Plugin \"{directory}\" has the same id as another plugin", source: "Main");
continue;
}
plugins.Add(pluginMetadata.Id, new(pluginMetadata, directory));
}
mainWindowDataContext.IsLoading = false;
}
#endregion Load
#region Windows
private void EditCheckBox_Click(object? sender, RoutedEventArgs? e)
{
foreach (PluginWindow window in Windows)
{
window.SetEditMode(editCheckBox.IsChecked == true);
}
SaveSettings();
}
private void PluginCheckBox_Click(object sender, RoutedEventArgs? e)
{
if (sender is not CheckBox checkBox)
{
return;
}
LoadPlugin(uint.Parse(checkBox.Tag.ToString()!));
}
private void LoadPlugin(uint pluginId)
{
if (!plugins.TryGetValue(pluginId, out InternalPluginData? internalPluginData))
{
return;
}
if (!Settings.CurrentLayout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings))
{
pluginSettings = new PluginSettings();
Settings.CurrentLayout.Plugins.Add(pluginId, pluginSettings);
}
if (WindowNames.Contains(internalPluginData.Metadata.Id.ToString()) || !pluginSettings.Enabled)
{
int index = WindowNames.IndexOf(internalPluginData.Metadata.Id.ToString());
if (index >= 0)
{
try
{
blockWindowsClosing = false;
Windows[index].Close();
blockWindowsClosing = true;
Windows.RemoveAt(index);
WindowNames.RemoveAt(index);
}
catch (Exception ex)
{
App.Logger.LogError(ex.Message, source: "Main");
}
}
return;
}
PluginWindow window;
if (builtInPlugins.TryGetValue(internalPluginData.Metadata, out Type? pluginType))
{
window = new PluginWindow((Api.Plugin)Activator.CreateInstance(pluginType)!, internalPluginData.Metadata, pluginSettings)
{
Title = internalPluginData.Metadata.Id.ToString()
};
_manager.Settings.IsFirstRun = false;
_ = Activate();
}
else
{
window = new PluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath)
{
Title = internalPluginData.Metadata.Id.ToString()
};
WindowState = WindowState.Minimized;
Visibility = Visibility.Collapsed;
ShowInTaskbar = false;
}
Action? onPluginLoaded = null;
onPluginLoaded = () =>
{
Dispatcher.Invoke(() =>
{
if (!optionsComboBox.Items.Contains(internalPluginData.Metadata))
{
_ = optionsComboBox.Items.Add(internalPluginData.Metadata);
_manager.IsLoaded = true;
_mainWindowDataContext.IsLoading = false;
await ShowLatestReleaseInfoAfterUpdateAsync();
App.Logger.LogInfo("Application loaded", source: "MainWindow");
}
optionsComboBox.SelectedIndex = -1;
optionsComboBox.SelectedIndex = optionsComboBox.Items.IndexOf(internalPluginData.Metadata);
window.PluginLoaded -= onPluginLoaded;
});
};
window.OnExit += () =>
catch (Exception ex)
{
Windows.Remove(window);
WindowNames.Remove(window.Title);
blockWindowsClosing = false;
window.Close();
blockWindowsClosing = true;
pluginSettings.Enabled = false;
App.Logger.LogError(ex.Message, source: "MainWindow");
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = App.AppName,
Content = ex.ToString(),
CloseButtonText = "Ok"
};
window.PluginLoaded += onPluginLoaded;
window.ShowInTaskbar = false;
window.Show();
window.SetEditMode(editCheckBox.IsChecked == true);
window.Closing += DisplayWindow_Closing;
Windows.Add(window);
WindowNames.Add(window.Title);
_ = await messageBox.ShowDialogAsync();
}
}
private void DisplayWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
private async void NavigationView_Loaded(object sender, RoutedEventArgs e)
{
e.Cancel = blockWindowsClosing;
_ = NavigationView.Navigate(typeof(Pages.MainPage));
}
private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
SaveSettings();
_manager.SaveSettings();
if (blockWindowsClosing)
if (_manager.BlockWindowsClosing)
{
e.Cancel = true;
editCheckBox.IsChecked = false;
EditCheckBox_Click(null, null);
_manager.SetEditMode(false);
ShowInTaskbar = false;
WindowState = WindowState.Minimized;
Visibility = Visibility.Collapsed;
@@ -288,67 +101,31 @@ namespace DesktopMagic
private void Window_Closed(object sender, EventArgs e)
{
Microsoft.Win32.SystemEvents.DisplaySettingsChanged -= SystemEvents_DisplaySettingsChanged;
Visibility = Visibility.Collapsed;
UpdateLayout();
foreach (Window window in Windows)
{
window.Hide();
}
_manager.CloseAllPluginWindows();
Environment.Exit(0);
}
#endregion Windows
#region Options
private void FontComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
private void NotifyIcon_MouseClick(object? sender, System.Windows.Forms.MouseEventArgs e)
{
Settings.CurrentLayout.Theme.Font = fontComboBox.SelectedValue.ToString()!.Replace("System.Windows.Controls.ComboBoxItem: ", "");
}
private void OptionsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
if (e.Button == System.Windows.Forms.MouseButtons.Left)
{
optionsPanel.Visibility = Visibility.Visible;
optionsPanel.Children.Clear();
optionsPanel.UpdateLayout();
if (optionsComboBox.SelectedItem is null)
{
return;
}
bool success = Settings.CurrentLayout.Plugins.TryGetValue(((PluginMetadata)optionsComboBox.SelectedItem).Id, out Settings.PluginSettings? pluginSettings);
if (!success || pluginSettings is null || pluginSettings.Settings.Count == 0)
{
_ = optionsPanel.Children.Add(new TextBlock() { Text = (string)FindResource("noOptions") });
return;
}
SettingElementGenerator settingElementGenerator = new SettingElementGenerator(optionsComboBox);
foreach (SettingElement settingElement in pluginSettings.Settings)
{
DockPanel dockPanel = new()
{
LastChildFill = true,
HorizontalAlignment = HorizontalAlignment.Stretch,
Margin = new Thickness(0, 0, 0, 5)
};
_ = optionsPanel.Children.Add(dockPanel);
TextBlock textBlock = new()
{
Text = $"{settingElement.Name}:",
Padding = new Thickness(0, 0, 3, 0),
VerticalAlignment = VerticalAlignment.Center
};
_ = dockPanel.Children.Add(textBlock);
settingElementGenerator.Generate(settingElement, dockPanel, textBlock);
RestoreWindow();
}
}
#endregion Options
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()
{
@@ -365,296 +142,14 @@ namespace DesktopMagic
private void Quit()
{
blockWindowsClosing = false;
_manager.BlockWindowsClosing = false;
Close();
}
private void OpenPluginsFolderButton_Click(object sender, RoutedEventArgs e)
private void ReportBugNavigationViewItem_Click(object sender, RoutedEventArgs e)
{
_ = Process.Start("explorer.exe", App.PluginsPath);
}
private void ScrollViewer_PreviewMouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e)
{
ScrollViewer scv = (ScrollViewer)sender;
scv.ScrollToVerticalOffset(scv.VerticalOffset - e.Delta);
e.Handled = true;
}
private void TextBlock_Loaded(object sender, RoutedEventArgs e)
{
int index = 0;
foreach (FontFamily ff in Fonts.SystemFontFamilies)
{
ComboBoxItem comboBoxItem = new()
{
FontFamily = ff,
Content = ff.ToString()
};
_ = fontComboBox.Items.Add(comboBoxItem);
if (ff.ToString() == Settings.CurrentLayout.Theme.Font)
{
fontComboBox.SelectedIndex = index;
}
index++;
}
if (fontComboBox.SelectedIndex == -1)
{
fontComboBox.SelectedIndex = 0;
}
}
private void ChangePrimaryColorButton_Click(object sender, RoutedEventArgs e)
{
ColorDialog colorDialog = new ColorDialog("Set Primary Color", Settings.CurrentLayout.Theme.PrimaryColor)
{
Owner = this
};
if (colorDialog.ShowDialog() == true)
{
Settings.CurrentLayout.Theme.PrimaryColor = colorDialog.ResultColor;
primaryColorRechtangle.Fill = colorDialog.ResultBrush;
}
}
private void ChangeSecondaryColorButton_Click(object sender, RoutedEventArgs e)
{
ColorDialog colorDialog = new ColorDialog("Set Secondary Color", Settings.CurrentLayout.Theme.SecondaryColor)
{
Owner = this
};
if (colorDialog.ShowDialog() == true)
{
Settings.CurrentLayout.Theme.SecondaryColor = colorDialog.ResultColor;
secondaryColorRechtangle.Fill = colorDialog.ResultBrush;
}
}
private void ChangeBackgroundColorButton_Click(object sender, RoutedEventArgs e)
{
ColorDialog colorDialog = new ColorDialog("Set Background Color", Settings.CurrentLayout.Theme.BackgroundColor)
{
Owner = this
};
if (colorDialog.ShowDialog() == true)
{
Settings.CurrentLayout.Theme.BackgroundColor = colorDialog.ResultColor;
backgroundColorRechtangle.Fill = colorDialog.ResultBrush;
}
}
private void CornerRadiusTextBox_TextChanged(object? sender, TextChangedEventArgs? e)
{
bool success = int.TryParse(cornerRadiusTextBox.Text, out int cornerRadius);
if (success)
{
cornerRadiusTextBox.Foreground = Brushes.Black;
Settings.CurrentLayout.Theme.CornerRadius = cornerRadius;
}
else
{
cornerRadiusTextBox.Foreground = Brushes.Red;
}
}
private void MarginTextBox_TextChanged(object? sender, TextChangedEventArgs? e)
{
bool success = int.TryParse(marginTextBox.Text, out int margin);
if (success)
{
marginTextBox.Foreground = Brushes.Black;
Settings.CurrentLayout.Theme.Margin = margin;
}
else
{
marginTextBox.Foreground = Brushes.Red;
}
}
#region Layout
private readonly JsonSerializerOptions jsonSettingsOptions = new()
{
Converters =
{
new ColorJsonConverter()
}
};
private void LayoutsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (loaded)
{
SaveSettings();
LoadLayout(false);
}
}
private void NewLayoutButton_Click(object sender, RoutedEventArgs e)
{
InputDialog inputDialog = new((string)FindResource("enterLayoutName"))
{
Owner = this
};
if (inputDialog.ShowDialog() == true)
{
if (Settings.Layouts.Any(l => l.Name.Trim() == inputDialog.ResponseText.Trim()))
{
_ = MessageBox.Show((string)FindResource("layoutAlreadyExists"), App.AppName, MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
Settings.Layouts.Add(new Layout(inputDialog.ResponseText.Trim()));
Settings.CurrentLayoutName = inputDialog.ResponseText.Trim();
SaveSettings();
}
}
private void RemoveLayoutButton_Click(object sender, RoutedEventArgs e)
{
Settings.Layouts.Remove(Settings.CurrentLayout);
SaveSettings();
}
private void SaveSettings()
{
if (!loaded)
{
return;
}
string json = JsonSerializer.Serialize(Settings, jsonSettingsOptions);
File.WriteAllText(Path.Combine(App.ApplicationDataPath, "settings.json"), json);
}
private void LoadSettings()
{
if (!File.Exists(Path.Combine(App.ApplicationDataPath, "settings.json")))
{
Settings = new DesktopMagicSettings()
{
Layouts =
[
new Layout((string)FindResource("default"))
]
};
return;
}
string json = File.ReadAllText(Path.Combine(App.ApplicationDataPath, "settings.json"));
Settings = JsonSerializer.Deserialize<DesktopMagicSettings>(json, jsonSettingsOptions) ?? new DesktopMagicSettings()
{
Layouts =
[
new Layout((string)FindResource("default"))
]
};
}
private void LoadLayout(bool minimize = true)
{
mainWindowDataContext.IsLoading = true;
cornerRadiusTextBox.Text = Settings.CurrentLayout.Theme.CornerRadius.ToString();
marginTextBox.Text = Settings.CurrentLayout.Theme.Margin.ToString();
blockWindowsClosing = false;
primaryColorRechtangle.Fill = new SolidColorBrush(MultiColorConverter.ConvertToMediaColor(Settings.CurrentLayout.Theme.PrimaryColor));
secondaryColorRechtangle.Fill = new SolidColorBrush(MultiColorConverter.ConvertToMediaColor(Settings.CurrentLayout.Theme.SecondaryColor));
backgroundColorRechtangle.Fill = new SolidColorBrush(MultiColorConverter.ConvertToMediaColor(Settings.CurrentLayout.Theme.BackgroundColor));
CornerRadiusTextBox_TextChanged(null, null);
MarginTextBox_TextChanged(null, null);
foreach (Window window in Windows)
{
window.Close();
}
editCheckBox.IsChecked = false;
EditCheckBox_Click(null, null);
blockWindowsClosing = true;
Windows.Clear();
WindowNames.Clear();
optionsComboBox.Items.Clear();
bool showWindow = true;
// Load plugins
foreach (uint pluginId in plugins.Keys)
{
InternalPluginData internalPluginData = plugins[pluginId];
// Add plugin to layout if it doesn't exist
if (!Settings.CurrentLayout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings))
{
Settings.CurrentLayout.Plugins.Add(pluginId, new PluginSettings() { Name = internalPluginData.Metadata.Name });
continue;
}
pluginSettings.Name = internalPluginData.Metadata.Name;
if (pluginSettings.Enabled)
{
LoadPlugin(pluginId);
}
if (showWindow && pluginSettings.Enabled)
{
showWindow = false;
}
}
// Remove plugins that are not loaded anymore
foreach (uint pluginId in Settings.CurrentLayout.Plugins.Keys)
{
if (!plugins.ContainsKey(pluginId))
{
Settings.CurrentLayout.Plugins.Remove(pluginId);
}
}
Settings.CurrentLayout.UpdatePlugins();
if (!showWindow && minimize)
{
Close();
}
else
{
RestoreWindow();
}
mainWindowDataContext.IsLoading = false;
}
#endregion Layout
private void UpdatePluginsButton_Click(object sender, RoutedEventArgs e)
{
LoadPlugins();
LoadLayout(false);
}
private void NotifyIcon_MouseClick(object? sender, System.Windows.Forms.MouseEventArgs e)
{
if (e.Button == System.Windows.Forms.MouseButtons.Left)
{
RestoreWindow();
}
}
private void GitHubButton_Click(object sender, RoutedEventArgs e)
{
string uri = "https://github.com/Stone-Red-Code/DesktopMagic";
ProcessStartInfo psi = new ProcessStartInfo
string uri = "https://github.com/Stone-Red-Code/DesktopMagic/issues/new?template=bug_report.md";
ProcessStartInfo psi = new()
{
UseShellExecute = true,
FileName = uri
@@ -662,22 +157,141 @@ namespace DesktopMagic
_ = Process.Start(psi);
}
private void PluginManagerButton_Click(object sender, RoutedEventArgs e)
private void ReportBugEmailNavigationViewItem_Click(object sender, RoutedEventArgs e)
{
PluginManager pluginManager = new PluginManager
string uri = "mailto:[email protected]";
ProcessStartInfo psi = new()
{
Owner = this
UseShellExecute = true,
FileName = uri
};
pluginManager.ShowDialog();
LoadPlugins();
LoadLayout(Visibility != Visibility.Visible);
}
_ = Process.Start(psi);
}
internal class InternalPluginData(PluginMetadata pluginMetadata, string directoryPath)
private void RequestFeatureNavigationViewItem_Click(object sender, RoutedEventArgs e)
{
public PluginMetadata Metadata { get; set; } = pluginMetadata;
public string DirectoryPath { get; set; } = directoryPath;
string uri = "https://github.com/Stone-Red-Code/DesktopMagic/issues/new?template=feature_request.md";
ProcessStartInfo psi = new()
{
UseShellExecute = true,
FileName = uri
};
_ = 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)
{
RestoreWindow();
}
private void SettingsMenuItem_Click(object sender, RoutedEventArgs e)
{
RestoreWindow();
_ = NavigationView.Navigate(typeof(Pages.MainPage));
}
private void EditLayoutMenuItem_Click(object sender, RoutedEventArgs e)
{
_manager.SetEditMode(!_manager.IsEditMode);
}
private void QuitMenuItem_Click(object sender, RoutedEventArgs e)
{
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;
}
}
+587
View File
@@ -0,0 +1,587 @@
using DesktopMagic.BuiltInPlugins;
using DesktopMagic.Helpers;
using DesktopMagic.Plugins;
using DesktopMagic.Settings;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Windows;
namespace DesktopMagic;
/// <summary>
/// Singleton Manager class that handles global state, plugin management, and shared operations
/// </summary>
public sealed class Manager
{
private static Manager? _instance;
private static readonly object _lock = new();
public static Manager Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
_instance ??= new Manager();
}
}
return _instance;
}
}
// Name of the built-in layout that shows no widgets on a screen.
public const string EmptyLayoutName = "Empty";
// Plugin management
private readonly Dictionary<uint, InternalPluginData> _plugins = [];
private readonly Dictionary<PluginMetadata, Type> _builtInPlugins = new()
{
{new((string)App.LanguageDictionary["musicVisualizer"], 1) { Author = "Stone_Red" }, typeof(MusicVisualizerPlugin)},
{new((string)App.LanguageDictionary["time"],2) { Author = "Stone_Red" }, typeof(TimePlugin)},
{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["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
public List<IPluginWindow> PluginWindows { get; } = [];
public IReadOnlyDictionary<uint, InternalPluginData> Plugins => _plugins;
public bool BlockWindowsClosing { get; set; } = true;
// Edit mode tracking
private bool _isEditMode = false;
public bool IsEditMode => _isEditMode;
// Settings
public DesktopMagicSettings Settings { get; set; } = new();
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()
{
Converters = { new ColorJsonConverter() }
};
// Events
public event Action? PluginsChanged;
public event Action? SettingsChanged;
public event Action<bool>? EditModeChanged;
private Manager()
{
// Private constructor for singleton
}
#region Plugin Management
public Dictionary<uint, InternalPluginData> GetPlugins() => new(_plugins);
public void LoadPlugins()
{
App.Logger.LogInfo("Loading plugins", source: "Manager");
_plugins.Clear();
// Load built-in plugins
foreach (PluginMetadata builtInPlugin in _builtInPlugins.Keys)
{
_plugins.Add(builtInPlugin.Id, new(builtInPlugin, PluginType.DotNet, string.Empty));
}
// Load external plugins
foreach (string directory in Directory.GetDirectories(App.PluginsPath))
{
string? pluginDllPath = Directory.GetFiles(directory, "main.dll").FirstOrDefault();
string? pluginHtmlPath = Directory.GetFiles(directory, "main.html").FirstOrDefault();
string? pluginMetadataPath = Directory.GetFiles(directory, "metadata.json").FirstOrDefault();
if (pluginDllPath is null && pluginHtmlPath is null)
{
App.Logger.LogError($"Plugin \"{directory}\" has no \"main.dll\" or \"main.html\"", source: "Manager");
continue;
}
if (pluginMetadataPath is null)
{
App.Logger.LogWarn($"Plugin \"{directory}\" has no \"metadata.json\"", source: "Manager");
continue;
}
PluginMetadata? pluginMetadata = JsonSerializer.Deserialize<PluginMetadata>(File.ReadAllText(pluginMetadataPath));
if (pluginMetadata is null)
{
App.Logger.LogError($"Plugin \"{directory}\" has no valid \"metadata.json\"", source: "Manager");
continue;
}
if (_plugins.ContainsKey(pluginMetadata.Id))
{
App.Logger.LogError($"Plugin \"{directory}\" has the same id as another plugin", source: "Manager");
continue;
}
PluginType pluginType = pluginHtmlPath is not null ? PluginType.Web : PluginType.DotNet;
_plugins.Add(pluginMetadata.Id, new(pluginMetadata, pluginType, directory));
}
PluginsChanged?.Invoke();
App.Logger.LogInfo($"Loaded {_plugins.Count} plugins", source: "Manager");
}
/// <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))
{
return;
}
if (!layout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings))
{
pluginSettings = new PluginSettings();
layout.Plugins.Add(pluginId, pluginSettings);
}
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)
{
// Close the window if it's already open or disabled
if (existingWindow is not null)
{
try
{
BlockWindowsClosing = false;
existingWindow.Close();
BlockWindowsClosing = true;
PluginWindows.Remove(existingWindow);
}
catch (Exception ex)
{
App.Logger.LogError(ex.Message, source: "Manager");
}
}
return;
}
IPluginWindow window;
if (_builtInPlugins.TryGetValue(internalPluginData.Metadata, out Type? pluginType))
{
window = new PluginWindow((Api.Plugin)Activator.CreateInstance(pluginType)!, internalPluginData.Metadata, pluginSettings, screenBounds, screenDeviceName)
{
Title = internalPluginData.Metadata.Id.ToString()
};
}
else if (internalPluginData.Type == PluginType.Web)
{
window = new WebPluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath, screenBounds, screenDeviceName)
{
Title = internalPluginData.Metadata.Id.ToString()
};
}
else
{
window = new PluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath, screenBounds, screenDeviceName)
{
Title = internalPluginData.Metadata.Id.ToString()
};
}
Action? pluginLoadedHandler = null;
pluginLoadedHandler = () =>
{
onPluginLoaded?.Invoke(internalPluginData);
window.PluginLoaded -= pluginLoadedHandler;
};
Action exitHandler = () =>
{
// 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;
sharedWindow.Close();
BlockWindowsClosing = true;
}
}
}
pluginSettings.Enabled = false;
};
window.PluginLoaded += pluginLoadedHandler;
window.OnExit += exitHandler;
window.Show();
window.SetEditMode(_isEditMode);
PluginWindows.Add(window);
}
public void ReloadPlugins()
{
App.Logger.LogInfo("Reloading plugins", source: "PluginManager");
LoadPlugins();
LoadLayout();
}
public void SetEditMode(bool editMode)
{
_isEditMode = editMode;
foreach (IPluginWindow window in PluginWindows)
{
window.SetEditMode(editMode);
}
Application.Current.MainWindow.Topmost = editMode;
EditModeChanged?.Invoke(editMode);
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
#region Settings Management
public void LoadSettings()
{
App.Logger.LogInfo("Loading settings", source: "Manager");
if (!File.Exists(Path.Combine(App.ApplicationDataPath, "settings.json")))
{
Settings = new DesktopMagicSettings();
Settings.Layouts.Add(new Layout("Default"));
Settings.Layouts.Add(new Layout(EmptyLayoutName));
Settings.Themes.Add(new Theme("Default"));
Settings.SchemaVersion = 1;
return;
}
string json = File.ReadAllText(Path.Combine(App.ApplicationDataPath, "settings.json"));
Settings = JsonSerializer.Deserialize<DesktopMagicSettings>(json, _jsonSettingsOptions) ?? new DesktopMagicSettings();
if (Settings.Layouts.Count == 0)
{
Settings.Layouts.Add(new Layout("Default"));
}
if (Settings.Themes.Count == 0)
{
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();
}
/// <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()
{
if (!IsLoaded)
{
return;
}
string json = JsonSerializer.Serialize(Settings, _jsonSettingsOptions);
File.WriteAllText(Path.Combine(App.ApplicationDataPath, "settings.json"), json);
App.Logger.LogInfo("Settings saved", source: "Manager");
SettingsChanged?.Invoke();
}
#endregion
#region Layout Management
/// <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)
{
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;
foreach (IPluginWindow window in PluginWindows)
{
window.Close();
}
BlockWindowsClosing = true;
PluginWindows.Clear();
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
foreach (uint pluginId in _plugins.Keys)
{
InternalPluginData internalPluginData = _plugins[pluginId];
// Add plugin to layout if it doesn't exist
if (!layout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings))
{
layout.Plugins.Add(pluginId, new PluginSettings() { Metadata = internalPluginData.Metadata, Owner = layout });
continue;
}
pluginSettings.Metadata = internalPluginData.Metadata;
pluginSettings.Owner = layout;
if (pluginSettings.Enabled)
{
EnsurePluginWindow(screen, layout, internalPluginData, pluginSettings, null);
}
}
// Remove plugins that are not loaded anymore
List<uint> pluginIdsToRemove = layout.Plugins.Keys.Where(id => !_plugins.ContainsKey(id)).ToList();
foreach (uint pluginId in pluginIdsToRemove)
{
layout.Plugins.Remove(pluginId);
}
layout.UpdatePlugins();
}
#endregion
#region Cleanup
public void CloseAllPluginWindows()
{
foreach (IPluginWindow window in PluginWindows)
{
window.Hide();
}
}
#endregion
}
public class InternalPluginData(PluginMetadata pluginMetadata, PluginType pluginType, string directoryPath)
{
public PluginMetadata Metadata { get; set; } = pluginMetadata;
public PluginType Type { get; set; } = pluginType;
public string DirectoryPath { get; set; } = directoryPath;
}
public enum PluginType
{
DotNet,
Web
}
+108
View File
@@ -0,0 +1,108 @@
<Page x:Class="DesktopMagic.Pages.MainPage"
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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:DesktopMagic.Pages"
xmlns:dataContext="clr-namespace:DesktopMagic.DataContexts"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=dataContext:MainWindowDataContext}"
d:DesignHeight="450" d:DesignWidth="800"
ScrollViewer.CanContentScroll="False"
Title="MainPage">
<Grid Margin="{StaticResource DefaultMargin}">
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="*" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<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: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>
<ScrollViewer Background="#FFBBBBBB" PreviewMouseWheel="ScrollViewer_PreviewMouseWheel" Grid.Row="1" VerticalAlignment="Stretch" VerticalContentAlignment="Top">
<ItemsControl x:Name="pluginsItemsControl" ItemsSource="{Binding SelectedLayout.Plugins}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<ui:CardExpander Tag="{Binding}" Margin="0 0 0 5" Expanded="OptionsCardExpander_Expanded">
<ui:CardExpander.Header>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ui:TextBlock Grid.Row="0" Grid.Column="0" FontTypography="Body" Text="{Binding Value.Metadata.Name}" />
<Label Grid.Row="1" Grid.Column="0" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" FontSize="12" ContentStringFormat="{DynamicResource authorFormat}">
<Label.Style>
<Style TargetType="Label" BasedOn="{StaticResource {x:Type Label}}">
<Setter Property="Content" Value="{Binding Value.Metadata.Author, Converter={StaticResource UnderscoreEscapingConverter}}" />
<Style.Triggers>
<DataTrigger Binding="{Binding Value.Metadata.Author}" Value="{x:Null}">
<Setter Property="Content" Value="{DynamicResource unknown}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Label.Style>
</Label>
<ui:ToggleSwitch Grid.Row="0" Grid.RowSpan="2" Grid.Column="1" Click="PluginCheckBox_Click" IsChecked="{Binding Value.Enabled}" Tag="{Binding Key}" Margin="0,0,16,0" VerticalAlignment="Center" />
</Grid>
</ui:CardExpander.Header>
</ui:CardExpander>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<ui:Card Grid.Row="2" Grid.ColumnSpan="2" Margin="0 5 0 0" Padding="5">
<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>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="0.5*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="0.5*" />
</Grid.ColumnDefinitions>
<ui:Button x:Name="newLayoutButton" Content="{DynamicResource newLayout}" Icon="{ui:SymbolIcon Add24}" Grid.Column="2" HorizontalAlignment="Stretch" Click="NewLayoutButton_Click" FontWeight="Regular" />
<ui:Button x:Name="removeLayoutButton" Content="{DynamicResource deleteLayout}" Icon="{ui:SymbolIcon Delete24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="RemoveLayoutButton_Click" FontWeight="Regular" />
</Grid>
</Grid>
</ui:Card>
</Grid>
</Page>
+359
View File
@@ -0,0 +1,359 @@
using DesktopMagic.DataContexts;
using DesktopMagic.Dialogs;
using DesktopMagic.Helpers;
using DesktopMagic.Plugins;
using DesktopMagic.Settings;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
namespace DesktopMagic.Pages;
/// <summary>
/// Interaction logic for MainPage.xaml
/// </summary>
public partial class MainPage : Page
{
private readonly Manager _manager = Manager.Instance;
private readonly MainWindowDataContext _dataContext;
public MainPage()
{
InitializeComponent();
_dataContext = new MainWindowDataContext
{
Settings = _manager.Settings
};
DataContext = _dataContext;
// Subscribe to manager events
_manager.PluginsChanged += OnPluginsChanged;
_manager.EditModeChanged += OnEditModeChanged;
Loaded += MainPage_Loaded;
Unloaded += MainPage_Unloaded;
}
private void MainPage_Loaded(object sender, RoutedEventArgs e)
{
// Initialize edit checkbox state
editCheckBox.IsChecked = _manager.IsEditMode;
_dataContext.RefreshScreens();
}
private void MainPage_Unloaded(object sender, RoutedEventArgs e)
{
// Unsubscribe from events
_manager.PluginsChanged -= OnPluginsChanged;
_manager.EditModeChanged -= OnEditModeChanged;
}
private void OnPluginsChanged()
{
Dispatcher.Invoke(() =>
{
_dataContext.Settings = _manager.Settings;
ApplyPluginsFilter();
});
}
private void OnEditModeChanged(bool editMode)
{
Dispatcher.Invoke(() =>
{
editCheckBox.IsChecked = editMode;
});
}
private void EditCheckBox_Click(object sender, RoutedEventArgs e)
{
_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)
{
if (sender is not Control checkBox)
{
return;
}
uint pluginId = uint.Parse(checkBox.Tag.ToString()!);
_manager.LoadPlugin(pluginId, _manager.SelectedLayout, (internalPluginData) =>
{
Dispatcher.Invoke(() =>
{
// Update the card expander to show options when enabling a plugin
Wpf.Ui.Controls.CardExpander? cardExpander = ((checkBox.Parent as FrameworkElement)?.Parent) as Wpf.Ui.Controls.CardExpander;
if (cardExpander is not null)
{
OptionsCardExpander_Expanded(cardExpander, new RoutedEventArgs());
}
});
});
}
private void ScrollViewer_PreviewMouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e)
{
if (sender is ScrollViewer scv)
{
scv.ScrollToVerticalOffset(scv.VerticalOffset - e.Delta);
e.Handled = true;
}
}
#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
private void LayoutsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
ApplySelectedLayout();
}
/// <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;
}
System.Windows.Forms.Screen? screen = ScreenUtilities.GetScreenByDeviceName(_dataContext.SelectedScreenId);
if (screen is null)
{
return;
}
string? layoutName = _dataContext.SelectedLayoutName;
if (layoutName is null)
{
return;
}
Layout? layout = _manager.Settings.Layouts.FirstOrDefault(l => l.Name == layoutName);
if (layout is null)
{
return;
}
if (_manager.GetLayoutForScreen(screen) == layout)
{
return;
}
_manager.BindLayoutToScreen(screen, layout);
_manager.ReloadScreen(screen);
_dataContext.RefreshSelection();
}
private async void NewLayoutButton_Click(object sender, RoutedEventArgs e)
{
InputDialog inputDialog = new((string)FindResource("enterLayoutName"))
{
Owner = Window.GetWindow(this)
};
if (inputDialog.ShowDialog() == true)
{
if (_manager.Settings.Layouts.Any(l => l.Name.Trim() == inputDialog.ResponseText.Trim()))
{
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = App.AppName,
Content = (string)FindResource("layoutAlreadyExists"),
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
return;
}
_manager.Settings.Layouts.Add(new Layout(inputDialog.ResponseText.Trim()));
_manager.SaveSettings();
// Select the new layout so the user can apply it to the current screen
_dataContext.SelectedLayoutName = inputDialog.ResponseText.Trim();
}
}
private async void RemoveLayoutButton_Click(object sender, RoutedEventArgs e)
{
if (_manager.Settings.Layouts.Count <= 1)
{
Wpf.Ui.Controls.MessageBox cannotDeleteMessageBox = new Wpf.Ui.Controls.MessageBox
{
Title = App.AppName,
Content = (string)FindResource("cannotDeleteLastLayout"),
CloseButtonText = "Ok"
};
_ = await cannotDeleteMessageBox.ShowDialogAsync();
return;
}
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = App.AppName,
Content = (string)FindResource("confirmDeleteLayout"),
PrimaryButtonText = "Yes",
SecondaryButtonText = "No",
IsCloseButtonEnabled = false
};
Wpf.Ui.Controls.MessageBoxResult result = await messageBox.ShowDialogAsync();
if (result != Wpf.Ui.Controls.MessageBoxResult.Primary) // Primary is "Yes"
{
return;
}
Layout? layout = _manager.Settings.Layouts.FirstOrDefault(l => l.Name == _dataContext.SelectedLayoutName);
if (layout is null)
{
return;
}
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.LoadLayout();
_dataContext.RefreshSelection();
}
#endregion
private void OptionsCardExpander_Expanded(object sender, RoutedEventArgs e)
{
if (sender is not Wpf.Ui.Controls.CardExpander expander || expander.Tag is not KeyValuePair<uint, PluginSettings> keyValuePair)
{
return;
}
PluginSettings? pluginSettings = keyValuePair.Value;
uint pluginId = keyValuePair.Key;
StackPanel optionsPanel = new StackPanel
{
Visibility = Visibility.Visible
};
expander.Content = optionsPanel;
optionsPanel.UpdateLayout();
// s.Input being null means the plugins has not been loaded yet but the settings are present in the saved configuration.
if (pluginSettings is null || pluginSettings.Settings.Count == 0 || pluginSettings.Settings.All(s => s.Input is null))
{
_ = optionsPanel.Children.Add(new System.Windows.Controls.TextBlock()
{
Text = (string)FindResource(pluginSettings?.Enabled == true ? "noOptions" : "enablePluginToConfigure")
});
return;
}
SettingElementGenerator settingElementGenerator = new SettingElementGenerator(pluginId);
foreach (SettingElement settingElement in pluginSettings.Settings)
{
Wpf.Ui.Controls.CardControl card = new()
{
Margin = new Thickness(0, 0, 0, 5),
Padding = new Thickness(5)
};
System.Windows.Controls.TextBlock textBlock = new()
{
Text = string.IsNullOrWhiteSpace(settingElement.Name) ? string.Empty : settingElement.Name,
Padding = new Thickness(0, 0, 3, 0),
VerticalAlignment = VerticalAlignment.Center,
FontWeight = FontWeights.SemiBold
};
card.Header = textBlock;
_ = optionsPanel.Children.Add(card);
FrameworkElement? control = settingElementGenerator.Generate(settingElement, textBlock);
if (control is not null)
{
control.MinWidth = 200;
control.MaxWidth = 200;
card.Content = control;
}
}
optionsPanel.UpdateLayout();
}
}
+52
View File
@@ -0,0 +1,52 @@
<Page x:Class="DesktopMagic.Pages.ThemePage"
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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:DesktopMagic.Pages"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800"
Title="ThemePage">
<Grid Margin="{StaticResource DefaultMargin}">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<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>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Margin="8" VerticalAlignment="Center" FontWeight="SemiBold" Text="{Binding Name, Mode=OneWay}" />
<StackPanel Grid.Column="1" VerticalAlignment="Center" Orientation="Horizontal" Margin="{StaticResource DefaultMargin}">
<ui:Button x:Name="editThemeButton" Content="{DynamicResource editTheme}" Icon="{ui:SymbolIcon Color24}" HorizontalAlignment="Stretch" Click="EditThemeButton_Click" FontWeight="Regular" Tag="{Binding}">
</ui:Button>
</StackPanel>
</Grid>
</DataTemplate>
</ui:ListView.ItemTemplate>
</ui:ListView>
<ui:Card Grid.Row="1" Grid.ColumnSpan="2" Margin="0 5 0 0" Padding="5">
<Grid >
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="0.5*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="0.5*" />
</Grid.ColumnDefinitions>
<ui:Button x:Name="newThemeButton" Content="{DynamicResource newTheme}" Icon="{ui:SymbolIcon Add24}" Grid.Column="2" HorizontalAlignment="Stretch" Click="AddThemeButton_Click" FontWeight="Regular"/>
<ui:Button x:Name="deleteThemeButton" Content="{DynamicResource deleteTheme}" Icon="{ui:SymbolIcon Delete24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="DeleteThemeButton_Click" FontWeight="Regular"/>
</Grid>
</ui:Card>
</Grid>
</Page>
+126
View File
@@ -0,0 +1,126 @@
using DesktopMagic.DataContexts;
using DesktopMagic.Dialogs;
using DesktopMagic.Plugins;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
namespace DesktopMagic.Pages;
/// <summary>
/// Interaction logic for ThemePage.xaml
/// </summary>
public partial class ThemePage : Page
{
private readonly Manager _manager = Manager.Instance;
private readonly MainWindowDataContext _dataContext;
public ThemePage()
{
InitializeComponent();
_dataContext = new MainWindowDataContext
{
Settings = _manager.Settings
};
DataContext = _dataContext;
// Subscribe to manager events
_manager.SettingsChanged += OnSettingsChanged;
Unloaded += ThemePage_Unloaded;
}
private void ThemePage_Unloaded(object sender, RoutedEventArgs e)
{
// Unsubscribe from events
_manager.SettingsChanged -= OnSettingsChanged;
}
private void OnSettingsChanged()
{
Dispatcher.Invoke(() =>
{
_dataContext.Settings = _manager.Settings;
});
}
private async void AddThemeButton_Click(object sender, RoutedEventArgs e)
{
InputDialog inputDialog = new((string)FindResource("enterThemeName"))
{
Owner = Window.GetWindow(this)
};
if (inputDialog.ShowDialog() == true)
{
if (_manager.Settings.Themes.Any(l => l.Name.Trim() == inputDialog.ResponseText.Trim()))
{
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = App.AppName,
Content = (string)FindResource("themeAlreadyExists"),
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
return;
}
_manager.Settings.Themes.Add(new Theme(inputDialog.ResponseText.Trim()));
_manager.SelectedLayout.CurrentThemeName = inputDialog.ResponseText.Trim();
_manager.SaveSettings();
}
}
private async void DeleteThemeButton_Click(object sender, RoutedEventArgs e)
{
if (_manager.Settings.Themes.Count <= 1)
{
Wpf.Ui.Controls.MessageBox cannotDeleteMessageBox = new Wpf.Ui.Controls.MessageBox
{
Title = App.AppName,
Content = (string)FindResource("cannotDeleteLastTheme"),
CloseButtonText = "Ok"
};
_ = await cannotDeleteMessageBox.ShowDialogAsync();
return;
}
var messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = App.AppName,
Content = (string)FindResource("confirmDeleteTheme"),
PrimaryButtonText = "Yes",
SecondaryButtonText = "No",
IsCloseButtonEnabled = false
};
Wpf.Ui.Controls.MessageBoxResult result = await messageBox.ShowDialogAsync();
if (result != Wpf.Ui.Controls.MessageBoxResult.Primary) // Primary is "Yes"
{
return;
}
if (themesListBox.SelectedItem is Theme theme)
{
_ = _manager.Settings.Themes.Remove(theme);
_manager.SaveSettings();
}
}
private void EditThemeButton_Click(object sender, RoutedEventArgs e)
{
if (sender is not System.Windows.Controls.Button button || button.Tag is not Theme theme)
{
return;
}
ThemeDialog themeDialog = new(theme.Name, theme, App.AppName)
{
Owner = Window.GetWindow(this)
};
_ = themeDialog.ShowDialog();
}
}
+28
View File
@@ -0,0 +1,28 @@
using DesktopMagic.Plugins;
using System;
namespace DesktopMagic;
public interface IPluginWindow
{
event Action? PluginLoaded;
event Action? OnExit;
bool IsRunning { get; }
PluginMetadata PluginMetadata { get; }
string PluginFolderPath { get; }
string Title { get; set; }
string ScreenDeviceName { get; }
void Exit();
void SetEditMode(bool enabled);
void Show();
void Hide();
void Close();
void ApplySettingValue(string id, string value);
void ApplyButtonClick(string id);
event System.ComponentModel.CancelEventHandler Closing;
}
+54 -2
View File
@@ -1,15 +1,19 @@
using DesktopMagic.Api;
using CuteUtils.Logging;
using DesktopMagic.Api;
using DesktopMagic.Settings;
using System.Drawing;
using Wpf.Ui.Controls;
namespace DesktopMagic.Plugins;
internal class PluginData(PluginWindow window, PluginSettings pluginSettings) : IPluginData
{
private readonly PluginWindow window = window;
public ITheme Theme { get; } = pluginSettings.Theme;
public ITheme Theme => pluginSettings.Theme;
public Size WindowSize => new Size((int)window.ActualWidth, (int)window.ActualHeight);
@@ -19,8 +23,56 @@ internal class PluginData(PluginWindow window, PluginSettings pluginSettings) :
public string PluginPath => window.PluginFolderPath;
public void Log(string message, LogLevel level = LogLevel.Info)
{
LogSeverity severity = level switch
{
LogLevel.Info => LogSeverity.Info,
LogLevel.Warning => LogSeverity.Warn,
LogLevel.Error => LogSeverity.Error,
_ => LogSeverity.Info,
};
App.Logger.Log($"\"{PluginName}\" - {message}", "Plugin", severity);
}
public void ShowMessage(string message, string? title = null)
{
title ??= PluginName;
_ = window.Dispatcher.Invoke(async () =>
{
System.Windows.Window temporaryOwner = new()
{
AllowsTransparency = true,
ShowInTaskbar = false,
WindowStyle = System.Windows.WindowStyle.None,
Background = System.Windows.Media.Brushes.Transparent,
Topmost = true,
};
temporaryOwner.Show();
MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Owner = temporaryOwner,
Title = $"{App.AppName} - {title}",
Content = message,
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
temporaryOwner.Close();
});
}
public void UpdateWindow()
{
window.UpdatePluginWindow();
}
public void SaveState()
{
window.SavePluginState();
}
}
+13 -23
View File
@@ -2,8 +2,8 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:local="clr-namespace:DesktopMagic.Plugins"
xmlns:dataContext="clr-namespace:DesktopMagic.DataContexts"
d:DataContext="{d:DesignInstance Type=dataContext:PluginEntryDataContext}"
@@ -12,11 +12,12 @@
Background="{DynamicResource MaterialDesignPaper}"
FontFamily="{DynamicResource MaterialDesignFont}"
Visibility="{Binding Visibility}">
<Grid Background="White" Margin="10">
<ui:Card Padding="5">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="2*" />
<RowDefinition Height="auto" />
<RowDefinition Height="3*" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
@@ -24,40 +25,29 @@
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Border HorizontalAlignment="Center" Margin="10" Width="100" Height="100" BorderThickness="1" BorderBrush="LightGray">
<Image Source="{Binding Logo, FallbackValue={StaticResource ModioCogBlue}, TargetNullValue={StaticResource ModioCogBlue}}" Width="100" Height="100" />
</Border>
<Image Width="100" Height="100" Margin="5" Source="{Binding Logo, FallbackValue={StaticResource ModioCogBlue}, TargetNullValue={StaticResource ModioCogBlue}}" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" />
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<Label Content="{Binding Author}" HorizontalAlignment="Center" ContentStringFormat="{DynamicResource authorFormat}" />
<Label Content="{Binding Author, Converter={StaticResource UnderscoreEscapingConverter}}" HorizontalAlignment="Center" ContentStringFormat="{DynamicResource authorFormat}" />
<Label Content="{Binding DateAdded}" HorizontalAlignment="Center" ContentStringFormat="{DynamicResource addedFormat}" />
<Label Content="{Binding DateUpdated}" HorizontalAlignment="Center" ContentStringFormat="{DynamicResource updatedFormat}" />
<Label Content="{Binding Version}" HorizontalAlignment="Center" ContentStringFormat="{DynamicResource versionFormat}" />
</StackPanel>
<Grid Grid.Row="1" Grid.ColumnSpan="2">
<Grid Grid.Row="1" Grid.ColumnSpan="2" Margin="0 5">
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Command="{Binding InstallUninstallButtonData.Command}" IsEnabled="{Binding InstallUninstallButtonData.IsEnabled}" VerticalAlignment="Bottom" HorizontalAlignment="Stretch" Margin="10 5 5 0">
<StackPanel Orientation="Horizontal">
<materialDesign:PackIcon Kind="{Binding InstallUninstallButtonData.IconKind}" 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 5 10 0">
<StackPanel Orientation="Horizontal">
<materialDesign:PackIcon Kind="{Binding OpenButtonData.IconKind}" Margin="0 0 10 0" />
<TextBlock Text="{Binding OpenButtonData.Text}" />
</StackPanel>
</Button>
<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"/>
<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"/>
</Grid>
<StackPanel Grid.Row="2" Grid.ColumnSpan="2" Margin="10">
<Label Content="{Binding Name}" FontSize="20" Padding="0" />
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
<StackPanel Grid.Row="2" Grid.ColumnSpan="2" Margin="5">
<ui:TextBlock Text="{Binding Name, Converter={StaticResource UnderscoreEscapingConverter}}" FontTypography="Title" Padding="0" Margin="0 0 0 5" />
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" Margin="0" />
</StackPanel>
</Grid>
</ui:Card>
</UserControl>
+45 -46
View File
@@ -1,25 +1,21 @@
<Window x:Class="DesktopMagic.Plugins.PluginManager"
<Page x:Class="DesktopMagic.Plugins.PluginManager"
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"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:busyIndicator="https://github.com/moh3ngolshani/BusyIndicator"
xmlns:local="clr-namespace:DesktopMagic.Plugins"
xmlns:dataContexts="clr-namespace:DesktopMagic.DataContexts"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=dataContexts:PluginManagerDataContext}"
Title="{DynamicResource pluginManager}"
Height="450"
Width="800"
MinHeight="520"
MinWidth="700"
WindowStyle="ToolWindow"
Background="{DynamicResource MaterialDesignPaper}"
FontFamily="{DynamicResource MaterialDesignFont}">
d:DesignHeight="450"
d:DesignWidth="800"
ScrollViewer.CanContentScroll="False"
Title="{DynamicResource pluginManager}">
<busyIndicator:BusyMask x:Name="BusyIndicator" IsBusy="{Binding IsLoading}" IndicatorType="Cogs" BusyContent="Please wait..." BusyContentMargin="0,20,0,0" IsBusyAtStartup="False">
<Grid Margin="5">
<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.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="auto" />
@@ -28,21 +24,29 @@
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition Width="5" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Label Content="{DynamicResource allPlugins}" FontSize="20" />
<ui:Card Grid.ColumnSpan="3" Padding="5" Margin="0 0 0 5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition Width="10" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<TextBox Grid.Row="1" Text="{Binding AllPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="AllPluginsSearchTextBox_TextChanged" materialDesign:HintAssist.Hint="Search..." materialDesign:TextFieldAssist.HasLeadingIcon="True" materialDesign:TextFieldAssist.LeadingIcon="Search" />
<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}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
</Grid>
</ui:Card>
<busyIndicator:BusyMask Grid.Row="2" IsBusy="{Binding IsSearching}" IndicatorType="Ring" BusyContent="Searching..." BusyContentMargin="0,50,0,0" IsBusyAtStartup="False">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<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">
<ItemsControl ItemsSource="{Binding AllPlugins}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="LightGray" BorderThickness="1">
<local:ModEntry />
</Border>
<local:ModEntry Margin="0 0 0 5" />
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.Style>
@@ -64,17 +68,11 @@
</ScrollViewer>
</busyIndicator:BusyMask>
<Label Grid.Column="1" Content="{DynamicResource installedPlugins}" FontSize="20" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding InstalledPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="InstalledPluginsSearchTextBox_TextChanged" materialDesign:HintAssist.Hint="Search..." materialDesign:TextFieldAssist.HasLeadingIcon="True" materialDesign:TextFieldAssist.LeadingIcon="Search" />
<ScrollViewer Grid.Row="2" Grid.Column="1" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding InstalledPlugins}">
<ScrollViewer VerticalScrollBarVisibility="Auto" Grid.Row="2" Grid.Column="2" VerticalAlignment="Stretch" Padding="0" VerticalContentAlignment="Top">
<ItemsControl ItemsSource="{Binding InstalledPlugins}" Margin="2.5 0 0 0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="LightGray" BorderThickness="1">
<local:ModEntry />
</Border>
<local:ModEntry Margin="0 0 0 5"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.Style>
@@ -95,23 +93,24 @@
</ItemsControl>
</ScrollViewer>
<Border Grid.Row="3" Grid.ColumnSpan="2" Padding="0 5 0 0" BorderThickness="0 2 0 0" BorderBrush="DarkGray">
<DockPanel>
<Image Source="{StaticResource ModioLogoBlueDark}" Cursor="Hand" Height="30" MouseUp="Image_MouseUp" HorizontalAlignment="Left" />
<Button DockPanel.Dock="Right" HorizontalAlignment="Right" Click="LogInButton_Click">
<StackPanel Orientation="Horizontal">
<materialDesign:PackIcon Kind="Login" Margin="0 0 10 0" />
<TextBlock Text="{Binding LoginButtonText}" />
</StackPanel>
</Button>
<Button DockPanel.Dock="Right" HorizontalAlignment="Right" Margin="0 0 10 0" Click="CreatePluginButton_Click">
<StackPanel Orientation="Horizontal">
<materialDesign:PackIcon Kind="Add" Margin="0 0 10 0" />
<TextBlock Text="{DynamicResource createNewPlugin}" />
</StackPanel>
</Button>
</DockPanel>
</Border>
<ui:Card Grid.Row="3" Grid.ColumnSpan="3" Padding="5" Margin="0 5 0 0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="15" />
<ColumnDefinition Width="1.05*" />
<ColumnDefinition Width="5.5" />
<ColumnDefinition Width="1.05*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="1.05*" />
</Grid.ColumnDefinitions>
<Image Source="{Binding ModIoIcon}" Cursor="Hand" Height="30" MouseUp="Image_MouseUp" HorizontalAlignment="Left" />
<ui:Button Content="{DynamicResource createNewPlugin}" Icon="{ui:SymbolIcon Add24}" Grid.Column="2" HorizontalAlignment="Stretch" Click="CreatePluginButton_Click"/>
<ui:Button Content="{DynamicResource reloadPlugins}" Icon="{ui:SymbolIcon ArrowClockwise24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="ReloadPluginsButton_Click"/>
<ui:Button Content="{Binding LoginButtonText}" Icon="{ui:SymbolIcon PersonKey20}" Grid.Column="6" HorizontalAlignment="Stretch" Click="LogInButton_Click"/>
</Grid>
</ui:Card>
</Grid>
</busyIndicator:BusyMask>
</Window>
</Page>
+320 -53
View File
@@ -8,7 +8,6 @@ using Modio.Models;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
@@ -19,6 +18,7 @@ using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using System.Xml.Linq;
@@ -30,7 +30,7 @@ namespace DesktopMagic.Plugins;
/// <summary>
/// Interaction logic for PluginManager.xaml
/// </summary>
public partial class PluginManager : Window
public partial class PluginManager : Page
{
private const int ModIoGameId = 5665;
private const string ModIoApiKey = "88e6ea774c3a502b06114e7fee0829ac";
@@ -38,12 +38,14 @@ public partial class PluginManager : Window
private readonly PluginManagerDataContext pluginManagerDataContext = new();
private readonly string pluginsPath = Path.Combine(App.ApplicationDataPath, "Plugins");
private readonly string pluginDevelopmentPath = Path.Combine(App.ApplicationDataPath, "PluginDevelopment");
private readonly Manager _manager = Manager.Instance;
private readonly DispatcherTimer searchTimer = new()
{
Interval = TimeSpan.FromMilliseconds(300),
};
private bool changed = false;
private Client modIoClient;
public PluginManager()
@@ -55,11 +57,13 @@ public partial class PluginManager : Window
if (modIoAccessToken is null)
{
modIoClient = new Client(new Credentials(ModIoApiKey));
App.Logger.LogInfo("Initialized mod.io client without authentication", source: "PluginManager");
}
else
{
modIoClient = new Client(new Credentials(ModIoApiKey, modIoAccessToken));
pluginManagerDataContext.IsAuthenticated = true;
App.Logger.LogInfo("Initialized mod.io client with authentication", source: "PluginManager");
}
InitializeComponent();
@@ -70,11 +74,16 @@ public partial class PluginManager : Window
searchTimer.Stop();
await SearchAllPlugins(pluginManagerDataContext.AllPluginsSearchText);
};
Loaded += PluginManager_Loaded;
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);
@@ -83,10 +92,17 @@ public partial class PluginManager : Window
try
{
Directory.Delete(pluginPath, true);
App.Logger.LogInfo($"Successfully deleted plugin directory: {pluginPath}", source: "PluginManager");
}
catch (Exception ex)
{
_ = MessageBox.Show(ex.Message, "Plugin Manager", MessageBoxButton.OK, MessageBoxImage.Error);
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");
}
}
@@ -94,41 +110,115 @@ public partial class PluginManager : Window
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;
}
protected override async void OnInitialized(EventArgs e)
{
base.OnInitialized(e);
[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)
{
await InitializePluginManager();
}
private void PluginManager_Unloaded(object sender, RoutedEventArgs e)
{
searchTimer.Stop();
if (changed)
{
_manager.ReloadPlugins();
}
}
private async Task InitializePluginManager()
{
App.Logger.LogInfo("Initializing Plugin Manager", source: "PluginManager");
HashSet<uint> pluginIds = [];
pluginManagerDataContext.IsLoading = true;
pluginManagerDataContext.InstalledPlugins.Clear();
pluginManagerDataContext.AllPlugins.Clear();
foreach (string pluginPath in Directory.GetDirectories(pluginsPath))
{
string pluginMetadataPath = Path.Combine(pluginPath, "metadata.json");
if (!File.Exists(pluginMetadataPath))
{
App.Logger.LogWarn($"Plugin metadata not found at: {pluginMetadataPath}", source: "PluginManager");
continue;
}
PluginMetadata? pluginMetadata = JsonSerializer.Deserialize<PluginMetadata>(await File.ReadAllTextAsync(pluginMetadataPath));
if (pluginMetadata is not null)
if (pluginMetadata is null)
{
pluginManagerDataContext.InstalledPlugins.Add(new PluginEntryDataContext(pluginMetadata, new CommandHandler(async () => await Remove(pluginPath, pluginMetadata.Id)), PluginEntryDataContext.Mode.Uninstall, pluginPath));
App.Logger.LogWarn($"Failed to deserialize plugin metadata from: {pluginMetadataPath}", source: "PluginManager");
continue;
}
string csprojPath = GetCsprojPath(pluginMetadata.Name);
App.Logger.LogInfo($"Loaded plugin: {pluginMetadata.Name} (ID: {pluginMetadata.Id})", source: "PluginManager");
pluginManagerDataContext.InstalledPlugins.Add(new PluginEntryDataContext(pluginMetadata, new CommandHandler(async () => await Remove(pluginPath, pluginMetadata.Id)), PluginEntryDataContext.Mode.Uninstall, pluginPath, csprojPath));
_ = pluginIds.Add(pluginMetadata.Id);
if (pluginMetadata.IsLocalPlugin)
{
continue;
}
try
{
Mod mod = await modIoClient.Games[ModIoGameId].Mods[pluginMetadata.Id].Get();
if (DateTimeOffset.FromUnixTimeSeconds(mod.DateUpdated).DateTime > pluginMetadata.Updated && pluginMetadata.SupportsUnloading)
{
App.Logger.LogInfo($"Plugin {pluginMetadata.Id} has an update available, reinstalling", source: "PluginManager");
await Remove(pluginPath, pluginMetadata.Id);
await Install(mod);
pluginManagerDataContext.IsLoading = true;
}
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to check for plugin {pluginMetadata.Id} updates: {ex.Message}", source: "PluginManager");
}
}
Filter filter = ModFilter.Popular.Desc().Limit(100);
try
{
App.Logger.LogInfo("Fetching popular plugins from mod.io", source: "PluginManager");
IAsyncEnumerable<Mod> mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable();
await foreach (Mod mod in mods)
{
@@ -139,26 +229,28 @@ public partial class PluginManager : Window
pluginManagerDataContext.AllPlugins.Add(new PluginEntryDataContext(new(mod), new CommandHandler(async () => await Install(mod)), PluginEntryDataContext.Mode.Install));
}
App.Logger.LogInfo($"Loaded {pluginManagerDataContext.AllPlugins.Count} available plugins", source: "PluginManager");
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to fetch plugins from mod.io: {ex.Message}", source: "PluginManager");
}
await SyncPlugins();
pluginManagerDataContext.IsLoading = false;
App.Logger.LogInfo("Plugin Manager initialization complete", source: "PluginManager");
}
protected override void OnClosing(CancelEventArgs e)
{
e.Cancel = pluginManagerDataContext.IsLoading;
}
[GeneratedRegex(@"[^a-zA-Z0-9]")]
private static partial Regex IdentifierNameRegex();
private async Task Install(Mod mod)
{
App.Logger.LogInfo($"Installing plugin: {mod.Name} (ID: {mod.Id})", source: "PluginManager");
pluginManagerDataContext.IsLoading = true;
changed = true;
if (mod.Modfile?.Download?.BinaryUrl is null)
{
App.Logger.LogWarn($"Plugin {mod.Id} has no download URL", source: "PluginManager");
return;
}
@@ -166,28 +258,39 @@ public partial class PluginManager : Window
string pluginPath = Path.Combine(pluginsPath, pluginGuid);
string zipFilePath = Path.Combine(pluginsPath, pluginGuid + ".zip");
try
{
App.Logger.LogInfo($"Downloading plugin from: {mod.Modfile.Download.BinaryUrl}", source: "PluginManager");
using (Stream fileStream = await httpClient.GetStreamAsync(mod.Modfile.Download.BinaryUrl))
{
using FileStream outputFileStream = new FileStream(zipFilePath, FileMode.Create);
await fileStream.CopyToAsync(outputFileStream);
}
App.Logger.LogInfo($"Plugin downloaded to: {zipFilePath}", source: "PluginManager");
using (ZipArchive zipArchive = ZipFile.OpenRead(zipFilePath))
{
zipArchive.ExtractToDirectory(pluginPath);
}
App.Logger.LogInfo($"Plugin extracted to: {pluginPath}", source: "PluginManager");
string pluginMetadataPath = Path.Combine(pluginPath, "metadata.json");
await File.WriteAllTextAsync(pluginMetadataPath, JsonSerializer.Serialize(new PluginMetadata(mod)));
File.Delete(zipFilePath);
if (!File.Exists(Path.Combine(pluginPath, "main.dll")))
if (!File.Exists(Path.Combine(pluginPath, "main.dll")) && !File.Exists(Path.Combine(pluginPath, "main.html")))
{
_ = Remove(pluginPath, mod.Id);
App.Logger.LogError($"Plugin {mod.Id} does not contain main.dll or main.html", source: "PluginManager");
await Remove(pluginPath, mod.Id);
pluginManagerDataContext.IsLoading = false;
_ = MessageBox.Show("The plugin you are trying to install does not contain a \"main.dll\" file. Please contact the plugin author.", "Plugin Manager", MessageBoxButton.OK, MessageBoxImage.Error);
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = "Plugin Manager",
Content = "The plugin you are trying to install does not contain a \"main.dll\" or \"main.html\" file. Please contact the plugin author.",
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
return;
}
@@ -199,11 +302,32 @@ public partial class PluginManager : Window
}
if (pluginManagerDataContext.IsAuthenticated)
{
try
{
await modIoClient.Games[ModIoGameId].Mods.Subscribe(mod.Id);
App.Logger.LogInfo($"Subscribed to plugin {mod.Id} on mod.io", source: "PluginManager");
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to subscribe to plugin {mod.Id}: {ex.Message}", source: "PluginManager");
}
}
pluginManagerDataContext.InstalledPlugins.Add(new PluginEntryDataContext(new PluginMetadata(mod), new CommandHandler(async () => await Remove(pluginPath, mod.Id)), PluginEntryDataContext.Mode.Uninstall, pluginPath));
App.Logger.LogInfo($"Successfully installed plugin: {mod.Name} (ID: {mod.Id})", source: "PluginManager");
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to install plugin {mod.Id}: {ex.Message}", source: "PluginManager");
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = "Plugin Manager",
Content = $"Failed to install plugin: {ex.Message}",
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
}
pluginManagerDataContext.IsLoading = false;
}
@@ -217,6 +341,7 @@ public partial class PluginManager : Window
FileName = uri
};
_ = Process.Start(psi);
App.Logger.LogInfo($"Opened mod.io page: {uri}", source: "PluginManager");
}
private void AllPluginsSearchTextBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
@@ -246,6 +371,7 @@ public partial class PluginManager : Window
private async Task SearchAllPlugins(string searchString)
{
App.Logger.LogInfo($"Searching plugins with query: {searchString}", source: "PluginManager");
pluginManagerDataContext.AllPlugins.Clear();
if (!searchString.Contains('*'))
@@ -255,6 +381,8 @@ public partial class PluginManager : Window
Filter filter = ModFilter.Name.Like($"{searchString}").And(ModFilter.Popular.Desc()).Limit(100);
try
{
IAsyncEnumerable<Mod> mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable();
await foreach (Mod mod in mods)
{
@@ -265,28 +393,56 @@ public partial class PluginManager : Window
pluginManagerDataContext.AllPlugins.Add(new PluginEntryDataContext(new(mod), new CommandHandler(async () => await Install(mod)), PluginEntryDataContext.Mode.Install));
}
App.Logger.LogInfo($"Search completed: {pluginManagerDataContext.AllPlugins.Count} plugins found", source: "PluginManager");
}
catch (Exception ex)
{
App.Logger.LogError($"Plugin search failed: {ex.Message}", source: "PluginManager");
}
pluginManagerDataContext.IsSearching = false;
}
private void CreatePluginButton_Click(object sender, RoutedEventArgs e)
private async void ReloadPluginsButton_Click(object sender, RoutedEventArgs e)
{
_manager.ReloadPlugins();
await InitializePluginManager();
changed = false;
}
private async void CreatePluginButton_Click(object sender, RoutedEventArgs e)
{
try
{
CreateNewPlugin();
await CreateNewPlugin();
}
catch (Exception ex)
{
App.Logger.LogError(ex.Message, source: "PluginManager");
_ = MessageBox.Show(ex.Message, "Plugin Manager", MessageBoxButton.OK, MessageBoxImage.Error);
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = "Plugin Manager",
Content = ex.Message,
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
}
}
private void CreateNewPlugin()
private async Task CreateNewPlugin()
{
App.Logger.LogInfo("Creating new plugin", source: "PluginManager");
if (!FileUtilities.ExistsOnPath("dotnet.exe"))
{
_ = MessageBox.Show("The .NET SDK is required to create a plugin. Please install it and try again.", "Plugin Manager", MessageBoxButton.OK, MessageBoxImage.Error);
App.Logger.LogError(".NET SDK not found on PATH", source: "PluginManager");
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = "Plugin Manager",
Content = "The .NET SDK is required to create a plugin. Please install it and try again.",
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
return;
}
@@ -294,22 +450,21 @@ public partial class PluginManager : Window
string pluginPath = Path.Combine(pluginsPath, pluginGuid);
uint pluginId = (uint)Random.Shared.Next(1000, 9999);
InputDialog inputDialog = new((string)FindResource("enterPluginName"), "Plugin Manager")
CreatePluginDialog inputDialog = new()
{
Owner = this,
Owner = Window.GetWindow(this),
};
if (inputDialog.ShowDialog() != true)
{
App.Logger.LogInfo("Plugin creation cancelled by user", source: "PluginManager");
return;
}
string pluginName = inputDialog.ResponseText;
string pluginSafeName = pluginName.ToLower().Replace("_", " ");
string pluginSafeName = GetPluginSafeName(pluginName);
TextInfo info = CultureInfo.CurrentCulture.TextInfo;
pluginSafeName = info.ToTitleCase(pluginSafeName);
pluginSafeName = IdentifierNameRegex().Replace(pluginSafeName, "");
App.Logger.LogInfo($"Creating plugin with name: {pluginName} (safe name: {pluginSafeName})", source: "PluginManager");
if (!Directory.Exists(pluginDevelopmentPath))
{
@@ -320,25 +475,44 @@ public partial class PluginManager : Window
if (Directory.Exists(pluginProjectPath))
{
_ = MessageBox.Show("A plugin with the same name already exists. Please choose a different name.", "Plugin Manager", MessageBoxButton.OK, MessageBoxImage.Error);
App.Logger.LogWarn($"Plugin project already exists: {pluginProjectPath}", source: "PluginManager");
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = "Plugin Manager",
Content = "A plugin with the same name already exists. Please choose a different name.",
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
return;
}
pluginManagerDataContext.IsLoading = true;
PluginMetadata pluginMetadata = new(pluginName, pluginId);
_ = Directory.CreateDirectory(pluginPath);
_ = Directory.CreateDirectory(pluginProjectPath);
string pluginMetadataPath = Path.Combine(pluginPath, "metadata.json");
File.WriteAllText(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");
string cmd = $"new classlib -n {pluginSafeName} -o {pluginProjectPath} -f net8.0 --target-framework-override net8.0-windows7";
Process process = Process.Start("dotnet", cmd);
process.WaitForExit();
await process.WaitForExitAsync();
// Install the required NuGet packages
App.Logger.LogInfo("Installing NuGet package: DesktopMagic.Api", source: "PluginManager");
process = Process.Start("dotnet", $"add {pluginProjectPath} package DesktopMagic.Api");
process.WaitForExit();
await process.WaitForExitAsync();
File.Move(Path.Combine(pluginProjectPath, "Class1.cs"), Path.Combine(pluginProjectPath, $"{pluginSafeName}.cs"));
@@ -352,26 +526,21 @@ public class {pluginSafeName}Plugin : Plugin
public override Bitmap Main()
{{
Bitmap bmp = new Bitmap(2000, 1000);
bmp.SetResolution(100, 100); // Set DPI to avoid scaling issues.
using (Graphics g = Graphics.FromImage(bmp))
{{
g.Clear(Application.Theme.PrimaryColor); // Set the background color to the color specified in the DesktopMagic application.
g.DrawString(""Hello World"", new Font(Application.Theme.Font, 100), Brushes.Black, new PointF(0, 0)); // Draw ""Hello World"" to the image.
g.DrawString(""Hello World!"", new Font(Application.Theme.Font, 100), new SolidBrush(Application.Theme.PrimaryColor), new PointF(0, 0)); // Draw ""Hello World"" to the image.
}}
bmp.SetResolution(300, 300); // Set DPI to avoid scaling issues.
return bmp; // Return the image.
}}
}}
";
// Update the .csproj file
string csprojPath = Path.Combine(pluginProjectPath, $"{pluginSafeName}.csproj");
File.WriteAllText(Path.Combine(pluginProjectPath, $"{pluginSafeName}.cs"), code);
await File.WriteAllTextAsync(Path.Combine(pluginProjectPath, $"{pluginSafeName}.cs"), code);
XDocument doc = XDocument.Load(csprojPath);
XElement? propertyGroup = doc.Root?.Element("PropertyGroup");
@@ -412,23 +581,38 @@ public class {pluginSafeName}Plugin : Plugin
}
doc.Save(csprojPath);
App.Logger.LogInfo("Updated .csproj file with custom build settings", source: "PluginManager");
}
else
{
_ = MessageBox.Show("PropertyGroup element not found in .csproj", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
App.Logger.LogError("PropertyGroup element not found in .csproj", source: "PluginManager");
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = "Error",
Content = "PropertyGroup element not found in .csproj",
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
return;
}
// Open the project in the default IDE
App.Logger.LogInfo("Building plugin project", source: "PluginManager");
process = Process.Start("dotnet", $"build {csprojPath} -c Release");
await process.WaitForExitAsync();
changed = true;
await InitializePluginManager();
string? associatedProgram = FileUtilities.GetAssociatedProgram(".csproj");
if (associatedProgram is null)
{
App.Logger.LogInfo($"Opening plugin project in Explorer: {pluginProjectPath}", source: "PluginManager");
_ = Process.Start("explorer.exe", pluginProjectPath);
return;
}
else
{
App.Logger.LogInfo($"Opening plugin project in IDE: {associatedProgram}", source: "PluginManager");
ProcessStartInfo psi = new ProcessStartInfo
{
FileName = associatedProgram,
@@ -438,36 +622,90 @@ public class {pluginSafeName}Plugin : Plugin
_ = Process.Start(psi);
}
App.Logger.LogInfo($"Successfully created plugin: {pluginName}", source: "PluginManager");
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)
{
if (pluginManagerDataContext.IsAuthenticated)
{
App.Logger.LogInfo("Logging out from mod.io", source: "PluginManager");
MainWindowDataContext.GetSettings().ModIoAccessToken = null;
pluginManagerDataContext.IsAuthenticated = false;
return;
}
App.Logger.LogInfo("Starting mod.io authentication", source: "PluginManager");
try
{
InputDialog inputDialog = new((string)FindResource("enterModIoEmail"), "Plugin Manager")
{
Owner = this,
Owner = Window.GetWindow(this),
};
if (inputDialog.ShowDialog() != true)
{
App.Logger.LogInfo("Authentication cancelled by user", source: "PluginManager");
return;
}
App.Logger.LogInfo($"Requesting authentication code for email: {inputDialog.ResponseText}", source: "PluginManager");
await modIoClient.Auth.RequestCode(ModIoApiKey, inputDialog.ResponseText);
inputDialog = new((string)FindResource("enterModIoAccessToken"), "Plugin Manager")
{
Owner = this,
Owner = Window.GetWindow(this),
};
if (inputDialog.ShowDialog() != true)
{
App.Logger.LogInfo("Authentication cancelled by user", source: "PluginManager");
return;
}
@@ -478,11 +716,13 @@ public class {pluginSafeName}Plugin : Plugin
if (accessToken.Value is not null)
{
modIoClient = new Client(new Credentials(ModIoApiKey, accessToken.Value));
App.Logger.LogInfo("Successfully authenticated with mod.io", source: "PluginManager");
}
MainWindowDataContext.GetSettings().ModIoAccessToken = accessToken.Value;
pluginManagerDataContext.IsAuthenticated = true;
App.Logger.LogInfo("Subscribing to installed plugins on mod.io", source: "PluginManager");
foreach (PluginEntryDataContext plugin in pluginManagerDataContext.InstalledPlugins)
{
if (!plugin.IsLocalPlugin)
@@ -490,10 +730,11 @@ public class {pluginSafeName}Plugin : Plugin
try
{
await modIoClient.Games[ModIoGameId].Mods.Subscribe(plugin.Id);
App.Logger.LogInfo($"Subscribed to plugin {plugin.Id}", source: "PluginManager");
}
catch (Exception ex)
{
App.Logger.LogError(ex.Message, source: "PluginManager");
App.Logger.LogError($"Failed to subscribe to plugin {plugin.Id}: {ex.Message}", source: "PluginManager");
}
}
}
@@ -502,8 +743,14 @@ public class {pluginSafeName}Plugin : Plugin
}
catch (Exception ex)
{
App.Logger.LogError(ex.Message, source: "PluginManager");
_ = MessageBox.Show(ex.Message, "Plugin Manager", MessageBoxButton.OK, MessageBoxImage.Error);
App.Logger.LogError($"Authentication failed: {ex.Message}", source: "PluginManager");
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = "Plugin Manager",
Content = ex.Message,
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
}
pluginManagerDataContext.IsLoading = false;
@@ -516,7 +763,12 @@ public class {pluginSafeName}Plugin : Plugin
return;
}
App.Logger.LogInfo("Syncing plugins with mod.io subscriptions", source: "PluginManager");
try
{
IReadOnlyList<Mod> mods = await modIoClient.User.GetSubscriptions(ModFilter.GameId.Eq(ModIoGameId)).ToList();
App.Logger.LogInfo($"Found {mods.Count} subscribed plugins on mod.io", source: "PluginManager");
List<PluginEntryDataContext> pluginsToRemove = [];
@@ -524,6 +776,7 @@ public class {pluginSafeName}Plugin : Plugin
{
if (!plugin.IsLocalPlugin && !mods.Any(m => m.Id == plugin.Id))
{
App.Logger.LogInfo($"Plugin {plugin.Id} is no longer subscribed, marking for removal", source: "PluginManager");
pluginsToRemove.Add(plugin);
}
}
@@ -542,7 +795,21 @@ public class {pluginSafeName}Plugin : Plugin
foreach (Mod mod in notInstalledMods)
{
App.Logger.LogInfo($"Installing subscribed plugin: {mod.Name} (ID: {mod.Id})", source: "PluginManager");
await Install(mod);
}
App.Logger.LogInfo("Plugin sync completed", source: "PluginManager");
}
catch (Exception ex)
{
App.Logger.LogError($"Plugin sync failed: {ex.Message}", source: "PluginManager");
}
}
private string GetCsprojPath(string pluginName)
{
string pluginSafeName = GetPluginSafeName(pluginName);
return Path.Combine(pluginDevelopmentPath, pluginSafeName, $"{pluginSafeName}.csproj");
}
}
@@ -1,6 +1,8 @@
using Modio.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
namespace DesktopMagic.Plugins;
@@ -27,6 +29,14 @@ public class PluginMetadata
public string? Version { get; set; }
public List<string> Tags { get; set; } = [];
[JsonIgnore]
public bool SupportsUnloading => !Tags.Contains("Does Not Support Unloading");
[JsonIgnore]
public bool IsLocalPlugin => string.IsNullOrWhiteSpace(ProfileUri?.ToString());
public PluginMetadata(Mod mod)
{
Name = mod.Name ?? mod.Id.ToString();
@@ -39,6 +49,7 @@ public class PluginMetadata
Description = mod.DescriptionPlaintext;
Summary = mod.Summary;
Version = mod.Modfile?.Version;
Tags = mod.Tags.Select(t => t.Name).Where(t => t is not null).ToList()!;
}
public PluginMetadata(string name, uint id)
+5 -1
View File
@@ -1,6 +1,7 @@
<Window x:Class="DesktopMagic.PluginWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:busyIndicator="https://github.com/moh3ngolshani/BusyIndicator"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:DesktopMagic"
@@ -8,12 +9,14 @@
Title="PluginWindow" Height="450" Width="800"
Background="Transparent"
WindowStyle="None"
ShowInTaskbar="False"
AllowsTransparency="True"
LocationChanged="Window_LocationChanged"
SizeChanged="Window_SizeChanged"
Closing="Window_Closing"
ContentRendered="Window_ContentRendered"
x:Name="window">
<busyIndicator:BusyMask x:Name="busyMask" IsBusy="True" Background="Transparent" BusyContent="" BusyContentMargin="-5" IndicatorType="Cogs">
<Grid>
<Rectangle x:Name="panel" Fill="#4CBAFFEF" Stroke="White" Margin="0,0,0,0" Visibility="Collapsed" />
@@ -23,10 +26,11 @@
<RectangleGeometry x:Name="rectangleGeometry" RadiusX="{Binding ElementName=border, Path=CornerRadius.TopLeft}" RadiusY="{Binding ElementName=border, Path=CornerRadius.TopLeft}" />
</Viewbox.Clip>
<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>
</Viewbox>
</Grid>
</busyIndicator:BusyMask>
<WindowChrome.WindowChrome>
<WindowChrome x:Name="tileBar" CaptionHeight="30" ResizeBorderThickness="{x:Static SystemParameters.WindowResizeBorderThickness}" />
</WindowChrome.WindowChrome>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@ public class SettingElement
private string jsonValue = string.Empty;
[JsonIgnore]
public Setting Input { get; set; }
public Setting? Input { get; set; }
public string Name { 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));
}
}
});
}
}
}
+12 -2
View File
@@ -11,17 +11,27 @@ using Color = System.Drawing.Color;
namespace DesktopMagic.Plugins;
public class Theme : ITheme, INotifyPropertyChanged
public class Theme(string name) : ITheme, INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private Color primaryColor = Color.White;
private Color secondaryColor = Color.White;
private Color secondaryColor = Color.LightGray;
private Color backgroundColor = Color.Transparent;
private string font = "Segoe UI";
private int cornerRadius;
private int margin;
public string Name
{
get => name;
set
{
name = value;
OnPropertyChanged();
}
}
public Color PrimaryColor
{
get => primaryColor;
@@ -0,0 +1,40 @@
<Window x:Class="DesktopMagic.WebPluginWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:busyIndicator="https://github.com/moh3ngolshani/BusyIndicator"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:DesktopMagic"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
mc:Ignorable="d"
Title="WebPluginWindow" Height="450" Width="800"
Background="Transparent"
WindowStyle="None"
ShowInTaskbar="False"
AllowsTransparency="True"
LocationChanged="Window_LocationChanged"
SizeChanged="Window_SizeChanged"
Closing="Window_Closing"
ContentRendered="Window_ContentRendered"
x:Name="window">
<busyIndicator:BusyMask x:Name="busyMask" IsBusy="True" Background="Transparent" BusyContent="" BusyContentMargin="-5" IndicatorType="Cogs">
<Grid>
<Rectangle x:Name="panel" Fill="#4CBAFFEF" Stroke="White" Margin="0,0,0,0" Visibility="Collapsed" />
<Border x:Name="border" Background="Transparent" CornerRadius="10">
<Grid>
<wv2:WebView2 x:Name="webView" DefaultBackgroundColor="Transparent" CoreWebView2InitializationCompleted="WebView_CoreWebView2InitializationCompleted" />
<!--
WORKAROUND: This rectangle exists to allow interaction with the WebView2 when the window is transparent
https://github.com/MicrosoftEdge/WebView2Feedback/issues/997
-->
<Rectangle Fill="#01000000" />
</Grid>
</Border>
</Grid>
</busyIndicator:BusyMask>
<WindowChrome.WindowChrome>
<WindowChrome x:Name="tileBar" CaptionHeight="30" ResizeBorderThickness="{x:Static SystemParameters.WindowResizeBorderThickness}" />
</WindowChrome.WindowChrome>
</Window>
@@ -0,0 +1,818 @@
using DesktopMagic.Api.Settings;
using DesktopMagic.Helpers;
using DesktopMagic.Plugins;
using DesktopMagic.Settings;
using Microsoft.Web.WebView2.Core;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
namespace DesktopMagic;
public partial class WebPluginWindow : Window, IPluginWindow
{
public event Action? PluginLoaded;
public event Action? OnExit;
private readonly PluginSettings settings;
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 PluginMetadata PluginMetadata { get; private set; }
public string PluginFolderPath { get; private set; }
public string ScreenDeviceName => screenDeviceName;
public WebPluginWindow(PluginMetadata pluginMetadata, PluginSettings settings, string pluginFolderPath, System.Drawing.Rectangle screenBounds, string screenDeviceName)
{
InitializeComponent();
Window w = new()
{
Top = -100,
Left = -100,
Width = 0,
Height = 0,
WindowStyle = WindowStyle.ToolWindow,
ShowInTaskbar = false
};
WindowInteropHelper helper = new WindowInteropHelper(w);
_ = helper.EnsureHandle();
Owner = w;
settingsPropertyChangedHandler = (_, s) =>
{
if (s.PropertyName == nameof(PluginSettings.CurrentThemeName))
{
SubscribeToTheme(settings.Theme);
ThemeChanged();
}
else if (s.PropertyName == nameof(PluginSettings.Position))
{
UpdatePosition();
}
else if (s.PropertyName == nameof(PluginSettings.Size))
{
UpdateSize();
}
};
settings.PropertyChanged += settingsPropertyChangedHandler;
themePropertyChangedHandler = (_, _) => ThemeChanged();
SubscribeToTheme(settings.Theme);
PluginMetadata = pluginMetadata;
this.settings = settings;
this.screenBounds = screenBounds;
this.screenDeviceName = screenDeviceName;
Point position = ScreenUtilities.PercentToPosition(settings.Position, screenBounds);
Point size = ScreenUtilities.PercentSizeToSize(settings.Size, screenBounds);
Left = position.X;
Top = position.Y;
Width = size.X;
Height = size.Y;
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()
{
IsRunning = false;
Dispatcher.Invoke(() =>
{
OnExit?.Invoke();
});
}
public void SetEditMode(bool enabled)
{
if (enabled)
{
Topmost = true;
panel.Visibility = Visibility.Visible;
_ = W32.EnableWindow(webView.Handle, false);
WindowPos.SetIsLocked(this, false);
tileBar.CaptionHeight = tileBar.CaptionHeight = ActualHeight - 10 < 0 ? 0 : ActualHeight - 10;
ResizeMode = ResizeMode.CanResize;
}
else
{
Topmost = false;
panel.Visibility = Visibility.Collapsed;
_ = W32.EnableWindow(webView.Handle, true);
WindowPos.SendWpfWindowBack(this);
WindowPos.SendWpfWindowBack(this);
WindowPos.SetIsLocked(this, true);
tileBar.CaptionHeight = 0;
ResizeMode = ResizeMode.NoResize;
}
}
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)
{
base.OnSourceInitialized(e);
WindowInteropHelper helper = new(this);
_ = WindowPos.SetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE,
WindowPos.GetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE) | WindowPos.WS_EX_NOACTIVATE);
}
private async void Window_ContentRendered(object? sender, EventArgs e)
{
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Starting web plugin", source: "WebPlugin");
try
{
await InitializeWebView();
}
catch (Exception ex)
{
App.Logger.LogError($"\"{PluginMetadata.Name}\" - {ex}", source: "WebPlugin");
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = $"Error \"{PluginMetadata.Name}\"",
Content = "WebView2 initialization error:\n" + ex,
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
Exit();
}
}
private void ThemeChanged()
{
webView.Margin = new Thickness(settings.Theme.Margin);
border.Background = new SolidColorBrush(MultiColorConverter.ConvertToMediaColor(settings.Theme.BackgroundColor));
border.CornerRadius = new CornerRadius(settings.Theme.CornerRadius);
string cssVariables = $@"
:root {{
--background-color: {MultiColorConverter.ConvertToHexRgba(settings.Theme.BackgroundColor)};
--primary-color: {MultiColorConverter.ConvertToHexRgba(settings.Theme.PrimaryColor)};
--secondary-color: {MultiColorConverter.ConvertToHexRgba(settings.Theme.SecondaryColor)};
--font-family: {settings.Theme.Font};
font-family: {settings.Theme.Font};
color: {MultiColorConverter.ConvertToHexRgba(settings.Theme.PrimaryColor)};
}}
";
string script = $@"
(function() {{
let style = document.getElementById('desktop-magic-theme');
if (!style) {{
style = document.createElement('style');
style.id = 'desktop-magic-theme';
document.head.appendChild(style);
}}
style.textContent = `{cssVariables}`;
}})();
";
_ = webView.ExecuteScriptAsync(script);
}
private async System.Threading.Tasks.Task InitializeWebView()
{
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Initializing WebView2", source: "WebPlugin");
string htmlPath = Path.Combine(PluginFolderPath, "main.html");
if (!File.Exists(htmlPath))
{
App.Logger.LogError($"\"{PluginMetadata.Name}\" - File \"main.html\" does not exist", source: "WebPlugin");
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
Title = $"Error \"{PluginMetadata.Name}\"",
Content = "File \"main.html\" does not exist!",
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
Exit();
return;
}
try
{
LoadWebPluginSettings();
string userDataFolder = Path.Combine(Path.GetTempPath(), "DesktopMagic", "WebView2", PluginMetadata.Id.ToString());
CoreWebView2Environment environment = await CoreWebView2Environment.CreateAsync(null, userDataFolder);
await webView.EnsureCoreWebView2Async(environment);
webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
webView.CoreWebView2.Settings.AreDevToolsEnabled = false;
webView.CoreWebView2.Settings.IsStatusBarEnabled = false;
webView.CoreWebView2.Settings.AreDefaultScriptDialogsEnabled = true;
_ = await webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(GetSettingsBridgeScript());
string htmlUri = new Uri(htmlPath).AbsoluteUri;
webView.Source = new Uri(htmlUri);
isInitialized = true;
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - WebView2 initialized successfully", source: "WebPlugin");
PluginLoaded?.Invoke();
busyMask.IsBusy = false;
}
catch (Exception ex)
{
App.Logger.LogError($"\"{PluginMetadata.Name}\" - WebView2 initialization failed: {ex}", source: "WebPlugin");
throw;
}
}
private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Stopping web plugin", source: "WebPlugin");
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
{
if (isInitialized && webView.CoreWebView2 != null)
{
webView.Dispose();
}
}
catch (Exception ex)
{
App.Logger.LogError($"\"{PluginMetadata.Name}\" - {ex}", source: "WebPlugin");
}
}
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)
{
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)
{
if (!isUpdatingPosition)
{
settings.Size = ScreenUtilities.SizeToPercent(new Point(Width, Height), screenBounds);
}
tileBar.CaptionHeight = ActualHeight - 10;
}
private bool firstLoad = true;
private void WebView_CoreWebView2InitializationCompleted(object sender, CoreWebView2InitializationCompletedEventArgs e)
{
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,4 +3,5 @@
<BitmapImage x:Key="Icon" UriSource="/icon.ico" />
<BitmapImage x:Key="ModioCogBlue" UriSource="modio-cog-blue.png" />
<BitmapImage x:Key="ModioLogoBlueDark" UriSource="modio-logo-bluedark.png" />
<BitmapImage x:Key="ModioLogoBlueLight" UriSource="modio-logo-bluelight.png" />
</ResourceDictionary>
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

@@ -3,6 +3,10 @@
xmlns:col="clr-namespace:System.Collections;assembly=mscorlib"
xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="developedBy">Entwickelt von Stone__Red</system:String>
<system:String x:Key="reportBug">Bug Melden</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="mirrorMode">Spiegeln</system:String>
<system:String x:Key="folder">Ordner</system:String>
@@ -15,29 +19,45 @@
<system:String x:Key="enterModIoEmail">Mod.io Account E-Mail eingeben</system:String>
<system:String x:Key="enterModIoAccessToken">Geben Sie Ihren mod.io Sicherheitscode ein</system:String>
<system:String x:Key="toggleEditMode">Bearbeitungsmodus umschalten</system:String>
<system:String x:Key="allPlugins">Alle Plugins</system:String>
<system:String x:Key="installedPlugins">Installierte Plugins</system:String>
<system:String x:Key="updatePlugins">Neu Laden</system:String>
<system:String x:Key="searchAllPlugins">Alle Plugins Suchen...</system:String>
<system:String x:Key="searchInstalledPlugins">Installierte Plugins Suchen...</system:String>
<system:String x:Key="reloadPlugins">Plugins Neu Laden</system:String>
<system:String x:Key="pluginsFolder">Plugins Ordner</system:String>
<system:String x:Key="pluginManager">Plugins Manager</system:String>
<system:String x:Key="createNewPlugin">Neues Plugin erstellen</system:String>
<system:String x:Key="enterPluginName">Pluginnamen eingeben</system:String>
<system:String x:Key="plugins">Plugins</system:String>
<system:String x:Key="enablePluginToConfigure">Aktivieren Sie das Plugin, um seine Einstellungen zu konfigurieren.</system:String>
<system:String x:Key="color">Farbe:</system:String>
<system:String x:Key="default">Standard</system:String>
<system:String x:Key="wantToCloseProgram">Wollen sie das Programm wirklich schließen?</system:String>
<system:String x:Key="noOptions">Keine Optionen!</system:String>
<system:String x:Key="edit">Bearbeiten</system:String>
<system:String x:Key="editLayout">Layout Bearbeiten</system:String>
<system:String x:Key="time">Uhrzeit</system:String>
<system:String x:Key="date">Datum</system:String>
<system:String x:Key="cpuUsage">CPU Auslastung</system:String>
<system:String x:Key="musicVisualizer">Musik Visualisierer</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="newLayout">Neues Layout</system:String>
<system:String x:Key="deleteLayout">Layout Löschen</system:String>
<system:String x:Key="newTheme">Neues Theme</system:String>
<system:String x:Key="deleteTheme">Theme Löschen</system:String>
<system:String x:Key="editTheme">Theme Bearbeiten</system:String>
<system:String x:Key="themes">Themes</system:String>
<system:String x:Key="ok">Ok</system:String>
<system:String x:Key="cancel">Abbrechen</system:String>
<system:String x:Key="confirmDeleteLayout">Möchten Sie dieses Layout 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="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="themeAlreadyExists">Theme existiert bereits!</system:String>
<system:String x:Key="install">Installieren</system:String>
<system:String x:Key="uninstall">Deinstallieren</system:String>
<system:String x:Key="remove">Entfernen</system:String>
@@ -45,6 +65,11 @@
<system:String x:Key="addedFormat">Erstellt: {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="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">
<system:String>Unten</system:String>
<system:String>Mitte</system:String>
@@ -3,6 +3,10 @@
xmlns:col="clr-namespace:System.Collections;assembly=mscorlib"
xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="developedBy">Developed by Stone__Red</system:String>
<system:String x:Key="reportBug">Report Bug</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="mirrorMode">Mirror</system:String>
<system:String x:Key="folder">Folder</system:String>
@@ -15,29 +19,47 @@
<system:String x:Key="enterModIoEmail">Enter your mod.io account E-mail</system:String>
<system:String x:Key="enterModIoAccessToken">Enter your mod.io security code</system:String>
<system:String x:Key="toggleEditMode">Toggle Edit Mode</system:String>
<system:String x:Key="allPlugins">All Plugins</system:String>
<system:String x:Key="installedPlugins">Installed Plugins</system:String>
<system:String x:Key="updatePlugins">Reload</system:String>
<system:String x:Key="searchAllPlugins">Search All Plugins...</system:String>
<system:String x:Key="searchInstalledPlugins">Search Installed Plugins...</system:String>
<system:String x:Key="reloadPlugins">Reload Plugins</system:String>
<system:String x:Key="pluginsFolder">Plugins Folder</system:String>
<system:String x:Key="pluginManager">Plugins Manager</system:String>
<system:String x:Key="createNewPlugin">Create New Plugin</system:String>
<system:String x:Key="enterPluginName">Enter plugin name</system:String>
<system:String x:Key="plugins">Plugins</system:String>
<system:String x:Key="enablePluginToConfigure">Enable the plugin to configure its settings.</system:String>
<system:String x:Key="color">Color:</system:String>
<system:String x:Key="default">Default</system:String>
<system:String x:Key="wantToCloseProgram">Do you really want to close the program?</system:String>
<system:String x:Key="noOptions">No Options!</system:String>
<system:String x:Key="edit">Edit</system:String>
<system:String x:Key="editLayout">Edit Layout</system:String>
<system:String x:Key="time">Time</system:String>
<system:String x:Key="date">Date</system:String>
<system:String x:Key="cpuUsage">CPU Usage</system:String>
<system:String x:Key="musicVisualizer">Music Visualizer</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="newLayout">New Layout</system:String>
<system:String x:Key="deleteLayout">Delete Layout</system:String>
<system:String x:Key="newTheme">New Theme</system:String>
<system:String x:Key="deleteTheme">Delete Theme</system:String>
<system:String x:Key="editTheme">Edit Theme</system:String>
<system:String x:Key="themes">Themes</system:String>
<system:String x:Key="ok">Ok</system:String>
<system:String x:Key="cancel">Cancel</system:String>
<system:String x:Key="confirmDeleteLayout">Do you really want to delete this layout?</system:String>
<system:String x:Key="confirmDeleteTheme">Do you really want to delete this theme?</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="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="themeAlreadyExists">Theme already exists!</system:String>
<system:String x:Key="cannotDeleteLastTheme">Can't delete last theme!</system:String>
<system:String x:Key="install">Install</system:String>
<system:String x:Key="uninstall">Uninstall</system:String>
<system:String x:Key="remove">Remove</system:String>
@@ -45,6 +67,11 @@
<system:String x:Key="addedFormat">Added: {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="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">
<system:String>Bottom</system:String>
<system:String>Middle</system:String>
@@ -0,0 +1,131 @@
<ResourceDictionary
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:system="clr-namespace:System;assembly=System.Runtime">
<system:Double x:Key="ToggleButtonWidth">40</system:Double>
<system:Double x:Key="ToggleButtonHeight">20</system:Double>
<Thickness x:Key="ToggleSwitchBorderThemeThickness">1</Thickness>
<Thickness x:Key="ToggleSwitchContentLeftMargin">0,0,8,0</Thickness>
<Style x:Key="ToggleSwitchContentLeftStyle" TargetType="{x:Type ui:ToggleSwitch}">
<Setter Property="FocusVisualStyle" Value="{DynamicResource DefaultControlFocusVisualStyle}" />
<Setter Property="Background" Value="{DynamicResource ToggleSwitchFillOn}" />
<Setter Property="Foreground" Value="{DynamicResource ToggleSwitchContentForeground}" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="{StaticResource ToggleSwitchBorderThemeThickness}" />
<Setter Property="Padding" Value="0" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontSize" Value="{DynamicResource ControlContentThemeFontSize}" />
<Setter Property="OverridesDefaultStyle" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ui:ToggleSwitch}">
<Grid Margin="{TemplateBinding Padding}" Background="Transparent">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ContentPresenter
x:Name="ContentPresenter"
Grid.Column="0"
Margin="{StaticResource ToggleSwitchContentLeftMargin}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
RecognizesAccessKey="True"
TextElement.Foreground="{TemplateBinding Foreground}" />
<Grid
Grid.Column="1"
Width="{StaticResource ToggleButtonWidth}"
Height="{StaticResource ToggleButtonHeight}">
<Rectangle
x:Name="ToggleRectangle"
Width="{StaticResource ToggleButtonWidth}"
Height="{StaticResource ToggleButtonHeight}"
Fill="{DynamicResource ToggleSwitchFillOff}"
RadiusX="10" RadiusY="10"
Stroke="{DynamicResource ToggleSwitchStrokeOff}"
StrokeThickness="1" />
<Rectangle
x:Name="ActiveToggleRectangle"
Width="{StaticResource ToggleButtonWidth}"
Height="{StaticResource ToggleButtonHeight}"
Fill="{TemplateBinding Background}"
Opacity="0.0"
RadiusX="10" RadiusY="10" />
<Rectangle
x:Name="ToggleEllipse"
Width="12" Height="12"
Fill="{DynamicResource ToggleSwitchKnobFillOff}"
RadiusX="7" RadiusY="7">
<Rectangle.RenderTransform>
<TranslateTransform X="-9" />
</Rectangle.RenderTransform>
</Rectangle>
<Rectangle
x:Name="ActiveToggleEllipse"
Width="12" Height="12"
Fill="{DynamicResource ToggleSwitchKnobFillOn}"
Opacity="0.0"
RadiusX="7" RadiusY="7">
<Rectangle.RenderTransform>
<TranslateTransform X="-9" />
</Rectangle.RenderTransform>
</Rectangle>
</Grid>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Trigger.EnterActions>
<BeginStoryboard Name="SlideRight">
<Storyboard>
<DoubleAnimation Storyboard.TargetName="ToggleRectangle" Storyboard.TargetProperty="Opacity" To="0.0" Duration="0:0:0.16" />
<DoubleAnimation Storyboard.TargetName="ActiveToggleRectangle" Storyboard.TargetProperty="Opacity" To="1.0" Duration="0:0:0.16" />
<DoubleAnimation Storyboard.TargetName="ToggleEllipse" Storyboard.TargetProperty="Opacity" To="0.0" Duration="0:0:0.16" />
<DoubleAnimation Storyboard.TargetName="ActiveToggleEllipse" Storyboard.TargetProperty="Opacity" To="1.0" Duration="0:0:0.16" />
<DoubleAnimation Storyboard.TargetName="ActiveToggleEllipse" Storyboard.TargetProperty="(Rectangle.RenderTransform).(TranslateTransform.X)" To="9" Duration="0:0:0.167" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="ToggleRectangle" Storyboard.TargetProperty="Opacity" To="1.0" Duration="0:0:0.16" />
<DoubleAnimation Storyboard.TargetName="ActiveToggleRectangle" Storyboard.TargetProperty="Opacity" To="0.0" Duration="0:0:0.16" />
<DoubleAnimation Storyboard.TargetName="ToggleEllipse" Storyboard.TargetProperty="Opacity" To="1.0" Duration="0:0:0.16" />
<DoubleAnimation Storyboard.TargetName="ActiveToggleEllipse" Storyboard.TargetProperty="Opacity" To="0.0" Duration="0:0:0.16" />
<DoubleAnimation Storyboard.TargetName="ActiveToggleEllipse" Storyboard.TargetProperty="(Rectangle.RenderTransform).(TranslateTransform.X)" To="-9" Duration="0:0:0.167" />
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="Content" Value="{x:Null}" />
<Condition Property="IsChecked" Value="True" />
</MultiTrigger.Conditions>
<Setter TargetName="ContentPresenter" Property="Content" Value="{Binding OnContent, RelativeSource={RelativeSource TemplatedParent}}" />
</MultiTrigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="Content" Value="{x:Null}" />
<Condition Property="IsChecked" Value="False" />
</MultiTrigger.Conditions>
<Setter TargetName="ContentPresenter" Property="Content" Value="{Binding OffContent, RelativeSource={RelativeSource TemplatedParent}}" />
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
@@ -1,4 +1,7 @@
using System.Collections.ObjectModel;
using DesktopMagic.Plugins;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
@@ -6,19 +9,39 @@ using System.Text.Json.Serialization;
namespace DesktopMagic.Settings;
internal class DesktopMagicSettings : INotifyPropertyChanged
public class DesktopMagicSettings : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private ObservableCollection<Layout> layouts = [];
private ObservableCollection<Theme> themes = [];
private string? currentLayoutName;
public ObservableCollection<Theme> Themes
{
get => themes;
init
{
themes = value;
themes.CollectionChanged += (s, e) =>
{
foreach (Layout layout in layouts)
{
layout.UpdateTheme();
}
};
OnPropertyChanged();
}
}
public ObservableCollection<Layout> Layouts
{
get => layouts;
set
{
layouts = value;
layouts.CollectionChanged += (s, e) => OnPropertyChanged(nameof(CurrentLayout));
layouts.CollectionChanged += (s, e) => OnPropertyChanged(nameof(CurrentLayoutName));
OnPropertyChanged();
}
}
@@ -28,7 +51,16 @@ internal class DesktopMagicSettings : INotifyPropertyChanged
public string? CurrentLayoutName
{
get => currentLayoutName ?? Layouts.FirstOrDefault()?.Name;
get
{
if (!Layouts.Any(l => l.Name == currentLayoutName))
{
currentLayoutName = null;
}
return currentLayoutName ?? Layouts.FirstOrDefault()?.Name;
}
set
{
currentLayoutName = value;
@@ -37,8 +69,38 @@ internal 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? ReleaseInfoLastAppVersion { get; set; }
public bool IsFirstRun { get; set; } = true;
public DesktopMagicSettings()
{
themes.CollectionChanged += (s, e) =>
{
foreach (Layout layout in layouts)
{
layout.UpdateTheme();
}
};
layouts.CollectionChanged += (s, e) => OnPropertyChanged(nameof(CurrentLayout));
layouts.CollectionChanged += (s, e) => OnPropertyChanged(nameof(CurrentLayoutName));
}
protected void OnPropertyChanged([CallerMemberName] string? name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+52 -7
View File
@@ -1,27 +1,43 @@
using DesktopMagic.Plugins;
using DesktopMagic.DataContexts;
using DesktopMagic.Plugins;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
namespace DesktopMagic.Settings;
internal class Layout(string name) : INotifyPropertyChanged
public class Layout(string name) : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private string name = name;
private Theme theme = new Theme();
private string? currentThemeName = null;
private Dictionary<uint, PluginSettings> plugins = [];
private double screenAspectRatio = 0;
[JsonIgnore]
public Theme Theme
{
get => theme;
get
{
DesktopMagicSettings settings = MainWindowDataContext.GetSettings();
return settings.Themes.FirstOrDefault(theme => theme.Name == currentThemeName) ?? settings.Themes.FirstOrDefault() ?? new Theme("ERROR");
}
}
public string? CurrentThemeName
{
get => Theme.Name;
set
{
theme = value;
OnPropertyChanged();
if (currentThemeName != value)
{
currentThemeName = value;
UpdateTheme();
}
}
}
@@ -45,9 +61,38 @@ internal 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()
{
Plugins = Plugins.ToDictionary();
plugins = plugins.ToDictionary();
OnPropertyChanged(nameof(Plugins));
}
public void UpdateTheme()
{
OnPropertyChanged(nameof(Theme));
OnPropertyChanged(nameof(CurrentThemeName));
foreach (PluginSettings pluginSettings in plugins.Values)
{
pluginSettings.UpdateTheme();
}
}
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+64 -7
View File
@@ -3,7 +3,9 @@ using DesktopMagic.Plugins;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows;
@@ -13,18 +15,44 @@ public class PluginSettings : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private readonly Theme? theme;
private string? currentThemeName;
private List<SettingElement> settings = [];
private Dictionary<string, JsonElement> state = [];
private bool enabled = false;
private Point position = new Point(100, 100);
private Point size = new Point(300, 300);
[JsonIgnore]
public Theme Theme => theme is null ? MainWindowDataContext.GetSettings().CurrentLayout.Theme : theme;
private Point position = new Point(0.05, 0.05);
private Point size = new Point(0.3, 0.3);
// Only for internal use to show the name of the plugin in the main window
[JsonIgnore]
public string Name { get; set; } = string.Empty;
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]
public Theme Theme
{
get
{
DesktopMagicSettings settings = MainWindowDataContext.GetSettings();
return settings.Themes.FirstOrDefault(t => t.Name == currentThemeName) ?? Owner?.Theme ?? settings.CurrentLayout.Theme;
}
}
public string? CurrentThemeName
{
get => currentThemeName;
set
{
if (currentThemeName != value)
{
currentThemeName = value;
OnPropertyChanged();
OnPropertyChanged(nameof(Theme));
}
}
}
public List<SettingElement> Settings
{
@@ -39,6 +67,23 @@ public class PluginSettings : INotifyPropertyChanged
}
}
/// <summary>
/// Dictionary storing plugin state. Key is the field/property name, value is the JsonElement.
/// This is persisted per layout in settings.json.
/// </summary>
public Dictionary<string, JsonElement> State
{
get => state;
set
{
if (state != value)
{
state = value;
OnPropertyChanged();
}
}
}
public bool Enabled
{
get => enabled;
@@ -52,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
{
get => position;
@@ -65,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
{
get => size;
@@ -78,6 +129,12 @@ public class PluginSettings : INotifyPropertyChanged
}
}
public void UpdateTheme()
{
OnPropertyChanged(nameof(Theme));
OnPropertyChanged(nameof(CurrentThemeName));
}
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+3 -3
View File
@@ -14,10 +14,10 @@ namespace DesktopMagic.PluginTest;
public class GifPlugin : Plugin
{
[Setting("gif-path", "GIF path")]
private readonly TextBox input = new TextBox("");
private readonly FileSelector input = new FileSelector();
[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 = [];
@@ -92,7 +92,7 @@ public class GifPlugin : Plugin
info.Value = $"Loading frame {i + 1} of {gif.GetFrameCount(FrameDimension.Time)}";
}
info.Value = string.Empty;
info.Value = $"Loaded {bitmaps.Count} frames.";
}
else
{
+47
View File
@@ -0,0 +1,47 @@
using System;
using System.Drawing;
using System.Threading;
using System.Threading.Tasks;
namespace DesktopMagic.Api;
/// <summary>
/// Provides an abstract base class for creating asynchronous plugins that can be integrated into the application, supporting periodic updates,
/// rendering, and user interaction.
/// </summary>
/// <remarks>Derive from this class to implement custom plugin functionality. The class defines lifecycle methods
/// such as <see cref="Plugin.Start"/>, <see cref="Plugin.Stop"/>, and <see cref="Main"/> for activation, deactivation, and periodic
/// execution. It also provides event handlers for mouse and theme interactions, as well as access to application data
/// and rendering configuration. Implementations should override relevant methods to respond to user input, update
/// intervals, and configuration changes as needed.</remarks>
public abstract class AsyncPlugin : Plugin
{
/// <summary>
/// Occurs once when the plugin gets activated. Override for async initialization.
/// </summary>
/// <param name="cancellationToken">Token signaled when the host requests cancellation.</param>
public virtual Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>
/// Occurs once when the plugin gets deactivated. Override for async cleanup.
/// </summary>
/// <param name="cancellationToken">Token signaled when the host requests cancellation.</param>
public virtual Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>
/// Occurs when the <see cref="Plugin.UpdateInterval"/> elapses.
/// </summary>
/// <returns></returns>
public abstract Task<Bitmap?> MainAsync(CancellationToken cancellationToken);
/// <summary>
/// This method should not be called directly! Override and use <see cref="MainAsync"/> for asynchronous plugin operations.
/// </summary>
/// <remarks>This method only exists to fulfill base class requirements.</remarks>
/// <returns>Calling it will always result in an <see cref="InvalidOperationException"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if this method is called directly. Use MainAsync for plugin execution instead.</exception>
public sealed override Bitmap? Main()
{
throw new InvalidOperationException($"AsyncPlugin.Main() should not be called directly. Override {nameof(MainAsync)} instead.");
}
}
@@ -4,15 +4,17 @@
<TargetFramework>net8.0-windows</TargetFramework>
<Company>Stone_Red</Company>
<Product>Stone_Red</Product>
<Version>0.0.0.5</Version>
<Version>1.0.0.0</Version>
<RepositoryUrl>https://github.com/Stone-Red-Code/DesktopMagic</RepositoryUrl>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<AssemblyVersion>0.0.0.5</AssemblyVersion>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<PackageProjectUrl></PackageProjectUrl>
<FileVersion>0.0.0.5</FileVersion>
<FileVersion></FileVersion>
<Nullable>enable</Nullable>
<Platforms>AnyCPU;x64</Platforms>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageIcon>Square44x44Logo.altform-lightunplated_targetsize-256.png</PackageIcon>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -36,9 +38,18 @@
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
<None Include="..\..\README.md">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
<None Include="..\MsixPackaging\Images\Square44x44Logo.altform-lightunplated_targetsize-256.png">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="SkiaSharp" Version="4.148.0" />
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
</ItemGroup>
+23
View File
@@ -36,4 +36,27 @@ public interface IPluginData
/// Updates the plugin window.
/// </summary>
void UpdateWindow();
/// <summary>
/// Logs a message to the application log.
/// </summary>
/// <param name="message">The message to log.</param>
/// <param name="level">The log level (Info, Warning, Error).</param>
void Log(string message, LogLevel level = LogLevel.Info);
/// <summary>
/// Shows a message box to the user.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="title">The title of the message box.</param>
void ShowMessage(string message, string? title = null);
/// <summary>
/// Saves the current state of fields and properties marked with <see cref="PersistStateAttribute"/>
/// </summary>
/// <remarks>
/// State is automatically saved when the plugin stops, but this method can be called
/// to save state at any time, such as after important user interactions.
/// </remarks>
void SaveState();
}
+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,
}
+22
View File
@@ -0,0 +1,22 @@
namespace DesktopMagic.Api;
/// <summary>
/// Specifies the severity level of a log message.
/// </summary>
public enum LogLevel
{
/// <summary>
/// Informational message for general information.
/// </summary>
Info,
/// <summary>
/// Warning message indicating a potential issue.
/// </summary>
Warning,
/// <summary>
/// Error message indicating a failure or critical issue.
/// </summary>
Error
}
@@ -1,4 +1,4 @@
namespace DesktopMagic.Api.Settings;
namespace DesktopMagic.Api;
/// <summary>
/// Mouse Buttons
@@ -0,0 +1,15 @@
using System;
namespace DesktopMagic.Api;
/// <summary>
/// Marks a field or property to be automatically persisted between plugin sessions.
/// </summary>
/// <remarks>
/// The field or property must be JSON serializable. Complex types should have parameterless constructors.
/// State is saved when the plugin stops and loaded when it starts.
/// </remarks>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class PersistStateAttribute : Attribute
{
}
+46 -1
View File
@@ -7,8 +7,14 @@ using System.Drawing;
namespace DesktopMagic.Api;
/// <summary>
/// The plugin class.
/// Provides an abstract base class for creating plugins that can be integrated into the application, supporting periodic updates,
/// rendering, and user interaction.
/// </summary>
/// <remarks>Derive from this class to implement custom plugin functionality. The class defines lifecycle methods
/// such as <see cref="Start"/>, <see cref="Stop"/>, and <see cref="Main"/> for activation, deactivation, and periodic
/// execution. It also provides event handlers for mouse and theme interactions, as well as access to application data
/// and rendering configuration. Implementations should override relevant methods to respond to user input, update
/// intervals, and configuration changes as needed.</remarks>
public abstract class Plugin
{
[Setting("desktopmagic-horizontal-alignment", "Horizontal Alignment", -999)]
@@ -17,6 +23,15 @@ public abstract class Plugin
[Setting("desktopmagic-vertical-alignment", "Vertical Alignment", -998)]
internal ComboBox verticalAlignment = new ComboBox("Center", "Top", "Bottom");
[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>");
private IPluginData application = null!;
/// <summary>
@@ -83,4 +98,34 @@ public abstract class Plugin
public virtual void OnMouseWheel(Point position, int delta)
{
}
/// <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>
/// Occurs when the application's theme has changed.
/// </summary>
public virtual void OnThemeChanged()
{
}
/// <summary>
/// Invoked when the settings have changed to allow derived classes to respond to configuration updates.
/// </summary>
public virtual void OnSettingsChanged()
{
}
}
@@ -46,4 +46,14 @@ public class Button : Setting
{
OnClick?.Invoke();
}
internal override string GetJsonValue()
{
return Value;
}
internal override void SetJsonValue(string value)
{
Value = value;
}
}
@@ -0,0 +1,62 @@
using System.Drawing;
using System.Globalization;
namespace DesktopMagic.Api.Settings;
/// <summary>
/// Represents a color picker control.
/// </summary>
public sealed class ColorPicker : Setting
{
private Color _value;
/// <summary>
/// Gets or sets the color value assigned to the <see cref="ColorPicker"/> element.
/// </summary>
public Color Value
{
get => _value;
set
{
if (_value != value)
{
_value = value;
ValueChanged();
}
}
}
/// <summary>
/// Initializes a new instance of the <see cref="ColorPicker"/> class with the provided <paramref name="defaultColor"/>.
/// </summary>
/// <param name="defaultColor">The default color value.</param>
public ColorPicker(Color defaultColor)
{
_value = defaultColor;
}
/// <summary>
/// Initializes a new instance of the <see cref="ColorPicker"/> class with a default color of White.
/// </summary>
public ColorPicker() : this(Color.White)
{
}
internal override string GetJsonValue()
{
return $"{Value.A},{Value.R},{Value.G},{Value.B}";
}
internal override void SetJsonValue(string value)
{
string[] parts = value.Split(',');
if (parts.Length == 4 &&
byte.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte a) &&
byte.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte r) &&
byte.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte g) &&
byte.TryParse(parts[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte b))
{
Value = Color.FromArgb(a, r, g, b);
}
}
}
@@ -0,0 +1,65 @@
namespace DesktopMagic.Api.Settings;
/// <summary>
/// Represents a file selector control that allows the user to browse for a file.
/// </summary>
public sealed class FileSelector : Setting
{
private string _value = string.Empty;
/// <summary>
/// Gets or sets the selected file path.
/// </summary>
public string Value
{
get => _value;
set
{
if (_value != value)
{
_value = value;
ValueChanged();
}
}
}
/// <summary>
/// Gets or sets the file filter for the file dialog (e.g., "Image Files|*.png;*.jpg|All Files|*.*").
/// </summary>
public string Filter { get; set; }
/// <summary>
/// Gets or sets the title of the file dialog.
/// </summary>
public string Title { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the file selector should select folders instead of files.
/// </summary>
public bool SelectFolder { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="FileSelector"/> class.
/// </summary>
/// <param name="defaultPath">The default file path.</param>
/// <param name="filter">The file filter for the file dialog.</param>
/// <param name="title">The title of the file dialog.</param>
/// <param name="selectFolder">If true, selects folders instead of files.</param>
public FileSelector(string defaultPath = "", string filter = "All Files|*.*", string title = "Select File", bool selectFolder = false)
{
_value = defaultPath;
Filter = filter;
Title = title;
SelectFolder = selectFolder;
}
internal override string GetJsonValue()
{
return Value;
}
internal override void SetJsonValue(string value)
{
Value = value;
}
}
@@ -38,4 +38,14 @@ public class Label : Setting
Value = value;
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
Name="StoneRed.DesktopMagic"
Publisher="CN=6D1FD759-1951-428A-9B03-964461C7DBF5"
Version="1.1.0.0" />
Version="1.3.3.0" />
<Properties>
<DisplayName>Desktop Magic</DisplayName>