145 Commits
Author SHA1 Message Date
Stone_Red 8d691f626a Hide benchmark plugins in release build 2026-10-07 00:06:36 +02:00
Stone_Red 894f215adf Bump versions and installer 2026-10-06 23:54:37 +02:00
Stone_Red 6bca09413d Make occlusion monitor more efficient 2026-10-06 23:18:08 +02:00
Stone_Red a5e56961b6 Make sure numerical settings parsing uses InvariantCulture 2026-10-06 23:15:11 +02:00
Stone_Red 839ffdaaf3 Fix occlusion monitor to also pause web plugins 2026-10-06 23:00:30 +02:00
Stone_Red 7d365643f7 Align skia plugin API with normal plugin API 2026-10-06 22:32:53 +02:00
Stone_Red d0ad90f83a Fix plugin windows not clamping to the selected monitor bounds 2026-10-06 22:18:10 +02:00
Stone_Red 1aa06238c2 Allow sharting of themes and layouts via mod.io 2026-10-04 19:04:50 +02:00
Stone_Red 1507f7a36d Pause plugins when covered by other window 2026-10-04 19:04:02 +02:00
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
Stone_Red d285bdc489 Update version 2025-07-05 17:39:07 +02:00
Stone_Red 0a64184c32 Fix plugin manager crash when opening it 2025-05-17 15:45:43 +02:00
Stone_Red 2df00fbeeb Properly remove the plugin window from the lists in the main window when an exception occurs 2025-02-26 20:22:42 +01:00
Stone_Red 088f51410a Send plugin windows to front when edit mode enabled and to back when disabled 2025-02-26 19:28:42 +01:00
Stone_Red 70d08aa4c3 Set WindowState of main window to Minimized when closing 2025-02-25 23:04:44 +01:00
Stone_Red af9aa60bd9 Rename ModEntry UserControl to PluginEntry 2025-02-25 23:03:14 +01:00
Stone_Red 695cb867ee Add autostart checkbox 2025-02-25 19:20:02 +01:00
Stone_Red 376176460b Update modio.net library links in README.md 2025-02-25 03:09:35 +01:00
Stone_Red 327b79d69c Remove Extended.Wpf.Toolkit from thrid party licenses section in README.md 2025-02-25 03:04:43 +01:00
Stone_Red c74bc60209 Add login/logout button to plugin manger to sync subscriptions 2025-02-24 22:02:58 +01:00
Stone_Red 930da5d679 Correctly update .csproj file when creating new plugin 2025-02-23 15:53:41 +01:00
Stone_Red ffd92d2259 Add create new plugin button to plugin manager 2025-02-23 15:32:56 +01:00
Stone_Red 6100f444a8 Center dialogs on parent window 2025-02-23 15:32:25 +01:00
Stone_Red ead20d1279 Change GetLanguageDictionary method to LanguageDictionary property 2024-12-30 16:54:04 +01:00
Stone_Red b638eff451 Add missing translations 2024-12-28 23:16:23 +01:00
Stone_Red 793dcbad38 Small fixes 2024-12-28 16:40:06 +01:00
Stone_Red c2474e6425 Plugin manager layout improvments 2024-12-05 15:00:48 +01:00
Stone_Red cbeaaee88e Standardize README 2024-12-03 00:43:51 +01:00
Stone_Red 92c13c5ed0 Change app name from "Desktop Magic" to "DesktopMagic" and some QOL improvments 2024-12-03 00:16:37 +01:00
Stone_Red 26e979af79 Standardize default plugin setting names 2024-12-03 00:16:37 +01:00
Stone_Red 29619dbdda Rename download plugins to browse plugins in README.md 2024-11-30 00:38:56 +01:00
Stone_Red bc11832f9f Add right click actions to tray icon 2024-11-29 18:40:00 +01:00
Stone_Red f8f06a9e2c Improve plugin window performance by not constantly updating properties of UI elements 2024-11-29 17:55:33 +01:00
Stone_Red 35d64ef2c3 Don't unnecessarily redraw time plugin bitmap every second 2024-11-29 17:55:33 +01:00
Stone_Red 3b9d49e8da Update README.md 2024-11-29 00:57:02 +01:00
Stone_Red aada1b8807 Add Microsoft Store link and fix twitter/x link 2024-11-28 17:47:36 +01:00
Stone_Red 9abfb9cb66 Merge pull request #7 from Stone-Red-Code/main
Readme updates
2024-11-18 23:00:21 +01:00
Stone_Red ce1c181b14 Add MsixPackaging project 2024-11-16 14:36:04 +01:00
Stone_Red e6be5c011b Replace deprecated Modio library with alternative library 2024-11-15 19:55:12 +01:00
Stone_Red c6e858764f Remove unnecessary files 2024-11-15 19:53:19 +01:00
Stone_Red f684cdb051 Show error when failing to delete plugin files 2024-11-15 19:41:34 +01:00
Stone_Red 3993e6d612 Fix multiple warnings 2024-11-15 19:41:16 +01:00
Stone_Red 8e187f91fa Update README.md 2024-10-31 13:19:28 +01:00
Stone_Red 566bcee426 Update README.md 2024-10-30 21:51:00 +01:00
Stone_Red 038074cd87 Update README.md 2024-10-30 21:50:22 +01:00
Stone_Red 7e1736696d Rename BuiltInWindowElements to BuiltInPlugins 2024-10-26 14:43:14 +02:00
Stone_Red e545f27461 Replace Xceed.Wpf.Toolkit.IntegerUpDown with MaterialDesignThemes.Wpf.NumericUpDown 2024-10-26 00:25:53 +02:00
Stone_Red d458feca1e Add DesktopMagicPluginAPI.xml to .gitignore 2024-10-25 23:28:52 +02:00
Stone_Red f899fd3e9b Merge branch 'main' into develop 2024-10-25 23:22:23 +02:00
Stone_Red c3b867ff67 Merge pull request #6 from dylanrenwick/main
Updated documentation file path to be relative to project
2024-10-25 23:19:29 +02:00
drenwick 1fe59e6338 Updated documentation file path to be relative to project 2024-10-25 15:10:58 -04:00
162 changed files with 11795 additions and 1474 deletions
+3
View File
@@ -6,6 +6,9 @@
bin/
obj/
# Project-specific files
/src/DesktopMagicPluginAPI/DesktopMagicPluginAPI.xml
# User-specific files
*.rsuser
*.suo
+3
View File
@@ -0,0 +1,3 @@
[submodule "lib/modio.net"]
path = lib/modio.net
url = https://github.com/nickelc/modio.net.git
+18 -15
View File
@@ -1,31 +1,34 @@
# DesktopMagic
> DesktopMagic allows you to improve the looks of your Windows desktop!\
> You can add useful elements to your desktop like a clock, an audio visualizer or even your own plugins using mod.io!
![DesktopMagic](https://socialify.git.ci/Stone-Red-Code/DesktopMagic/image?description=1&descriptionEditable=DesktopMagic%20allows%20you%20to%20improve%20the%20looks%20of%20your%20desktop!&forks=1&issues=1&logo=https%3A%2F%2Fgithub.com%2Fuser-attachments%2Fassets%2F34f3c473-a29a-4d71-9ec0-1387b04ad2c5&name=1&pattern=Formal%20Invitation&pulls=1&stargazers=1&theme=Auto)
> You can add useful widgets to your desktop like a clock, an audio visualizer or even your own plugins using mod.io!
- [Releases](https://github.com/Stone-Red-Code/DesktopMagic/releases)
- [Download plugins](https://mod.io/g/desktopmagic)
- [Browse plugins](https://mod.io/g/desktopmagic)
- [Plugin creation guide](https://mod.io/g/desktopmagic/r/plugin-creation-guide)
- [Licence](https://github.com/Stone-Red-Code/DesktopMagic/blob/main/LICENSE)
## Setup
## Usage
1. Download one of the [releases](https://github.com/Stone-Red-Code/DesktopMagic/releases).
1. Execute the `DesktopMagic-Installer.exe` file.
1. (Optional) Put a shortcut to the `DesktopMagic.exe` file in the install directory into the `shell:startup` directory.
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. 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.
## Follow the development
[![Twitter(X)](http://img.shields.io/badge/Twitter-black.svg?&logo=x&style=for-the-badge&logoColor=white)](https://twitter.com/search?q=%23DesktopMagic%20%40StoneRedCode&f=live)
[![Twitter(X)](http://img.shields.io/badge/Twitter-black.svg?&logo=x&style=for-the-badge&logoColor=white)](https://x.com/search?q=from%3A%40StoneRedCode%20%23DesktopMagic)
## 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)
- [Extended.Wpf.Toolkit](https://github.com/xceedsoftware/wpftoolkit) - [XCEED SOFTWARE COMMUNITY LICENSE AGREEMENT](https://github.com/xceedsoftware/wpftoolkit/blob/master/license.md)
- [Material Design In XAML Toolkit](https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit) - [MIT](https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/blob/master/LICENSE)
- [Modio.NET](https://github.com/thetestgame/modio.net) - [Apache-2.0](https://github.com/thetestgame/Modio.NET/blob/main/LICENSE-APACHE), [MIT](https://github.com/thetestgame/Modio.NET/blob/main/LICENSE-MIT)
- [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")
Submodule
+1
Submodule lib/modio.net added at cc684b198f
+92 -4
View File
@@ -9,24 +9,112 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DesktopMagic.Api", "Desktop
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DesktopMagic.PluginTest", "DesktopMagicPlugin.Test\DesktopMagic.PluginTest.csproj", "{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}"
EndProject
Project("{C7167F0D-BC9F-4E6E-AFE1-012C56B48DB5}") = "MsixPackaging", "MsixPackaging\MsixPackaging.wapproj", "{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|ARM = Debug|ARM
Debug|ARM64 = Debug|ARM64
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|ARM = Release|ARM
Release|ARM64 = Release|ARM64
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Release|Any CPU.Build.0 = Release|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Debug|Any CPU.ActiveCfg = Debug|x64
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Debug|Any CPU.Build.0 = Debug|x64
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Debug|ARM.ActiveCfg = Debug|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Debug|ARM.Build.0 = Debug|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Debug|ARM64.ActiveCfg = Debug|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Debug|ARM64.Build.0 = Debug|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Debug|x64.ActiveCfg = Debug|x64
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Debug|x64.Build.0 = Debug|x64
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Debug|x86.ActiveCfg = Debug|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Debug|x86.Build.0 = Debug|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Release|Any CPU.ActiveCfg = Release|x64
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Release|Any CPU.Build.0 = Release|x64
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Release|ARM.ActiveCfg = Release|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Release|ARM.Build.0 = Release|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Release|ARM64.ActiveCfg = Release|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Release|ARM64.Build.0 = Release|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Release|x64.ActiveCfg = Release|x64
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Release|x64.Build.0 = Release|x64
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Release|x86.ActiveCfg = Release|Any CPU
{B56B4CCC-CF92-4C05-9DEA-57666D81889A}.Release|x86.Build.0 = Release|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Debug|ARM.ActiveCfg = Debug|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Debug|ARM.Build.0 = Debug|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Debug|ARM64.ActiveCfg = Debug|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Debug|ARM64.Build.0 = Debug|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Debug|x64.ActiveCfg = Debug|x64
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Debug|x64.Build.0 = Debug|x64
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Debug|x86.ActiveCfg = Debug|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Debug|x86.Build.0 = Debug|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Release|Any CPU.Build.0 = Release|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Release|ARM.ActiveCfg = Release|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Release|ARM.Build.0 = Release|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Release|ARM64.ActiveCfg = Release|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Release|ARM64.Build.0 = Release|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Release|x64.ActiveCfg = Release|x64
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Release|x64.Build.0 = Release|x64
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Release|x86.ActiveCfg = Release|Any CPU
{9D193A10-3E8E-447F-89F1-4B316C2EE37D}.Release|x86.Build.0 = Release|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Debug|ARM.ActiveCfg = Debug|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Debug|ARM.Build.0 = Debug|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Debug|ARM64.ActiveCfg = Debug|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Debug|ARM64.Build.0 = Debug|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Debug|x64.ActiveCfg = Debug|x64
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Debug|x64.Build.0 = Debug|x64
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Debug|x86.ActiveCfg = Debug|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Debug|x86.Build.0 = Debug|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Release|Any CPU.Build.0 = Release|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Release|ARM.ActiveCfg = Release|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Release|ARM.Build.0 = Release|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Release|ARM64.ActiveCfg = Release|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Release|ARM64.Build.0 = Release|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Release|x64.ActiveCfg = Release|x64
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Release|x64.Build.0 = Release|x64
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Release|x86.ActiveCfg = Release|Any CPU
{EF8407AB-28AE-4B5E-AC2E-D4C4A575FBC7}.Release|x86.Build.0 = Release|Any CPU
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|ARM.ActiveCfg = Debug|ARM
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|ARM.Build.0 = Debug|ARM
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|ARM.Deploy.0 = Debug|ARM
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|ARM64.ActiveCfg = Debug|ARM64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|ARM64.Build.0 = Debug|ARM64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|ARM64.Deploy.0 = Debug|ARM64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|x64.ActiveCfg = Debug|x64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|x64.Build.0 = Debug|x64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|x64.Deploy.0 = Debug|x64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|x86.ActiveCfg = Debug|x86
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|x86.Build.0 = Debug|x86
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Debug|x86.Deploy.0 = Debug|x86
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|Any CPU.ActiveCfg = Release|x64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|Any CPU.Build.0 = Release|x64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|Any CPU.Deploy.0 = Release|x64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|ARM.ActiveCfg = Release|ARM
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|ARM.Build.0 = Release|ARM
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|ARM.Deploy.0 = Release|ARM
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|ARM64.ActiveCfg = Release|ARM64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|ARM64.Build.0 = Release|ARM64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|ARM64.Deploy.0 = Release|ARM64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|x64.ActiveCfg = Release|x64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|x64.Build.0 = Release|x64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|x64.Deploy.0 = Release|x64
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|x86.ActiveCfg = Release|x86
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|x86.Build.0 = Release|x86
{CC2850EF-1451-4D0C-9474-4CBEBB28A2C8}.Release|x86.Deploy.0 = Release|x86
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+16 -8
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>
</Application>
+67 -23
View File
@@ -5,6 +5,8 @@ using System.IO;
using System.Threading;
using System.Windows;
using Wpf.Ui;
namespace DesktopMagic;
/// <summary>
@@ -14,13 +16,19 @@ public partial class App : Application
{
public const string AppGuid = "{{61FE5CE9-47C3-4255-A1F4-5BCF4ACA0879}";
public const string AppName = "Desktop Magic";
public const string AppName = "DesktopMagic";
// This is the previous name of the application, used to migrate the application data folder
private const string PreviousAppName = "Desktop Magic";
private static readonly string logFilePath = Path.Combine(ApplicationDataPath, $"{AppName}.log");
private readonly Thread? eventThread;
private readonly EventWaitHandle eventWaitHandle;
public static string ApplicationDataPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "StoneRed", AppName);
public static string PluginsPath => Path.Combine(ApplicationDataPath, "Plugins");
public static IContentDialogService DialogService { get; } = new ContentDialogService();
public static Logger Logger { get; } = new Logger()
{
@@ -62,33 +70,51 @@ public partial class App : Application
}
};
public static string ApplicationDataPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "StoneRed", AppName);
public static ResourceDictionary LanguageDictionary
{
get
{
ResourceDictionary dict = [];
string currentCulture = Thread.CurrentThread.CurrentUICulture.ToString();
if (currentCulture.Contains("de"))
{
dict.Source = new Uri("..\\Resources\\Strings\\StringResources.de.xaml", UriKind.Relative);
}
else
{
dict.Source = new Uri("..\\Resources\\Strings\\StringResources.en.xaml", UriKind.Relative);
}
return dict;
}
}
private static string PreviousApplicationDataPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "StoneRed", PreviousAppName);
public App()
{
// Setup global event handler
eventWaitHandle = new EventWaitHandle(false, EventResetMode.AutoReset, AppGuid, out bool createdNew);
//check if creating new was successful
// Check if creating new was successful
if (!createdNew)
{
Setup(false);
Logger.LogWarn("Shutting down because other instance already running.", source: "Setup");
//Shutdown Application
// Shutdown Application
_ = eventWaitHandle.Set();
Current.Shutdown();
}
else
{
eventThread = new Thread(
() =>
eventThread = new Thread(() =>
{
while (eventWaitHandle.WaitOne())
{
while (eventWaitHandle.WaitOne())
{
_ = Current.Dispatcher.BeginInvoke(
() => ((MainWindow)Current.MainWindow).RestoreWindow());
}
});
_ = Current.Dispatcher.BeginInvoke(() => ((MainWindow)Current.MainWindow).RestoreWindow());
}
});
eventThread.Start();
Setup(true);
@@ -102,21 +128,45 @@ public partial class App : Application
eventThread?.Interrupt();
}
private void Setup(bool clearLogFile)
private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
Exception exception = (Exception)e.ExceptionObject;
Logger.LogFatal(exception + (e.IsTerminating ? "\t Process terminating!" : ""), source: exception.Source ?? "Unknown");
}
private static async void Setup(bool clearLogFile)
{
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
try
{
if (Directory.Exists(PreviousApplicationDataPath) && !Directory.Exists(ApplicationDataPath))
{
Directory.Move(PreviousApplicationDataPath, ApplicationDataPath);
Logger.LogInfo("Migrated ApplicationData folder", source: "Setup");
}
if (!Directory.Exists(ApplicationDataPath))
{
_ = Directory.CreateDirectory(ApplicationDataPath);
Logger.LogInfo("Created ApplicationData folder", source: "Setup");
}
if (!Directory.Exists(PluginsPath))
{
_ = Directory.CreateDirectory(PluginsPath);
Logger.LogInfo("Created Plugins folder", source: "Setup");
}
Logger.LogInfo("Created ApplicationData Folder", source: "Main");
}
catch (Exception ex)
{
_ = MessageBox.Show(ex.ToString());
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);
}
@@ -127,10 +177,4 @@ public partial class App : Application
Logger.LogInfo("Setup complete", source: "Setup");
}
private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
Exception exception = (Exception)e.ExceptionObject;
Logger.LogFatal(exception + (e.IsTerminating ? "\t Process terminating!" : ""), source: exception.Source ?? "Unknown");
}
}
@@ -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,131 @@
using DesktopMagic.Api;
using DesktopMagic.Api.Settings;
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
namespace DesktopMagic.BuiltInPlugins;
#if DEBUG
internal class BenchmarkPlugin : Plugin
{
private readonly Random rng = new(42);
private readonly object rngLock = new();
private readonly System.Diagnostics.Stopwatch frameTimer = System.Diagnostics.Stopwatch.StartNew();
private double fps;
private long lastFrameMs;
[Setting("shape-count", "Shapes")]
private readonly IntegerUpDown shapeCount = new(10, 10000, 1000);
[Setting("shape-type", "Shape Type")]
private readonly ComboBox shapeType = new("Rectangles", "Ellipses", "Lines", "Text", "Mixed");
[Setting("fps-counter", "Fps")]
private readonly Label fpsCounter = new("");
public override int UpdateInterval => 1;
public override Bitmap? Main()
{
long now = frameTimer.ElapsedMilliseconds;
if (lastFrameMs > 0)
{
double elapsed = now - lastFrameMs;
fps = (fps * 0.9) + (1000.0 / Math.Max(elapsed, 1) * 0.1);
}
lastFrameMs = now;
// Render at the full device-pixel window size so both benchmarks cover the
// same on-screen area at the same resolution for a fair comparison.
int w = Application.WindowPixelSize.Width;
int h = Application.WindowPixelSize.Height;
Bitmap bmp = new Bitmap(w, h);
using Graphics g = Graphics.FromImage(bmp);
g.Clear(Color.Transparent);
g.SmoothingMode = SmoothingMode.HighSpeed;
g.CompositingQuality = CompositingQuality.HighSpeed;
g.InterpolationMode = InterpolationMode.Low;
Color color = Application.Theme.PrimaryColor;
int count = shapeCount.Value;
string type = shapeType.Value;
for (int i = 0; i < count; i++)
{
int x, y, rw, rh;
lock (rngLock)
{
x = rng.Next(0, w);
y = rng.Next(0, h);
rw = rng.Next(5, 60);
rh = rng.Next(5, 60);
}
using Brush brush = new SolidBrush(Color.FromArgb(rng.Next(100, 255), color));
switch (type)
{
case "Rectangles":
g.FillRectangle(brush, x, y, rw, rh);
break;
case "Ellipses":
g.FillEllipse(brush, x, y, rw, rh);
break;
case "Lines":
{
int thickness;
lock (rngLock) { thickness = rng.Next(1, 5); }
g.DrawLine(new Pen(brush, thickness), x, y, x + rw, y + rh);
}
break;
case "Text":
{
int fontSize;
lock (rngLock) { fontSize = rng.Next(8, 24); }
using Font font = new("Arial", fontSize);
g.DrawString("Mg", font, brush, x, y);
}
break;
default:
{
int shape, thickness, fontSize;
lock (rngLock)
{
shape = rng.Next(0, 4);
thickness = rng.Next(1, 5);
fontSize = rng.Next(8, 24);
}
if (shape == 0)
{
g.FillRectangle(brush, x, y, rw, rh);
}
else if (shape == 1)
{
g.FillEllipse(brush, x, y, rw, rh);
}
else if (shape == 2)
{
g.DrawLine(new Pen(brush, thickness), x, y, x + rw, y + rh);
}
else
{
using Font font = new("Arial", fontSize);
g.DrawString("Mg", font, brush, x, y);
}
}
break;
}
}
fpsCounter.Value = fps.ToString();
return bmp;
}
}
#endif
@@ -7,7 +7,7 @@ using System.Drawing.Text;
using SettingAttribute = DesktopMagic.Api.Settings.SettingAttribute;
namespace DesktopMagic.BuiltInWindowElements;
namespace DesktopMagic.BuiltInPlugins;
internal class CpuMonitorPlugin : Plugin
{
@@ -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;
@@ -0,0 +1,167 @@
using DesktopMagic.Api;
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 partial class DatePlugin : Plugin
{
[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 bool themeChanged = false;
public override int UpdateInterval => 1000;
public override Bitmap? Main()
{
if (oldDateTime.Date == DateTime.Now.Date && !themeChanged)
{
return null;
}
oldDateTime = DateTime.Now;
themeChanged = false;
CultureInfo culture = CultureInfo.CurrentCulture;
DateTime dateTime = DateTime.Now;
DateTimeFormatInfo dtf = culture.DateTimeFormat;
string pattern = dtf.LongDatePattern;
WeekdayStyle weekdayStyle = Enum.Parse<WeekdayStyle>(weekDayStyleComboBox.Value);
DayStyle dayStyle = Enum.Parse<DayStyle>(dayStyleComboBox.Value);
MonthStyle monthStyle = Enum.Parse<MonthStyle>(monthStyleComboBox.Value);
YearStyle yearStyle = Enum.Parse<YearStyle>(yearStyleComboBox.Value);
// Weekday: ddd, dddd
pattern = WeekdayRegex().Replace(pattern, weekdayStyle switch
{
WeekdayStyle.Long => "dddd",
WeekdayStyle.Short => "ddd",
_ => ""
});
// Day: d, dd
pattern = DayRegex().Replace(pattern, dayStyle switch
{
DayStyle.Numeric => "dd",
_ => ""
});
// Month: M, MM, MMM, MMMM
pattern = MonthRegex().Replace(pattern, monthStyle switch
{
MonthStyle.Long => "MMMM",
MonthStyle.Short => "MMM",
MonthStyle.Numeric => "MM",
_ => ""
});
// Year: yy, yyyy
pattern = YearRegex().Replace(pattern, yearStyle switch
{
YearStyle.FourDigit => "yyyy",
YearStyle.TwoDigit => "yy",
_ => ""
});
// Clean up leftover punctuation/spacing
pattern = CleanupRegex().Replace(pattern, " ").Trim();
if (string.IsNullOrWhiteSpace(pattern))
{
pattern = dtf.LongDatePattern;
}
string date = dateTime.ToString(pattern, culture);
if (useDateSeparators.Value)
{
date = DateSeparatorsRegex().Replace(date, dtf.DateSeparator);
}
Color color = useColorOverrideForWeekend.Value && (dateTime.DayOfWeek == DayOfWeek.Saturday || dateTime.DayOfWeek == DayOfWeek.Sunday)
? weekendColorOverride.Value
: Application.Theme.PrimaryColor;
using Font font = new Font(Application.Theme.Font, 200);
Bitmap bmp = new Bitmap(1, 1);
bmp.SetResolution(100, 100);
using Graphics tmpGr = Graphics.FromImage(bmp);
tmpGr.TextRenderingHint = TextRenderingHint.AntiAlias;
SizeF size = tmpGr.MeasureString(date, font);
bmp = new Bitmap((int)size.Width, (int)size.Height);
bmp.SetResolution(100, 100);
using Graphics gr = Graphics.FromImage(bmp);
using SolidBrush brush = new SolidBrush(color);
gr.TextRenderingHint = TextRenderingHint.AntiAlias;
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 }
}
@@ -9,7 +9,7 @@ using System.Collections.Generic;
using System.Drawing;
using System.Linq;
namespace DesktopMagic.BuiltInWindowElements;
namespace DesktopMagic.BuiltInPlugins;
internal class MusicVisualizerPlugin : Plugin
{
@@ -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,142 @@
using DesktopMagic.Api;
using DesktopMagic.Api.Settings;
using SkiaSharp;
using System;
using System.Drawing;
namespace DesktopMagic.BuiltInPlugins;
#if DEBUG
internal class SkiaBenchmarkPlugin : SkiaPlugin
{
private readonly Random rng = new(42);
private readonly object rngLock = new();
private readonly System.Diagnostics.Stopwatch frameTimer = System.Diagnostics.Stopwatch.StartNew();
private double fps;
private long lastFrameMs;
[Setting("skia-shape-count", "Shapes")]
private readonly IntegerUpDown shapeCount = new(10, 10000, 1000);
[Setting("skia-shape-type", "Shape Type")]
private readonly ComboBox shapeType = new("Rectangles", "Ellipses", "Lines", "Text", "Mixed");
[Setting("skia-fps-counter", "Fps")]
private readonly Label fpsCounter = new("");
public override int UpdateInterval => 1;
private SKSurface? surface;
public override void Start()
{
Size pixelSize = Application.WindowPixelSize;
int w = Math.Max(1, pixelSize.Width);
int h = Math.Max(1, pixelSize.Height);
surface = SKSurface.Create(new SKImageInfo(w, h, SKColorType.Bgra8888, SKAlphaType.Premul));
}
public override SKImage? Main()
{
long now = frameTimer.ElapsedMilliseconds;
if (lastFrameMs > 0)
{
double elapsed = now - lastFrameMs;
fps = (fps * 0.9) + (1000.0 / Math.Max(elapsed, 1) * 0.1);
}
lastFrameMs = now;
// Render at the full device-pixel window size so the benchmark measures
// real on-screen resolution instead of being scaled down.
Size pixelSize = Application.WindowPixelSize;
int w = Math.Max(1, pixelSize.Width);
int h = Math.Max(1, pixelSize.Height);
SKCanvas canvas = surface.Canvas;
canvas.Clear(SKColors.Transparent);
SKColor color = new(
Application.Theme.PrimaryColor.R,
Application.Theme.PrimaryColor.G,
Application.Theme.PrimaryColor.B,
Application.Theme.PrimaryColor.A);
int count = shapeCount.Value;
string type = shapeType.Value;
for (int i = 0; i < count; i++)
{
int x, y, rw, rh;
lock (rngLock)
{
x = rng.Next(0, w);
y = rng.Next(0, h);
rw = rng.Next(5, 60);
rh = rng.Next(5, 60);
}
byte alpha = (byte)rng.Next(100, 255);
using SKPaint paint = new SKPaint
{
Color = color.WithAlpha(alpha),
IsAntialias = false,
Style = SKPaintStyle.Fill
};
switch (type)
{
case "Rectangles":
canvas.DrawRect(x, y, rw, rh, paint);
break;
case "Ellipses":
canvas.DrawOval(x + (rw / 2f), y + (rh / 2f), rw / 2f, rh / 2f, paint);
break;
case "Lines":
paint.Style = SKPaintStyle.Stroke;
paint.StrokeWidth = 2;
canvas.DrawLine(x, y, x + rw, y + rh, paint);
break;
case "Text":
using (SKFont textFont = new SKFont(SKTypeface.Default, 16))
{
canvas.DrawText("Mg", x, y + 16, SKTextAlign.Left, textFont, paint);
}
break;
default:
int shape;
lock (rngLock) { shape = rng.Next(0, 4); }
if (shape == 0)
{
canvas.DrawRect(x, y, rw, rh, paint);
}
else if (shape == 1)
{
canvas.DrawOval(x + (rw / 2f), y + (rh / 2f), rw / 2f, rh / 2f, paint);
}
else if (shape == 2)
{
paint.Style = SKPaintStyle.Stroke;
paint.StrokeWidth = 2;
canvas.DrawLine(x, y, x + rw, y + rh, paint);
}
else
{
using SKFont textFont2 = new SKFont(SKTypeface.Default, 16);
canvas.DrawText("Mg", x, y + 16, SKTextAlign.Left, textFont2, paint);
}
break;
}
}
fpsCounter.Value = fps.ToString();
return surface.Snapshot();
}
}
#endif
@@ -0,0 +1,56 @@
using DesktopMagic.Api;
using DesktopMagic.Api.Settings;
using SkiaSharp;
using System;
namespace DesktopMagic.BuiltInPlugins;
internal class TimePlugin : SkiaPlugin
{
private const float FontSize = 200;
private const float ContentPadding = 8;
[Setting("display-seconds", "Show Seconds")]
private readonly CheckBox displaySecondsCheckBox = new CheckBox(true);
public override int UpdateInterval => 1000;
public override SKImage? Main()
{
string time = GetTime();
using SKTypeface typeface = SKTypeface.FromFamilyName(Application.Theme.Font);
using SKFont font = new(typeface, FontSize);
using SKPaint paint = new()
{
Color = new SKColor(
Application.Theme.PrimaryColor.R,
Application.Theme.PrimaryColor.G,
Application.Theme.PrimaryColor.B,
Application.Theme.PrimaryColor.A),
IsAntialias = true
};
float textWidth = font.MeasureText(time, paint);
SKFontMetrics metrics = font.Metrics;
float textHeight = metrics.Descent - metrics.Ascent;
int width = (int)Math.Ceiling(textWidth) + (int)(ContentPadding * 2);
int height = (int)Math.Ceiling(textHeight) + (int)(ContentPadding * 2);
using SKSurface surface = SKSurface.Create(new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul));
surface.Canvas.Clear(SKColors.Transparent);
// Draw the text at a fixed size into a content-sized image; the window scales it to fit.
surface.Canvas.DrawText(time, ContentPadding, -metrics.Ascent + ContentPadding, SKTextAlign.Left, font, paint);
return surface.Snapshot();
}
private string GetTime()
{
return displaySecondsCheckBox.Value ? DateTime.Now.ToLongTimeString() : DateTime.Now.ToShortTimeString();
}
}
@@ -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"
};
}
}
@@ -1,54 +0,0 @@
using DesktopMagic.Api;
using DesktopMagic.Api.Settings;
using System;
using System.Drawing;
using System.Drawing.Text;
namespace DesktopMagic.BuiltInWindowElements;
internal class DatePlugin : Plugin
{
[Setting("short-date", "Short date")]
private readonly CheckBox shortDatecheckBox = new CheckBox(true);
private DateTime oldDateTime = DateTime.MinValue;
private Color oldColor = Color.White;
private string oldFont = string.Empty;
private bool oldShortDatecheckBoxValue;
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)
{
return null;
}
oldDateTime = DateTime.Now;
oldColor = Application.Theme.PrimaryColor;
oldFont = Application.Theme.Font;
oldShortDatecheckBoxValue = shortDatecheckBox.Value;
string date = shortDatecheckBox.Value ? DateTime.Now.ToShortDateString() : DateTime.Now.ToLongDateString();
Font font = new Font(Application.Theme.Font, 200);
Bitmap bmp = new Bitmap(1, 1);
bmp.SetResolution(100, 100);
using Graphics tmpGr = Graphics.FromImage(bmp);
tmpGr.TextRenderingHint = TextRenderingHint.AntiAlias;
SizeF size = tmpGr.MeasureString(date, font);
bmp = new Bitmap((int)size.Width, (int)size.Height);
bmp.SetResolution(100, 100);
using Graphics gr = Graphics.FromImage(bmp);
gr.TextRenderingHint = TextRenderingHint.AntiAlias;
gr.DrawString(date, font, new SolidBrush(Application.Theme.PrimaryColor), 0, 0);
return bmp;
}
}
@@ -1,40 +0,0 @@
using DesktopMagic.Api;
using DesktopMagic.Api.Settings;
using System;
using System.Drawing;
using System.Drawing.Text;
namespace DesktopMagic.BuiltInWindowElements;
internal class TimePlugin : Plugin
{
[Setting("display-seconds", "Display Seconds")]
private readonly CheckBox displaySecondsCheckBox = new CheckBox(true);
public override int UpdateInterval => 1000;
public override Bitmap Main()
{
string time = displaySecondsCheckBox.Value ? DateTime.Now.ToLongTimeString() : DateTime.Now.ToShortTimeString();
Font font = new Font(Application.Theme.Font, 200);
Bitmap bmp = new Bitmap(1, 1);
bmp.SetResolution(100, 100);
using Graphics tmpGr = Graphics.FromImage(bmp);
tmpGr.TextRenderingHint = TextRenderingHint.AntiAlias;
SizeF size = tmpGr.MeasureString(time, font);
bmp = new Bitmap((int)size.Width, (int)size.Height);
bmp.SetResolution(100, 100);
using Graphics gr = Graphics.FromImage(bmp);
gr.TextRenderingHint = TextRenderingHint.AntiAlias;
gr.DrawString(time, font, new SolidBrush(Application.Theme.PrimaryColor), 0, 0);
return bmp;
}
}
@@ -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,6 +1,10 @@
using DesktopMagic.Settings;
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;
@@ -10,7 +14,34 @@ 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 bool isSharing;
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;
/// <summary>
/// Whether a layout or theme is currently being uploaded to mod.io.
/// </summary>
public bool IsSharing
{
get => isSharing;
set
{
isSharing = value;
OnPropertyChanged();
}
}
public DesktopMagicSettings Settings
{
@@ -22,6 +53,43 @@ internal class MainWindowDataContext : INotifyPropertyChanged
}
}
public ObservableCollection<ScreenDisplay> Screens { get; } = [];
public string? SelectedScreenId
{
get => selectedScreenDeviceName;
set
{
selectedScreenDeviceName = value;
UpdateSelection();
}
}
public ScreenDisplay? SelectedScreen
{
get => Screens.FirstOrDefault(screen => screen.DeviceName == SelectedScreenId);
set
{
if (value is not null)
{
SelectedScreenId = value.DeviceName;
}
OnPropertyChanged();
}
}
public Layout SelectedLayout => Manager.Instance.SelectedLayout;
public string? SelectedLayoutName
{
get => selectedLayoutName;
set
{
selectedLayoutName = value;
OnPropertyChanged();
}
}
public bool IsLoading
{
get => isLoading;
@@ -32,13 +100,195 @@ internal class MainWindowDataContext : INotifyPropertyChanged
}
}
public string? PluginsSearchText
{
get => pluginsSearchText;
set
{
pluginsSearchText = value;
OnPropertyChanged();
}
}
public bool IsAutoStartEnabled
{
get => StartupManager.IsAutoStartEnabled();
set
{
if (value)
{
_ = StartupManager.EnableAutoStart();
}
else
{
_ = StartupManager.DisableAutoStart();
}
OnPropertyChanged();
}
}
public static DesktopMagicSettings GetSettings()
{
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,14 +1,19 @@
using DesktopMagic.Plugins;
using DesktopMagic.Helpers;
using DesktopMagic.Plugins;
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, bool installed = false) : INotifyPropertyChanged
internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand command, PluginEntryDataContext.Mode mode, string? path = null, string? csprojPath = null) : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
@@ -16,20 +21,58 @@ 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"];
public string? Version => pluginMetadata.Version ?? (string)App.LanguageDictionary["unknown"];
public string? Logo => pluginMetadata.IconUri?.ToString();
public DateTime? FormattedDateAdded => pluginMetadata.Added;
public DateTime? FormattedDateUpdated => pluginMetadata.Updated;
public DateTime? DateAdded => pluginMetadata.Added;
public DateTime? DateUpdated => pluginMetadata.Updated;
public uint Id => pluginMetadata.Id;
public string? Path => path;
public bool IsLocalPlugin => pluginMetadata.IsLocalPlugin;
public ICommand Command => command;
public Visibility InstallButtonVisibility => installed ? Visibility.Collapsed : Visibility.Visible;
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 Visibility RemoveButtonVisibility => installed ? Visibility.Visible : Visibility.Collapsed;
public ButtonData OpenButtonData
{
get
{
if (File.Exists(csprojPath))
{
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(new SymbolIcon(SymbolRegular.Open24), "mod.io", true, new CommandHandler(OpenModIoPage));
}
}
}
public bool IsVisible
{
@@ -44,8 +87,65 @@ internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand co
public Visibility Visibility => IsVisible ? Visibility.Visible : Visibility.Collapsed;
private void OpenModIoPage()
{
ProcessStartInfo psi = new ProcessStartInfo
{
UseShellExecute = true,
FileName = pluginMetadata.ProfileUri?.ToString() ?? path
};
_ = 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
{
Mode.Install => (string)App.LanguageDictionary["install"],
Mode.Uninstall => (string)App.LanguageDictionary["uninstall"],
_ => throw new NotImplementedException()
};
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public record ButtonData(SymbolIcon Icon, string Text, bool IsEnabled, ICommand Command);
public enum Mode
{
Install,
Uninstall
}
}
@@ -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;
@@ -12,9 +16,39 @@ internal class PluginManagerDataContext : INotifyPropertyChanged
private string installedPluginsSearchText = string.Empty;
private bool isLoading = true;
private bool isSearching;
private bool isAuthenticated = false;
private ModCategory category = ModCategory.Plugins;
public ObservableCollection<PluginEntryDataContext> AllPlugins { get; } = [];
public ObservableCollection<PluginEntryDataContext> InstalledPlugins { get; } = [];
// Layouts or themes, depending on the selected category.
public ObservableCollection<PluginEntryDataContext> AllShared { get; } = [];
public ObservableCollection<PluginEntryDataContext> InstalledShared { get; } = [];
public ModCategory Category
{
get => category;
set
{
category = value;
OnPropertyChanged();
OnPropertyChanged(nameof(CategoryIndex));
OnPropertyChanged(nameof(ShownAll));
OnPropertyChanged(nameof(ShownInstalled));
}
}
public int CategoryIndex
{
get => (int)category;
set => Category = (ModCategory)value;
}
public ObservableCollection<PluginEntryDataContext> ShownAll => category == ModCategory.Plugins ? AllPlugins : AllShared;
public ObservableCollection<PluginEntryDataContext> ShownInstalled => category == ModCategory.Plugins ? InstalledPlugins : InstalledShared;
public bool IsLoading
{
get => isLoading;
@@ -58,8 +92,45 @@ internal class PluginManagerDataContext : INotifyPropertyChanged
}
}
public bool IsAuthenticated
{
get => isAuthenticated;
set
{
isAuthenticated = value;
OnPropertyChanged();
OnPropertyChanged(nameof(LoginButtonText));
}
}
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)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
}
internal enum ModCategory
{
Plugins,
Layouts,
Themes
}
+28 -8
View File
@@ -7,26 +7,34 @@
<ApplicationIcon>icon.ico</ApplicationIcon>
<RunPostBuildEvent>OnBuildSuccess</RunPostBuildEvent>
<Authors>Stone_Red</Authors>
<Version>1.0.1.240501</Version>
<Version>1.4.0.0</Version>
<PackageProjectUrl>https://github.com/Stone-Red-Code/DesktopMagic</PackageProjectUrl>
<RepositoryUrl>https://github.com/Stone-Red-Code/DesktopMagic</RepositoryUrl>
<AssemblyVersion>1.0.1.0501</AssemblyVersion>
<FileVersion>1.0.1.0501</FileVersion>
<AssemblyVersion>1.4.0.0</AssemblyVersion>
<FileVersion>1.4.0.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>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DefineConstants>DEBUG;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<DefineConstants>DEBUG;TRACE</DefineConstants>
</PropertyGroup>
<ItemGroup>
<None Remove="icon.ico" />
<None Remove="icons8_volunteering_26_85L_icon.ico" />
<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>
@@ -36,17 +44,22 @@
</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="Extended.Wpf.Toolkit" Version="4.6.0" />
<PackageReference Include="MaterialDesignThemes" Version="5.0.0" />
<PackageReference Include="Modio.NET" Version="1.1.1" />
<PackageReference Include="Interop.IWshRuntimeLibrary" Version="1.0.1" />
<PackageReference Include="Ical.Net" Version="4.3.1" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3650.58" />
<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>
<ProjectReference Include="..\DesktopMagicPluginAPI\DesktopMagic.Api.csproj" />
<ProjectReference Include="..\..\lib\modio.net\Modio\Modio.csproj" />
</ItemGroup>
<ItemGroup>
@@ -57,5 +70,12 @@
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Resource>
<Resource Include="Resources\Images\modio-logo-bluedark.png" />
<Resource Include="Resources\Images\modio-logo-bluelight.png" />
</ItemGroup>
</Project>
<ItemGroup>
<None Update="AutoStart.ps1">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+65 -40
View File
@@ -1,49 +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"
SizeToContent="WidthAndHeight">
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"
ExtendsContentIntoTitleBar="True">
<Grid>
<StackPanel Margin="15">
<Label x:Name="label" Content="Label" HorizontalAlignment="Left" VerticalAlignment="Top"/>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<DockPanel>
<Label Content="A:" Width="20" />
<Slider x:Name="alphaSlider" VerticalAlignment="Center" Maximum="255" ValueChanged="ColorSliders_ValueChanged" TickFrequency="1" IsSnapToTickEnabled="True" Background="#00030000" />
</DockPanel>
<DockPanel>
<Label Content="R:" Width="20" />
<Slider x:Name="redSlider" VerticalAlignment="Center" Maximum="255" ValueChanged="ColorSliders_ValueChanged" TickFrequency="1" IsSnapToTickEnabled="True" Background="#00030000" />
</DockPanel>
<DockPanel>
<Label Content="G:" Width="20" />
<Slider x:Name="greenSlider" VerticalAlignment="Center" Maximum="255" ValueChanged="ColorSliders_ValueChanged" TickFrequency="1" IsSnapToTickEnabled="True" Background="#0000FF00" />
</DockPanel>
<DockPanel>
<Label Content="B:" Width="20" />
<Slider x:Name="blueSlider" VerticalAlignment="Center" Maximum="255" ValueChanged="ColorSliders_ValueChanged" TickFrequency="1" IsSnapToTickEnabled="True" Background="#000000FF" />
</DockPanel>
<ui:TitleBar x:Name="titleBar" ShowMinimize="False" ShowMaximize="False">
<ui:TitleBar.Icon>
<ui:ImageIcon Source="{StaticResource Icon}" />
</ui:TitleBar.Icon>
</ui:TitleBar>
<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="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 Grid.Row="0">
<Label Content="A:" Width="20" />
<Slider x:Name="alphaSlider" VerticalAlignment="Center" Maximum="255" ValueChanged="ColorSliders_ValueChanged" TickFrequency="1" IsSnapToTickEnabled="True" />
</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" />
</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" />
</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" />
</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>
<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>
+11 -24
View File
@@ -1,28 +1,31 @@
using DesktopMagic.Helpers;
using System;
using System.Threading;
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; }
public Brush ResultBrush { get; private set; } = Brushes.Transparent;
public ColorDialog(string content, System.Drawing.Color defaultColor, string title = "ColorDialog")
public ColorDialog(string content, System.Drawing.Color defaultColor, string title = App.AppName)
{
InitializeComponent();
Resources.MergedDictionaries.Add(App.LanguageDictionary);
label.Content = content;
titleBar.Title = title;
Title = title;
SetLanguageDictionary();
alphaSlider.Value = defaultColor.A;
redSlider.Value = defaultColor.R;
@@ -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
{
@@ -97,7 +100,7 @@ public partial class ColorDialog : Window
private void SetColorText()
{
if (alphaSlider.Value == 255)
if ((int)alphaSlider.Value == 255)
{
colorHexTextBox.Text = "#";
}
@@ -108,20 +111,4 @@ public partial class ColorDialog : Window
colorHexTextBox.Text += ((int)redSlider.Value).ToString("X2") + ((int)greenSlider.Value).ToString("X2") + ((int)blueSlider.Value).ToString("X2");
}
private void SetLanguageDictionary()
{
ResourceDictionary dict = [];
string currentCulture = Thread.CurrentThread.CurrentCulture.ToString();
if (currentCulture.Contains("de"))
{
dict.Source = new Uri("..\\Resources\\Strings\\StringResources.de.xaml", UriKind.Relative);
}
else
{
dict.Source = new Uri("..\\Resources\\Strings\\StringResources.en.xaml", UriKind.Relative);
}
Resources.MergedDictionaries.Add(dict);
}
}
@@ -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;
}
}
+40 -14
View File
@@ -1,21 +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"
SizeToContent="WidthAndHeight">
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"
ExtendsContentIntoTitleBar="True">
<Grid>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" 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"/>
</StackPanel>
<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" />
<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>
+7 -22
View File
@@ -1,10 +1,8 @@
using System;
using System.Threading;
using System.Windows;
using System.Windows;
namespace DesktopMagic.Dialogs;
public partial class InputDialog : Window
public partial class InputDialog : Wpf.Ui.Controls.FluentWindow
{
public string ResponseText
{
@@ -12,12 +10,15 @@ public partial class InputDialog : Window
set => textBox.Text = value;
}
public InputDialog(string content, string title = "InputDialog")
public InputDialog(string content, string title = App.AppName)
{
InitializeComponent();
Resources.MergedDictionaries.Add(App.LanguageDictionary);
label.Content = content;
titleBar.Title = title;
Title = title;
SetLanguageDictionary();
}
private void OkButton_Click(object sender, RoutedEventArgs e)
@@ -29,20 +30,4 @@ public partial class InputDialog : Window
{
DialogResult = false;
}
private void SetLanguageDictionary()
{
ResourceDictionary dict = [];
string currentCulture = Thread.CurrentThread.CurrentCulture.ToString();
if (currentCulture.Contains("de"))
{
dict.Source = new Uri("..\\Resources\\Strings\\StringResources.de.xaml", UriKind.Relative);
}
else
{
dict.Source = new Uri("..\\Resources\\Strings\\StringResources.en.xaml", UriKind.Relative);
}
Resources.MergedDictionaries.Add(dict);
}
}
@@ -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;
}
}
}
+4
View File
@@ -9,4 +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();
+93
View File
@@ -0,0 +1,93 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
namespace DesktopMagic.Helpers;
internal static class FileUtilities
{
public static bool ExistsOnPath(string fileName)
{
return GetFullPath(fileName) is not null;
}
public static string? GetFullPath(string fileName)
{
if (File.Exists(fileName))
{
return Path.GetFullPath(fileName);
}
string? values = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrEmpty(values))
{
return null;
}
foreach (string path in values.Split(Path.PathSeparator))
{
string fullPath = Path.Combine(path, fileName);
if (File.Exists(fullPath))
{
return fullPath;
}
}
return null;
}
public static bool HasAssociatedProgram(string extension)
{
return GetAssociatedProgram(extension) is not null;
}
public static string? GetAssociatedProgram(string extension)
{
uint pcchOut = 0;
_ = AssocQueryString(AssocF.Verify, AssocStr.Executable, extension, null, null, ref pcchOut);
StringBuilder pszOut = new StringBuilder((int)pcchOut);
_ = AssocQueryString(AssocF.Verify, AssocStr.Executable, extension, null, pszOut, ref pcchOut);
if (File.Exists(pszOut.ToString()))
{
return pszOut.ToString();
}
return null;
}
[DllImport("Shlwapi.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern uint AssocQueryString(AssocF flags, AssocStr str, string pszAssoc, string? pszExtra, [Out] StringBuilder? pszOut, [In][Out] ref uint pcchOut);
[Flags]
public enum AssocF
{
Init_NoRemapCLSID = 0x1,
Init_ByExeName = 0x2,
Open_ByExeName = 0x2,
Init_DefaultToStar = 0x4,
Init_DefaultToFolder = 0x8,
NoUserSettings = 0x10,
NoTruncate = 0x20,
Verify = 0x40,
RemapRunDll = 0x80,
NoFixUps = 0x100,
IgnoreBaseClass = 0x200
}
public enum AssocStr
{
Command = 1,
Executable,
FriendlyDocName,
FriendlyAppName,
NoOpen,
ShellNewValue,
DDECommand,
DDEIfExec,
DDEApplication,
DDETopic
}
}
@@ -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();
}
}
+725
View File
@@ -0,0 +1,725 @@
using DesktopMagic.Dialogs;
using DesktopMagic.Plugins;
using DesktopMagic.Settings;
using Modio;
using Modio.Filters;
using Modio.Models;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using System.Windows;
using File = System.IO.File;
using Path = System.IO.Path;
namespace DesktopMagic.Helpers;
/// <summary>
/// Shared mod.io access: authentication and sharing/syncing of layouts and themes.
/// </summary>
internal static class ModIoService
{
public const uint GameId = 5665;
public const string ApiKey = "88e6ea774c3a502b06114e7fee0829ac";
private const string Source = "ModIo";
private const int MaxSummaryLength = 250;
private static readonly HttpClient httpClient = new();
private static (string Token, uint UserId)? currentUser;
public static bool IsAuthenticated => AppSettings.ModIoAccessToken is not null;
private static DesktopMagicSettings AppSettings => Manager.Instance.Settings;
public static Client CreateClient()
{
Client.Builder builder = CreateBuilder();
if (AppSettings.ModIoAccessToken is string token)
{
_ = builder.WithToken(token);
}
return builder.Build();
}
/// <summary>
/// The shared api.mod.io host is deprecated, requests have to go to the game specific host.
/// </summary>
private static Client.Builder CreateBuilder()
{
return Client.GetBuilder(ApiKey).WithGameHost(GameId);
}
/// <summary>
/// Whether the mod is a plugin (as opposed to a shared layout or theme).
/// </summary>
public static bool IsPluginMod(Mod mod)
{
return !mod.Tags.Any(tag => tag.Name is SharePackage.LayoutTag or SharePackage.ThemeTag);
}
/// <summary>
/// Runs the e-mail security code login. Returns false when cancelled.
/// </summary>
public static async Task<bool> LogInAsync(Window? owner, string title)
{
App.Logger.LogInfo("Starting mod.io authentication", source: Source);
Client client = CreateBuilder().Build();
InputDialog inputDialog = new(GetString("enterModIoEmail"), title)
{
Owner = owner,
};
if (inputDialog.ShowDialog() != true)
{
App.Logger.LogInfo("Authentication cancelled by user", source: Source);
return false;
}
await client.Auth.RequestCode(ApiKey, inputDialog.ResponseText);
inputDialog = new(GetString("enterModIoAccessToken"), title)
{
Owner = owner,
};
if (inputDialog.ShowDialog() != true)
{
App.Logger.LogInfo("Authentication cancelled by user", source: Source);
return false;
}
AccessToken accessToken = await client.Auth.SecurityCode(ApiKey, inputDialog.ResponseText);
if (accessToken.Value is null)
{
return false;
}
AppSettings.ModIoAccessToken = accessToken.Value;
Manager.Instance.SaveSettings();
App.Logger.LogInfo("Successfully authenticated with mod.io", source: Source);
return true;
}
public static void LogOut()
{
App.Logger.LogInfo("Logging out from mod.io", source: Source);
AppSettings.ModIoAccessToken = null;
currentUser = null;
Manager.Instance.SaveSettings();
}
/// <summary>
/// Compares the local content with the last published or downloaded version.
/// </summary>
public static void RefreshShareState(IModIoShareable item)
{
item.ShareState = item.ModIo switch
{
null => ModIoShareState.NotShared,
{ IsOwner: false } => ModIoShareState.NotOwner,
{ PublishedHash: string hash } when hash == SharePackage.Build(item).Hash => ModIoShareState.UpToDate,
_ => ModIoShareState.Modified,
};
}
/// <summary>
/// Publishes the item as a new mod, uploads a new version of the linked mod, or opens the mod page when nothing changed.
/// </summary>
/// <param name="setBusy">Shows or hides the caller's waiting overlay while the upload runs.</param>
public static async Task ShareAsync(IModIoShareable item, Window? owner, Action<bool> setBusy)
{
if (item is Layout { Name: Manager.EmptyLayoutName })
{
await ShowMessageAsync(GetString("cannotShareEmptyLayout"));
return;
}
RefreshShareState(item);
if (item.ShareState == ModIoShareState.UpToDate)
{
OpenProfile(item.ModIo?.ProfileUri);
return;
}
if (!IsAuthenticated && !await LogInAsync(owner, App.AppName))
{
return;
}
SharePackage.Package package = SharePackage.Build(item);
bool isUpdate = item.ModIo is { IsOwner: true };
string? summary = null;
string? changelog = null;
if (isUpdate)
{
InputDialog changelogDialog = new(GetString("enterModIoChangelog")) { Owner = owner };
if (changelogDialog.ShowDialog() != true)
{
return;
}
changelog = changelogDialog.ResponseText.Trim();
}
else
{
string warning = GetString(item is Layout ? "modIoShareLayoutWarning" : "modIoShareThemeWarning");
if (package.LocalPlugins.Count > 0)
{
warning += Environment.NewLine + Environment.NewLine + string.Format(GetString("modIoLocalPluginsWarning"), string.Join(", ", package.LocalPlugins));
}
if (!await ConfirmAsync(warning, GetString("shareOnModIo")))
{
return;
}
InputDialog summaryDialog = new(GetString("enterModIoSummary")) { Owner = owner };
if (summaryDialog.ShowDialog() != true)
{
return;
}
summary = summaryDialog.ResponseText.Trim();
if (string.IsNullOrWhiteSpace(summary))
{
summary = item.Name;
}
if (summary.Length > MaxSummaryLength)
{
summary = summary[..MaxSummaryLength];
}
}
string workingDirectory = Path.Combine(Path.GetTempPath(), App.AppName, Guid.NewGuid().ToString());
setBusy(true);
try
{
_ = Directory.CreateDirectory(workingDirectory);
string contentDirectory = Path.Combine(workingDirectory, "content");
_ = Directory.CreateDirectory(contentDirectory);
await File.WriteAllTextAsync(Path.Combine(contentDirectory, SharePackage.ContentFileName), package.Json.ToJsonString());
string zipPath = Path.Combine(workingDirectory, "content.zip");
ZipFile.CreateFromDirectory(contentDirectory, zipPath);
string logoPath = Path.Combine(workingDirectory, "logo.png");
SharePreviewRenderer.Render(item, logoPath);
Client client = CreateClient();
ModIoLink link;
if (isUpdate)
{
link = item.ModIo!;
}
else
{
App.Logger.LogInfo($"Creating mod.io mod for \"{item.Name}\"", source: Source);
NewMod newMod = new(item.Name, summary!, new FileInfo(logoPath))
{
Visible = Modio.Models.Visibility.Public,
};
newMod.Tags.Add(SharePackage.GetTag(item));
Mod mod = await client.Games[GameId].Mods.Add(newMod);
// A downloaded item shared as a new mod no longer follows the original, which would otherwise be downloaded again.
if (item.ModIo is { IsOwner: false } originalLink)
{
await UnsubscribeAsync(originalLink.ModId);
}
// Save the link right away, so a failed upload is retried on the same mod instead of creating another one.
link = new ModIoLink
{
ModId = mod.Id,
IsOwner = true,
ProfileUri = mod.ProfileUrl,
};
item.ModIo = link;
Manager.Instance.SaveSettings();
}
App.Logger.LogInfo($"Uploading new version of \"{item.Name}\" (mod {link.ModId})", source: Source);
Modio.Models.File modfile = await client.Games[GameId].Mods[link.ModId].Files.Add(new NewFile(new FileInfo(zipPath))
{
Version = DateTime.UtcNow.ToString("yyyy.MM.dd.HHmm"),
Changelog = string.IsNullOrWhiteSpace(changelog) ? null : changelog,
Active = true,
}, new UploadOptions());
link.ModfileId = modfile.Id;
link.PublishedHash = package.Hash;
Manager.Instance.SaveSettings();
if (isUpdate)
{
await TryUpdateLogo(client, link.ModId, logoPath);
}
if (item is Layout)
{
await TryUpdateDependencies(client, link.ModId, package.RequiredPlugins);
}
RefreshShareState(item);
App.Logger.LogInfo($"Published \"{item.Name}\" on mod.io", source: Source);
setBusy(false);
if (await ConfirmAsync(string.Format(GetString(isUpdate ? "modIoUpdateSuccess" : "modIoShareSuccess"), item.Name), GetString("open"), GetString("close")))
{
OpenProfile(link.ProfileUri);
}
}
catch (UnauthorizedException ex)
{
App.Logger.LogError($"Sharing \"{item.Name}\" failed, not authorized: {ex.Message}", source: Source);
LogOut();
setBusy(false);
await ShowMessageAsync(GetString("modIoSessionExpired"));
}
catch (Exception ex)
{
App.Logger.LogError($"Sharing \"{item.Name}\" failed: {ex.Message}", source: Source);
setBusy(false);
await ShowMessageAsync(string.Format(GetString("modIoShareFailed"), ex.Message));
}
finally
{
setBusy(false);
RefreshShareState(item);
TryDeleteDirectory(workingDirectory);
}
}
/// <summary>
/// Unsubscribes from the mod an item is linked to, so deleting it locally does not bring it back on the next sync.
/// </summary>
public static async Task UnsubscribeAsync(IModIoShareable item)
{
if (item.ModIo is not null)
{
await UnsubscribeAsync(item.ModIo.ModId);
}
}
private static async Task UnsubscribeAsync(uint modId)
{
if (!IsAuthenticated)
{
return;
}
try
{
await CreateClient().Games[GameId].Mods.Unsubscribe(modId);
App.Logger.LogInfo($"Unsubscribed from mod {modId}", source: Source);
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to unsubscribe from mod {modId}: {ex.Message}", source: Source);
}
}
/// <summary>
/// Subscribes to the mods of downloaded layouts and themes, e.g. after logging in,
/// so the subscription sync keeps them instead of treating them as unsubscribed.
/// </summary>
public static async Task SubscribeToLinkedItemsAsync()
{
if (!IsAuthenticated)
{
return;
}
Client client = CreateClient();
foreach (ModIoLink link in GetSharedItems().Select(item => item.ModIo).OfType<ModIoLink>().Where(link => !link.IsOwner).ToList())
{
try
{
await client.Games[GameId].Mods.Subscribe(link.ModId);
App.Logger.LogInfo($"Subscribed to mod {link.ModId}", source: Source);
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to subscribe to mod {link.ModId}: {ex.Message}", source: Source);
}
}
}
public static IEnumerable<IModIoShareable> GetSharedItems()
{
DesktopMagicSettings settings = AppSettings;
return settings.Layouts.Cast<IModIoShareable>().Concat(settings.Themes);
}
public static IModIoShareable? FindLinkedItem(uint modId)
{
return GetSharedItems().FirstOrDefault(item => item.ModIo?.ModId == modId);
}
/// <summary>
/// Downloads a layout or theme from mod.io and subscribes to it when logged in.
/// </summary>
/// <param name="ensurePluginInstalled">Installs the mod.io plugin with the given ID if it is missing.</param>
/// <returns>Whether the item was installed.</returns>
public static async Task<bool> InstallAsync(Mod mod, Func<uint, Task> ensurePluginInstalled)
{
try
{
if (!await ApplyLatestVersionAsync(mod, FindLinkedItem(mod.Id), ensurePluginInstalled))
{
return false;
}
if (IsAuthenticated)
{
try
{
await CreateClient().Games[GameId].Mods.Subscribe(mod.Id);
App.Logger.LogInfo($"Subscribed to mod {mod.Id}", source: Source);
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to subscribe to mod {mod.Id}: {ex.Message}", source: Source);
}
}
Manager.Instance.SaveSettings();
return true;
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to install \"{mod.Name}\" (mod {mod.Id}): {ex.Message}", source: Source);
await ShowMessageAsync(string.Format(GetString("modIoInstallFailed"), mod.Name, ex.Message));
return false;
}
}
/// <summary>
/// Updates a linked layout or theme when mod.io has a newer version.
/// </summary>
/// <returns>Whether the item changed.</returns>
public static async Task<bool> UpdateAsync(IModIoShareable item, Mod mod, Func<uint, Task> ensurePluginInstalled)
{
try
{
if (!await ApplyLatestVersionAsync(mod, item, ensurePluginInstalled))
{
return false;
}
Manager.Instance.SaveSettings();
return true;
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to update \"{item.Name}\" (mod {mod.Id}): {ex.Message}", source: Source);
return false;
}
}
/// <summary>
/// Deletes a layout or theme locally after confirmation and unsubscribes from its mod.
/// The mod itself stays on mod.io.
/// </summary>
/// <returns>Whether the item was removed.</returns>
public static async Task<bool> RemoveAsync(IModIoShareable item)
{
DesktopMagicSettings settings = AppSettings;
string? blocker = item switch
{
Layout { Name: Manager.EmptyLayoutName } => "cannotDeleteEmptyLayout",
Layout when settings.Layouts.Count <= 1 => "cannotDeleteLastLayout",
Theme when settings.Themes.Count <= 1 => "cannotDeleteLastTheme",
_ => null
};
if (blocker is not null)
{
await ShowMessageAsync(GetString(blocker));
return false;
}
if (!await ConfirmAsync(GetString(item is Layout ? "confirmDeleteLayout" : "confirmDeleteTheme"), "Yes", "No"))
{
return false;
}
await UnsubscribeAsync(item);
switch (item)
{
case Layout layout:
Manager.Instance.RemoveLayout(layout);
break;
case Theme theme:
_ = settings.Themes.Remove(theme);
Manager.Instance.SaveSettings();
break;
}
App.Logger.LogInfo($"Removed \"{item.Name}\"", source: Source);
return true;
}
/// <summary>
/// Downloads subscribed layouts and themes that are new or have a newer version on mod.io.
/// </summary>
/// <param name="subscriptions">The user's subscribed mods for this game.</param>
/// <param name="ensurePluginInstalled">Installs the mod.io plugin with the given ID if it is missing.</param>
/// <returns>Whether any layout or theme changed.</returns>
public static async Task<bool> SyncSubscriptionsAsync(IReadOnlyList<Mod> subscriptions, Func<uint, Task> ensurePluginInstalled)
{
if (!IsAuthenticated)
{
return false;
}
bool changed = false;
List<Mod> sharedMods = subscriptions.Where(mod => !IsPluginMod(mod)).ToList();
App.Logger.LogInfo($"Found {sharedMods.Count} subscribed layouts and themes on mod.io", source: Source);
foreach (Mod mod in sharedMods)
{
try
{
changed |= await ApplyLatestVersionAsync(mod, FindLinkedItem(mod.Id), ensurePluginInstalled);
}
catch (Exception ex)
{
App.Logger.LogError($"Failed to install \"{mod.Name}\" (mod {mod.Id}): {ex.Message}", source: Source);
}
}
// Downloaded items whose subscription was removed on mod.io stay as local copies.
foreach (IModIoShareable item in GetSharedItems().Where(item => item.ModIo is { IsOwner: false } && !subscriptions.Any(mod => mod.Id == item.ModIo.ModId)).ToList())
{
App.Logger.LogInfo($"\"{item.Name}\" is no longer subscribed, keeping it as a local copy", source: Source);
item.ModIo = null;
RefreshShareState(item);
changed = true;
}
if (changed)
{
Manager.Instance.SaveSettings();
}
return changed;
}
/// <summary>
/// Downloads the current version of the mod into a new item, or into <paramref name="local"/> when it is outdated.
/// Asks before overwriting local changes. Throws when the download or import fails.
/// </summary>
/// <returns>Whether anything changed.</returns>
private static async Task<bool> ApplyLatestVersionAsync(Mod mod, IModIoShareable? local, Func<uint, Task> ensurePluginInstalled)
{
if (mod.Modfile?.Download?.BinaryUrl is null)
{
throw new InvalidOperationException("The mod has no file to download.");
}
if (local?.ModIo?.ModfileId == mod.Modfile.Id)
{
return false;
}
if (local?.ModIo is ModIoLink localLink && localLink.PublishedHash != SharePackage.Build(local).Hash)
{
bool overwrite = await ConfirmAsync(string.Format(GetString("modIoUpdateConflict"), local.Name), GetString("overwrite"), GetString("keepMine"));
if (!overwrite)
{
App.Logger.LogInfo($"Keeping local changes of \"{local.Name}\" instead of updating to mod {mod.Id} version {mod.Modfile.Id}", source: Source);
if (localLink.IsOwner)
{
// The owner can publish their version over the remote one.
localLink.ModfileId = mod.Modfile.Id;
}
else
{
// Detach the local copy so it is no longer replaced by updates.
await UnsubscribeAsync(local);
local.ModIo = null;
}
return true;
}
}
App.Logger.LogInfo($"Downloading \"{mod.Name}\" (mod {mod.Id}, version {mod.Modfile.Id})", source: Source);
JsonObject content = await DownloadContentAsync(mod.Modfile.Download.BinaryUrl);
foreach (uint pluginId in SharePackage.GetRequiredPlugins(content).Where(id => !Manager.Instance.Plugins.ContainsKey(id)))
{
await ensurePluginInstalled(pluginId);
}
uint? userId = IsAuthenticated ? await GetCurrentUserIdAsync(CreateClient()) : null;
IModIoShareable item = SharePackage.Apply(content, local);
item.ModIo = new ModIoLink
{
ModId = mod.Id,
ModfileId = mod.Modfile.Id,
IsOwner = userId is not null && mod.SubmittedBy?.Id == userId,
ProfileUri = mod.ProfileUrl,
};
item.ModIo.PublishedHash = SharePackage.Build(item).Hash;
RefreshShareState(item);
App.Logger.LogInfo($"Installed \"{item.Name}\" from mod.io", source: Source);
return true;
}
private static async Task<JsonObject> DownloadContentAsync(Uri url)
{
using MemoryStream zipStream = new();
using (Stream download = await httpClient.GetStreamAsync(url))
{
await download.CopyToAsync(zipStream);
}
zipStream.Position = 0;
using ZipArchive archive = new(zipStream, ZipArchiveMode.Read);
ZipArchiveEntry entry = archive.GetEntry(SharePackage.ContentFileName) ?? throw new FormatException($"The download does not contain \"{SharePackage.ContentFileName}\".");
using StreamReader reader = new(entry.Open());
return SharePackage.Parse(await reader.ReadToEndAsync());
}
private static async Task<uint> GetCurrentUserIdAsync(Client client)
{
string token = AppSettings.ModIoAccessToken ?? string.Empty;
if (currentUser is not { } user || user.Token != token)
{
User me = await client.User.GetCurrentUser();
user = (token, me.Id);
currentUser = user;
}
return user.UserId;
}
private static async Task TryUpdateLogo(Client client, uint modId, string logoPath)
{
try
{
await client.Games[GameId].Mods[modId].AddMedia(new NewModMedia { Logo = new FileInfo(logoPath) });
}
catch (Exception ex)
{
App.Logger.LogWarn($"Failed to update logo of mod {modId}: {ex.Message}", source: Source);
}
}
private static async Task TryUpdateDependencies(Client client, uint modId, IReadOnlyList<uint> requiredPlugins)
{
try
{
DependenciesClient dependencies = client.Games[GameId].Mods[modId].Dependencies;
List<uint> current = (await dependencies.Get(false)).Select(dependency => dependency.ModId).ToList();
List<uint> removed = current.Except(requiredPlugins).ToList();
List<uint> added = requiredPlugins.Except(current).ToList();
if (removed.Count > 0)
{
await dependencies.Delete(removed);
}
if (added.Count > 0)
{
await dependencies.Add(added);
}
}
catch (Exception ex)
{
App.Logger.LogWarn($"Failed to update dependencies of mod {modId}: {ex.Message}", source: Source);
}
}
private static void OpenProfile(Uri? profileUri)
{
if (profileUri is null)
{
return;
}
_ = Process.Start(new ProcessStartInfo
{
UseShellExecute = true,
FileName = profileUri.ToString()
});
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, true);
}
}
catch (Exception ex)
{
App.Logger.LogWarn($"Failed to delete temporary directory {path}: {ex.Message}", source: Source);
}
}
private static async Task ShowMessageAsync(string content)
{
Wpf.Ui.Controls.MessageBox messageBox = new()
{
Title = App.AppName,
Content = content,
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
}
private static async Task<bool> ConfirmAsync(string content, string confirmText, string? cancelText = null)
{
Wpf.Ui.Controls.MessageBox messageBox = new()
{
Title = App.AppName,
Content = content,
PrimaryButtonText = confirmText,
CloseButtonText = cancelText ?? GetString("cancel"),
};
return await messageBox.ShowDialogAsync() == Wpf.Ui.Controls.MessageBoxResult.Primary;
}
private static string GetString(string key)
{
return App.LanguageDictionary[key] as string ?? key;
}
}
@@ -0,0 +1,405 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Threading;
namespace DesktopMagic.Helpers;
/// <summary>
/// Periodically checks whether plugin windows are occluded by other top-level windows and pauses
/// their rendering while they are (a coverage-based pause algorithm, same idea as Lively's).
/// Pausing stops the plugin update timer, which stops re-rendering and re-uploading frames to the
/// GPU, so fully covered widgets consume almost no resources.
/// </summary>
internal sealed class OcclusionMonitor : IDisposable
{
private const int CheckIntervalMs = 100;
// Pause only when essentially the whole window is covered so widgets stay responsive even
// when a corner is overlapped by another window.
private const double CoverageThreshold = 0.95;
private readonly System.Timers.Timer timer = new(CheckIntervalMs);
private readonly Dictionary<IPluginWindow, bool> lastPaused = [];
private readonly object gate = new();
private bool disposed;
// (window, handle) pairs of the plugin windows, refreshed asynchronously on the UI thread.
private volatile IReadOnlyList<(IPluginWindow Window, nint Hwnd)> windowSnapshot = [];
private int snapshotRefreshPending;
public bool IsRunning { get; private set; }
public void Start()
{
if (IsRunning)
{
return;
}
timer.Elapsed += Timer_Elapsed;
timer.Start();
IsRunning = true;
}
private void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e)
{
if (disposed)
{
return;
}
// System.Timers.Timer can re-enter this handler on another ThreadPool thread when the
// previous invocation is still running, so serialize the whole pass.
if (!Monitor.TryEnter(gate))
{
return;
}
try
{
RunOcclusionPass();
}
finally
{
Monitor.Exit(gate);
}
}
private void RunOcclusionPass()
{
RequestSnapshotRefresh();
IReadOnlyList<(IPluginWindow Window, nint Hwnd)> windows = windowSnapshot;
HashSet<IPluginWindow> live = [.. windows.Select(item => item.Window)];
Dictionary<IPluginWindow, bool> pausedStates;
try
{
pausedStates = ComputePausedStates(windows);
}
catch
{
return;
}
foreach ((IPluginWindow window, bool paused) in pausedStates)
{
if (lastPaused.TryGetValue(window, out bool previous) && previous == paused)
{
continue;
}
lastPaused[window] = paused;
try
{
_ = ((Window)window).Dispatcher.InvokeAsync(() => window.SetRenderPaused(paused));
}
catch
{
// Window is closing; ignore.
}
}
// Drop tracking for windows that no longer exist.
foreach (IPluginWindow stale in lastPaused.Keys.Where(key => !live.Contains(key)).ToArray())
{
_ = lastPaused.Remove(stale);
}
}
/// <summary>
/// Posts a refresh of <see cref="windowSnapshot"/> to the UI thread without waiting for it.
/// The manager mutates PluginWindows on the UI thread and window handles can only be obtained
/// there; a snapshot that is one pass old is good enough, and the timer thread never blocks.
/// </summary>
private void RequestSnapshotRefresh()
{
// Skip when the previous refresh has not run yet (e.g. the UI thread is busy).
if (Interlocked.Exchange(ref snapshotRefreshPending, 1) != 0)
{
return;
}
try
{
_ = Application.Current.Dispatcher.InvokeAsync(() =>
{
try
{
windowSnapshot = Manager.Instance.PluginWindows
.Where(window => window.IsRunning)
.Select(window => (Window: window, Hwnd: new WindowInteropHelper((Window)window).Handle))
.Where(item => item.Hwnd != nint.Zero)
.ToList();
}
finally
{
_ = Interlocked.Exchange(ref snapshotRefreshPending, 0);
}
}, DispatcherPriority.Background);
}
catch
{
// The application is shutting down.
_ = Interlocked.Exchange(ref snapshotRefreshPending, 0);
}
}
/// <summary>
/// Decides for every plugin window whether it is occluded, using a single top-to-bottom pass
/// over all top-level windows. EnumWindows enumerates in z-order (front to back), so every
/// window seen before a plugin window is above it. The pass stops at the lowest plugin window.
/// </summary>
private static Dictionary<IPluginWindow, bool> ComputePausedStates(IReadOnlyList<(IPluginWindow Window, nint Hwnd)> windows)
{
Dictionary<IPluginWindow, bool> pausedStates = [];
// Plugin windows that have not been reached in z-order yet.
Dictionary<nint, (IPluginWindow Window, Rectangle Area)> remaining = [];
foreach ((IPluginWindow window, nint hwnd) in windows)
{
if (W32.GetWindowRect(hwnd, out W32.RECT rect))
{
remaining[hwnd] = (window, rect);
}
}
if (remaining.Count == 0)
{
return pausedStates;
}
uint currentProcessId = (uint)Environment.ProcessId;
// Visible windows above the current z-order position that overlap a remaining plugin window.
List<Rectangle> occluders = [];
_ = W32.EnumWindows((hwnd, _) =>
{
if (remaining.Remove(hwnd, out (IPluginWindow Window, Rectangle Area) target))
{
double coverage = ComputeCoverage(target.Area, occluders);
pausedStates[target.Window] = IsForegroundCovering(target.Area, hwnd) || coverage >= CoverageThreshold;
// Windows below the lowest plugin window cannot occlude anything.
return remaining.Count > 0;
}
if (TryGetOccluderRect(hwnd, currentProcessId, remaining.Values, out Rectangle occluderRect))
{
occluders.Add(occluderRect);
}
return true;
}, nint.Zero);
return pausedStates;
}
/// <summary>
/// Returns the bounds of <paramref name="hwnd"/> when it is a visible window from another process
/// that overlaps at least one of <paramref name="targets"/>. Cheap checks run first so the more
/// expensive ones only run for windows that actually overlap a plugin window.
/// </summary>
private static bool TryGetOccluderRect(nint hwnd, uint currentProcessId, IEnumerable<(IPluginWindow Window, Rectangle Area)> targets, out Rectangle occluderRect)
{
occluderRect = Rectangle.Empty;
// Only windows from other processes occlude our widgets.
if (!W32.IsWindowVisible(hwnd)
|| W32.GetWindowThreadProcessId(hwnd, out uint processId) == 0
|| processId == currentProcessId
|| !W32.GetWindowRect(hwnd, out W32.RECT rect))
{
return false;
}
Rectangle bounds = rect;
if (!targets.Any(target => target.Area.IntersectsWith(bounds)))
{
return false;
}
// Only non-cloaked windows are actually visible (exclusive-fullscreen games cloak the desktop).
// Explorer's desktop/wallpaper host windows (WorkerW, Progman, ...) report as visible and span
// the whole screen but never occlude anything. Fully transparent layered overlays (game
// overlays etc.) span the whole screen but cannot be seen either.
if (IsCloaked(hwnd) || IsDesktopShellWindow(hwnd) || IsFullyTransparent(hwnd))
{
return false;
}
occluderRect = bounds;
return true;
}
/// <summary>
/// Computes how much of <paramref name="target"/> (in virtual screen coordinates) is covered by
/// <paramref name="occluders"/>.
/// </summary>
private static double ComputeCoverage(Rectangle target, List<Rectangle> occluders)
{
List<Rectangle> coveringRects = [];
foreach (Rectangle occluder in occluders)
{
Rectangle intersection = Rectangle.Intersect(target, occluder);
if (intersection.Width > 0 && intersection.Height > 0)
{
coveringRects.Add(intersection);
}
}
if (coveringRects.Count == 0 || target.Width <= 0 || target.Height <= 0)
{
return 0;
}
return UnionArea(coveringRects) / (double)(target.Width * target.Height);
}
private static bool IsForegroundCovering(Rectangle target, nint selfHwnd)
{
nint foreground = W32.GetForegroundWindow();
if (foreground == nint.Zero || foreground == selfHwnd)
{
return false;
}
if (W32.GetWindowThreadProcessId(foreground, out _) == 0)
{
return false;
}
// A maximized/fullscreen app from another process covering the widget pauses it.
_ = W32.GetWindowRect(foreground, out W32.RECT rect);
Rectangle intersection = Rectangle.Intersect(target, rect);
return target.Width > 0 && target.Height > 0
&& intersection.Width >= target.Width * CoverageThreshold
&& intersection.Height >= target.Height * CoverageThreshold;
}
private static bool IsCloaked(nint hwnd)
{
return W32.DwmGetWindowAttribute(hwnd, W32.DWMWA_CLOAKED, out int cloaked, sizeof(int)) == 0 && cloaked != 0;
}
/// <summary>
/// Returns true for layered windows that are fully transparent (alpha 0), i.e. invisible
/// overlay hosts that span the screen but never visibly occlude anything.
/// </summary>
private static bool IsFullyTransparent(nint hwnd)
{
if ((W32.GetWindowLong(hwnd, W32.GWL_EXSTYLE) & W32.WS_EX_LAYERED) == 0)
{
return false;
}
return W32.GetLayeredWindowAttributes(hwnd, out _, out byte alpha, out int flags)
&& (flags & W32.LWA_ALPHA) != 0
&& alpha == 0;
}
/// <summary>
/// Returns true for the Explorer desktop/wallpaper host windows that span the whole screen but
/// never visually occlude anything (wallpaper, hidden desktop icon layer, ...).
/// </summary>
private static bool IsDesktopShellWindow(nint hwnd)
{
StringBuilder className = new(256);
_ = W32.GetClassName(hwnd, className, className.Capacity);
return className.ToString() is "WorkerW" or "Progman" or "SHELLDLL_DefView" or "SysListView32";
}
/// <summary>
/// Computes the total (union) area of <paramref name="rects"/> using a y-scanline sweep so
/// overlapping windows are not double counted.
/// </summary>
private static long UnionArea(List<Rectangle> rects)
{
if (rects.Count == 0)
{
return 0;
}
List<int> yEdges = [];
foreach (Rectangle rect in rects)
{
yEdges.Add(rect.Top);
yEdges.Add(rect.Bottom);
}
yEdges.Sort();
long area = 0;
for (int i = 0; i < yEdges.Count - 1; i++)
{
int y0 = yEdges[i];
int y1 = yEdges[i + 1];
if (y0 == y1)
{
continue;
}
// Collect the x extents of all rects that span this band, then merge them.
List<(int Left, int Right)> xRanges = [];
foreach (Rectangle rect in rects)
{
if (rect.Top <= y0 && rect.Bottom >= y1)
{
xRanges.Add((rect.Left, rect.Right));
}
}
if (xRanges.Count == 0)
{
continue;
}
xRanges.Sort((a, b) => a.Left.CompareTo(b.Left));
int mergedLeft = xRanges[0].Left;
int mergedRight = xRanges[0].Right;
long bandWidth = 0;
for (int j = 1; j < xRanges.Count; j++)
{
if (xRanges[j].Left <= mergedRight)
{
mergedRight = Math.Max(mergedRight, xRanges[j].Right);
}
else
{
bandWidth += mergedRight - mergedLeft;
mergedLeft = xRanges[j].Left;
mergedRight = xRanges[j].Right;
}
}
bandWidth += mergedRight - mergedLeft;
area += bandWidth * (y1 - y0);
}
return area;
}
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
timer.Elapsed -= Timer_Elapsed;
timer.Stop();
timer.Dispose();
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ internal class SampleAggregator
}
}
private bool IsPowerOfTwo(int x)
private static bool IsPowerOfTwo(int x)
{
return (x & (x - 1)) == 0;
}
+397
View File
@@ -0,0 +1,397 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Windows;
namespace DesktopMagic.Helpers;
/// <summary>
/// Helpers for enumerating screens and converting between percentage based
/// positions/sizes (relative to a screen's bounds) and absolute positions.
/// All conversions work in WPF DIP space because WPF window <see cref="Window.Left"/>,
/// <see cref="Window.Top"/>, <see cref="Window.Width"/> and <see cref="Window.Height"/>
/// are expressed in device independent pixels, while <see cref="System.Windows.Forms.Screen.Bounds"/>
/// is expressed in physical pixels. Each screen is therefore converted to DIP space
/// using its own DPI scaling factor.
/// </summary>
public static class ScreenUtilities
{
private const uint MonitorDefaultToNearest = 0x00000002;
private const int MDT_EFFECTIVE_DPI = 0;
/// <summary>
/// Gets the list of all screens currently connected, in the order reported by Windows.
/// </summary>
public static List<System.Windows.Forms.Screen> GetAllScreens()
{
return System.Windows.Forms.Screen.AllScreens.ToList();
}
/// <summary>
/// Gets the screen matching the given device name (e.g. "\\.\DISPLAY1"), or null.
/// </summary>
public static System.Windows.Forms.Screen? GetScreenByDeviceName(string? deviceName)
{
if (string.IsNullOrWhiteSpace(deviceName))
{
return null;
}
return System.Windows.Forms.Screen.AllScreens.FirstOrDefault(screen => string.Equals(screen.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Gets the primary screen, or the first available screen as a fallback.
/// </summary>
public static System.Windows.Forms.Screen GetPrimaryScreen()
{
return System.Windows.Forms.Screen.PrimaryScreen ?? System.Windows.Forms.Screen.AllScreens.FirstOrDefault()
?? throw new InvalidOperationException("No screens detected.");
}
/// <summary>
/// Aspect ratio (Width / Height) of the screen's bounds.
/// </summary>
public static double GetAspectRatio(System.Windows.Forms.Screen screen)
{
if (screen.Bounds.Height == 0)
{
return 0;
}
return screen.Bounds.Width / (double)screen.Bounds.Height;
}
/// <summary>
/// Converts a percentage based position (0..1 relative to the screen bounds) to an absolute
/// WPF position (DIPs) on that screen.
/// </summary>
public static Point PercentToPosition(Point percent, System.Drawing.Rectangle bounds)
{
Rect dips = GetScreenDips(bounds);
return new Point(
dips.Left + (percent.X * dips.Width),
dips.Top + (percent.Y * dips.Height));
}
/// <summary>
/// Converts an absolute WPF position (DIPs) to a percentage (0..1) of the screen bounds.
/// </summary>
public static Point PositionToPercent(Point position, System.Drawing.Rectangle bounds)
{
Rect dips = GetScreenDips(bounds);
if (dips.Width == 0 || dips.Height == 0)
{
return new Point(0.05, 0.05);
}
return new Point(
(position.X - dips.Left) / dips.Width,
(position.Y - dips.Top) / dips.Height);
}
/// <summary>
/// Converts a percentage based size (0..1 of the screen bounds) to an absolute WPF size (DIPs).
/// </summary>
public static Point PercentSizeToSize(Point percent, System.Drawing.Rectangle bounds)
{
Rect dips = GetScreenDips(bounds);
return new Point(
percent.X * dips.Width,
percent.Y * dips.Height);
}
/// <summary>
/// Converts an absolute WPF size (DIPs) to a percentage (0..1) of the screen bounds.
/// </summary>
public static Point SizeToPercent(Point size, System.Drawing.Rectangle bounds)
{
Rect dips = GetScreenDips(bounds);
if (dips.Width == 0 || dips.Height == 0)
{
return new Point(0.3, 0.3);
}
return new Point(
size.X / dips.Width,
size.Y / dips.Height);
}
/// <summary>
/// Window hook that keeps a window inside the given screen bounds. <see cref="Window.LocationChanged"/>
/// fires only after a move has already happened, and the system move loop overrides any correction
/// made there on the next mouse move, so the proposed position is adjusted in WM_WINDOWPOSCHANGING
/// instead, which every move and resize goes through. The position and the bounds are both in this
/// process' screen coordinates, so no DIP conversion is needed.
/// </summary>
public static IntPtr ClampToScreenHook(IntPtr hwnd, int msg, IntPtr lParam, System.Drawing.Rectangle bounds)
{
if (msg != NativeMethods.WM_WINDOWPOSCHANGING || bounds.Width == 0 || bounds.Height == 0)
{
return IntPtr.Zero;
}
NativeMethods.WINDOWPOS pos = Marshal.PtrToStructure<NativeMethods.WINDOWPOS>(lParam);
if ((pos.flags & NativeMethods.SWP_NOMOVE) != 0)
{
return IntPtr.Zero;
}
int width = pos.cx;
int height = pos.cy;
if ((pos.flags & NativeMethods.SWP_NOSIZE) != 0)
{
if (!W32.GetWindowRect(hwnd, out W32.RECT current))
{
return IntPtr.Zero;
}
width = current.Width;
height = current.Height;
}
int x = width <= bounds.Width ? Math.Clamp(pos.x, bounds.Left, bounds.Right - width) : bounds.Left;
int y = height <= bounds.Height ? Math.Clamp(pos.y, bounds.Top, bounds.Bottom - height) : bounds.Top;
if (x != pos.x || y != pos.y)
{
pos.x = x;
pos.y = y;
Marshal.StructureToPtr(pos, lParam, false);
}
return IntPtr.Zero;
}
/// <summary>
/// Builds a human readable label for a screen, e.g. "Display 1 · DELL U2715H · 3840x2160".
/// </summary>
public static string GetScreenLabel(System.Windows.Forms.Screen screen, int index)
{
string name = GetFriendlyName(screen);
string resolution = $"{screen.Bounds.Width}x{screen.Bounds.Height}";
return string.IsNullOrWhiteSpace(name) || string.Equals(name, "Generic PnP Monitor", StringComparison.OrdinalIgnoreCase)
? $"Display {index + 1} · {resolution}"
: $"Display {index + 1} · {name} · {resolution}";
}
/// <summary>
/// Gets the friendly monitor model name (e.g. "DELL U2715H") for the given screen,
/// or an empty string when it cannot be determined.
/// </summary>
public static string GetFriendlyName(System.Windows.Forms.Screen screen)
{
return GetMonitorDisplayDevice(screen)?.DeviceString ?? string.Empty;
}
/// <summary>
/// Gets a stable hardware identifier for the screen: the PnP device instance ID of its
/// monitor (e.g. "MONITOR\DEL41F1\{...}\{0001}"), which is derived from the monitor's
/// EDID and survives disconnects and reconnects of the same monitor. When no hardware ID
/// is available (e.g. virtual or remote displays), a deterministic SHA-256 hash of the
/// bounds is used so the identifier is never empty.
/// </summary>
public static string GetMonitorHardwareId(System.Windows.Forms.Screen screen)
{
string? deviceId = GetMonitorDisplayDevice(screen)?.DeviceID;
if (!string.IsNullOrWhiteSpace(deviceId))
{
return deviceId;
}
System.Drawing.Rectangle bounds = screen.Bounds;
string boundsString = $"{bounds.X}-{bounds.Y}-{bounds.Width}-{bounds.Height}";
string hashString = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(boundsString))).ToLowerInvariant();
return $"DISPLAY#{hashString}";
}
/// <summary>
/// Gets the monitor device info (second-level <see cref="EnumDisplayDevices"/> entry) for
/// the given screen, or null when it cannot be determined.
/// </summary>
private static DISPLAY_DEVICE? GetMonitorDisplayDevice(System.Windows.Forms.Screen screen)
{
try
{
DISPLAY_DEVICE device = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf<DISPLAY_DEVICE>() };
if (EnumDisplayDevices(screen.DeviceName, 0, ref device, 0))
{
DISPLAY_DEVICE monitor = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf<DISPLAY_DEVICE>() };
if (EnumDisplayDevices(device.DeviceName, 0, ref monitor, 0))
{
return monitor;
}
}
}
catch (Exception)
{
// Fall through to null.
}
return null;
}
/// <summary>
/// Enumerates monitors in a Per-Monitor V2 DPI awareness context and returns each
/// monitor's bounds in physical (device) pixels on the virtual screen, keyed by device
/// name (e.g. "\\.\DISPLAY1"). This uses the same coordinate space as DPI-aware apps
/// such as Lively Wallpaper, keeping multi-screen layouts with mixed DPI scaling
/// consistent regardless of this app's own DPI awareness mode.
/// </summary>
public static Dictionary<string, System.Drawing.Rectangle> GetDisplayPhysicalBounds()
{
Dictionary<string, System.Drawing.Rectangle> result = new Dictionary<string, System.Drawing.Rectangle>();
IntPtr prevContext = IntPtr.Zero;
bool contextChanged = false;
try
{
prevContext = SetThreadDpiAwarenessContext((IntPtr)DpiAwarenessContextPerMonitorV2);
contextChanged = prevContext != IntPtr.Zero;
}
catch (Exception)
{
// SetThreadDpiAwarenessContext unavailable; fall back to default context.
}
try
{
_ = EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, (hMonitor, hdcMonitor, lprcMonitor, dwData) =>
{
MONITORINFOEX info = new MONITORINFOEX { cbSize = (uint)Marshal.SizeOf<MONITORINFOEX>() };
if (GetMonitorInfo(hMonitor, ref info))
{
string deviceName = info.szDevice.TrimEnd('\0');
result[deviceName] = new System.Drawing.Rectangle(
info.rcMonitor.Left, info.rcMonitor.Top,
info.rcMonitor.Right - info.rcMonitor.Left,
info.rcMonitor.Bottom - info.rcMonitor.Top);
}
return true;
}, IntPtr.Zero);
}
finally
{
if (contextChanged)
{
_ = SetThreadDpiAwarenessContext(prevContext);
}
}
return result;
}
/// <summary>
/// Gets the screen bounds converted to WPF DIP space using the screen's own DPI scaling factor.
/// </summary>
private static Rect GetScreenDips(System.Drawing.Rectangle bounds)
{
double scale = GetDpiScale(bounds);
return new Rect(bounds.Left / scale, bounds.Top / scale, bounds.Width / scale, bounds.Height / scale);
}
/// <summary>
/// Gets the DPI scaling factor (relative to 96 DPI) of the screen containing the given bounds.
/// </summary>
private static double GetDpiScale(System.Drawing.Rectangle bounds)
{
try
{
POINT center = new POINT
{
X = bounds.Left + (bounds.Width / 2),
Y = bounds.Top + (bounds.Height / 2)
};
IntPtr monitor = MonitorFromPoint(center, MonitorDefaultToNearest);
if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out uint dpiX, out _) == 0)
{
return dpiX / 96.0;
}
}
catch (Exception)
{
// Fall through to no scaling if DPI APIs are unavailable.
}
return 1.0;
}
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromPoint(POINT point, uint dwFlags);
[DllImport("shcore.dll")]
private static extern int GetDpiForMonitor(IntPtr hMonitor, int dpiType, out uint dpiX, out uint dpiY);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern bool EnumDisplayDevices(string? lpDevice, uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct DISPLAY_DEVICE
{
public uint cb;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string DeviceName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string DeviceString;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string DeviceID;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string DeviceKey;
public uint StateFlags;
}
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int X;
public int Y;
}
private const long DpiAwarenessContextPerMonitorV2 = -4;
private delegate bool EnumMonitorsProc(IntPtr hMonitor, IntPtr hdcMonitor, IntPtr lprcMonitor, IntPtr dwData);
[DllImport("user32.dll")]
private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext);
[DllImport("user32.dll")]
private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, EnumMonitorsProc lpfnEnum, IntPtr dwData);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi);
[StructLayout(LayoutKind.Sequential)]
private struct MONITORRECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct MONITORINFOEX
{
public uint cbSize;
public MONITORRECT rcMonitor;
public MONITORRECT rcWork;
public uint dwFlags;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string szDevice;
}
}
@@ -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,11 +122,11 @@ 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)
{
Xceed.Wpf.Toolkit.IntegerUpDown integerUpDown = new()
Wpf.Ui.Controls.NumberBox numberBox = new()
{
Value = eIntegerUpDown.Value,
Minimum = eIntegerUpDown.Minimum,
@@ -135,11 +134,11 @@ internal class SettingElementGenerator(ComboBox optionsComboBox)
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch
};
integerUpDown.ValueChanged += (_s, _e) =>
numberBox.ValueChanged += (_s, _e) =>
{
try
{
eIntegerUpDown.Value = integerUpDown.Value.GetValueOrDefault();
eIntegerUpDown.Value = (int)numberBox.Value;
}
catch (Exception ex)
{
@@ -148,12 +147,12 @@ internal class SettingElementGenerator(ComboBox optionsComboBox)
};
eIntegerUpDown.OnValueChanged += () =>
{
integerUpDown.Dispatcher.Invoke(() =>
numberBox.Dispatcher.Invoke(() =>
{
integerUpDown.Value = eIntegerUpDown.Value;
numberBox.Value = eIntegerUpDown.Value;
});
};
_ = dockPanel.Children.Add(integerUpDown);
return numberBox;
}
else if (settingElement.Input is DesktopMagic.Api.Settings.Slider eSlider)
{
@@ -165,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) =>
{
@@ -186,7 +186,7 @@ internal class SettingElementGenerator(ComboBox optionsComboBox)
});
};
_ = dockPanel.Children.Add(slider);
return slider;
}
else if (settingElement.Input is DesktopMagic.Api.Settings.ComboBox eComboBox)
{
@@ -203,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)
{
@@ -216,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;
}
return null;
}
private void DisplayException(string message)
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];
window?.Exit();
if (index >= 0 && index < Manager.Instance.PluginWindows.Count)
{
IPluginWindow window = Manager.Instance.PluginWindows[index];
window?.Exit();
}
}
}
+327
View File
@@ -0,0 +1,327 @@
using DesktopMagic.Plugins;
using DesktopMagic.Settings;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace DesktopMagic.Helpers;
/// <summary>
/// Converts layouts and themes to and from the portable JSON content that is uploaded to mod.io.
/// </summary>
internal static class SharePackage
{
public const int FormatVersion = 1;
public const string LayoutTag = "Layout";
public const string ThemeTag = "Theme";
public const string ContentFileName = "content.json";
private static readonly JsonSerializerOptions jsonOptions = new()
{
Converters = { new ColorJsonConverter() }
};
/// <param name="Json">The content written to <see cref="ContentFileName"/>.</param>
/// <param name="Hash">Hash of the shared content, used to detect local changes.</param>
/// <param name="RequiredPlugins">mod.io plugin IDs the layout uses.</param>
/// <param name="LocalPlugins">Names of locally developed plugins the layout uses, which others cannot install.</param>
public sealed record Package(JsonObject Json, string Hash, IReadOnlyList<uint> RequiredPlugins, IReadOnlyList<string> LocalPlugins);
public static string GetTag(IModIoShareable item) => item is Layout ? LayoutTag : ThemeTag;
public static Package Build(IModIoShareable item)
{
return item switch
{
Layout layout => BuildLayout(layout),
Theme theme => BuildTheme(theme),
_ => throw new ArgumentException($"Unsupported item type {item.GetType().Name}", nameof(item))
};
}
private static Package BuildTheme(Theme theme)
{
JsonObject root = new()
{
["format"] = FormatVersion,
["type"] = ThemeTag,
["themes"] = new JsonArray(SerializeTheme(theme)),
};
return new Package(root, ComputeHash(root), [], []);
}
private static Package BuildLayout(Layout layout)
{
DesktopMagicSettings settings = Manager.Instance.Settings;
IReadOnlyDictionary<uint, InternalPluginData> installedPlugins = Manager.Instance.Plugins;
Layout copy = new(layout.Name)
{
ScreenAspectRatio = layout.ScreenAspectRatio,
};
List<string> themeNames = [layout.Theme.Name];
List<uint> requiredPlugins = [];
List<string> localPlugins = [];
// Only enabled widgets are shared. Disabled entries are just placeholders for every installed plugin.
foreach ((uint pluginId, PluginSettings pluginSettings) in layout.Plugins.Where(kvp => kvp.Value.Enabled).OrderBy(kvp => kvp.Key))
{
copy.Plugins[pluginId] = new PluginSettings
{
Enabled = true,
Position = pluginSettings.Position,
Size = pluginSettings.Size,
CurrentThemeName = pluginSettings.CurrentThemeName,
Settings = pluginSettings.Settings.Where(setting => setting.Shareable).ToList(),
};
if (pluginSettings.CurrentThemeName is not null && settings.Themes.Any(t => t.Name == pluginSettings.CurrentThemeName) && !themeNames.Contains(pluginSettings.CurrentThemeName))
{
themeNames.Add(pluginSettings.CurrentThemeName);
}
// Built-in plugins have no directory and are available everywhere.
if (installedPlugins.TryGetValue(pluginId, out InternalPluginData? pluginData) && !string.IsNullOrEmpty(pluginData.DirectoryPath))
{
if (pluginData.Metadata.IsLocalPlugin)
{
localPlugins.Add(pluginData.Metadata.Name);
}
else
{
requiredPlugins.Add(pluginId);
}
}
}
copy.CurrentThemeName = layout.Theme.Name;
JsonObject layoutNode = JsonSerializer.SerializeToNode(copy, jsonOptions)!.AsObject();
_ = layoutNode.Remove(nameof(Layout.ModIo));
JsonArray themesNode = [];
foreach (string themeName in themeNames)
{
Theme? theme = settings.Themes.FirstOrDefault(t => t.Name == themeName);
if (theme is not null)
{
themesNode.Add(SerializeTheme(theme));
}
}
JsonObject root = new()
{
["format"] = FormatVersion,
["type"] = LayoutTag,
["layout"] = layoutNode,
["themes"] = themesNode,
};
// Required plugins are derived from the layout and depend on what is currently loaded, so they are not part of the hash.
string hash = ComputeHash(root);
root["requiredPlugins"] = new JsonArray(requiredPlugins.Select(id => (JsonNode)id).ToArray());
return new Package(root, hash, requiredPlugins, localPlugins);
}
public static JsonObject Parse(string json)
{
JsonObject root = JsonNode.Parse(json)?.AsObject() ?? throw new FormatException("The downloaded content is empty.");
int format = root["format"]?.GetValue<int>() ?? 0;
if (format is < 1 or > FormatVersion)
{
throw new FormatException($"Unsupported content format {format}. Please update DesktopMagic.");
}
return root;
}
public static IReadOnlyList<uint> GetRequiredPlugins(JsonObject root)
{
return root["requiredPlugins"]?.AsArray().Select(node => node!.GetValue<uint>()).ToList() ?? [];
}
/// <summary>
/// Adds the downloaded content to the settings, or updates <paramref name="existing"/> with it.
/// </summary>
public static IModIoShareable Apply(JsonObject root, IModIoShareable? existing)
{
DesktopMagicSettings settings = Manager.Instance.Settings;
string type = root["type"]?.GetValue<string>() ?? string.Empty;
List<Theme> themes = root["themes"]?.AsArray()
.Select(node => node.Deserialize<Theme>(jsonOptions))
.OfType<Theme>()
.ToList() ?? [];
if (type == ThemeTag)
{
Theme incoming = themes.FirstOrDefault() ?? throw new FormatException("The downloaded theme is empty.");
incoming.ModIo = null;
if (existing is Theme existingTheme)
{
CopyThemeValues(incoming, existingTheme);
return existingTheme;
}
incoming.Name = GetUniqueName(incoming.Name, settings.Themes.Select(t => t.Name));
settings.Themes.Add(incoming);
return incoming;
}
if (type != LayoutTag)
{
throw new FormatException($"Unknown content type \"{type}\".");
}
JsonObject layoutNode = root["layout"]?.AsObject() ?? throw new FormatException("The downloaded layout is empty.");
Layout incomingLayout = layoutNode.Deserialize<Layout>(jsonOptions) ?? throw new FormatException("The downloaded layout is invalid.");
string layoutName = existing?.Name ?? GetUniqueName(incomingLayout.Name, settings.Layouts.Select(l => l.Name));
Dictionary<string, string> themeNameMap = [];
foreach (Theme theme in themes)
{
themeNameMap[theme.Name] = AddEmbeddedTheme(theme, layoutName);
}
// Read the raw name, the getter falls back to another theme when the name does not exist yet.
string? layoutThemeName = layoutNode[nameof(Layout.CurrentThemeName)]?.GetValue<string>();
string? mappedLayoutThemeName = layoutThemeName is not null && themeNameMap.TryGetValue(layoutThemeName, out string? mapped) ? mapped : layoutThemeName;
Layout target = existing as Layout ?? new Layout(layoutName);
Dictionary<uint, PluginSettings> plugins = [];
foreach ((uint pluginId, PluginSettings incomingPlugin) in incomingLayout.Plugins)
{
if (incomingPlugin.CurrentThemeName is not null && themeNameMap.TryGetValue(incomingPlugin.CurrentThemeName, out string? pluginThemeName))
{
incomingPlugin.CurrentThemeName = pluginThemeName;
}
// Keep values that are never shared (e.g. URLs or locations) and saved widget data from the local copy.
if (target.Plugins.TryGetValue(pluginId, out PluginSettings? localPlugin))
{
incomingPlugin.State = localPlugin.State;
incomingPlugin.Settings = [.. incomingPlugin.Settings, .. localPlugin.Settings.Where(local => !incomingPlugin.Settings.Exists(s => s.Id == local.Id))];
}
incomingPlugin.Owner = target;
plugins[pluginId] = incomingPlugin;
}
// Widgets the new version no longer uses are disabled, keeping their settings.
foreach ((uint pluginId, PluginSettings localPlugin) in target.Plugins.Where(kvp => !plugins.ContainsKey(kvp.Key)))
{
localPlugin.Enabled = false;
plugins[pluginId] = localPlugin;
}
target.ScreenAspectRatio = incomingLayout.ScreenAspectRatio;
target.Plugins = plugins;
target.CurrentThemeName = mappedLayoutThemeName;
if (existing is null)
{
settings.Layouts.Add(target);
}
return target;
}
/// <summary>
/// Adds a theme that is embedded in a layout and returns the local name it is available under.
/// </summary>
private static string AddEmbeddedTheme(Theme theme, string layoutName)
{
DesktopMagicSettings settings = Manager.Instance.Settings;
theme.ModIo = null;
Theme? sameName = settings.Themes.FirstOrDefault(t => t.Name == theme.Name);
if (sameName is null)
{
settings.Themes.Add(theme);
return theme.Name;
}
if (HasSameValues(sameName, theme))
{
return sameName.Name;
}
// A different theme with the same name exists (e.g. "Default"). Use a name tied to the layout,
// which later updates of the same layout overwrite instead of adding more copies.
string layoutThemeName = $"{theme.Name} ({layoutName})";
Theme? layoutTheme = settings.Themes.FirstOrDefault(t => t.Name == layoutThemeName);
if (layoutTheme is null)
{
theme.Name = layoutThemeName;
settings.Themes.Add(theme);
}
else
{
CopyThemeValues(theme, layoutTheme);
}
return layoutThemeName;
}
private static void CopyThemeValues(Theme source, Theme target)
{
target.PrimaryColor = source.PrimaryColor;
target.SecondaryColor = source.SecondaryColor;
target.BackgroundColor = source.BackgroundColor;
target.Font = source.Font;
target.CornerRadius = source.CornerRadius;
target.Margin = source.Margin;
}
private static bool HasSameValues(Theme a, Theme b)
{
JsonObject nodeA = SerializeTheme(a);
JsonObject nodeB = SerializeTheme(b);
_ = nodeA.Remove(nameof(Theme.Name));
_ = nodeB.Remove(nameof(Theme.Name));
return JsonNode.DeepEquals(nodeA, nodeB);
}
private static JsonObject SerializeTheme(Theme theme)
{
JsonObject node = JsonSerializer.SerializeToNode(theme, jsonOptions)!.AsObject();
_ = node.Remove(nameof(Theme.ModIo));
return node;
}
private static string GetUniqueName(string name, IEnumerable<string> existingNames)
{
HashSet<string> names = existingNames.ToHashSet();
if (!names.Contains(name))
{
return name;
}
int index = 2;
while (names.Contains($"{name} ({index})"))
{
index++;
}
return $"{name} ({index})";
}
private static string ComputeHash(JsonObject root)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(root.ToJsonString()));
return Convert.ToHexString(hash);
}
}
@@ -0,0 +1,148 @@
using DesktopMagic.Plugins;
using DesktopMagic.Settings;
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Point = System.Windows.Point;
namespace DesktopMagic.Helpers;
/// <summary>
/// Renders the logo image used for layouts and themes on mod.io (mod.io requires one, at least 512x288).
/// </summary>
internal static class SharePreviewRenderer
{
private const int Width = 1280;
private const int Height = 720;
private static readonly Color canvasColor = Color.FromRgb(0x1C, 0x1C, 0x24);
private static readonly Color screenColor = Color.FromRgb(0x2E, 0x2E, 0x3A);
public static void Render(IModIoShareable item, string path)
{
DrawingVisual visual = new();
using (DrawingContext context = visual.RenderOpen())
{
context.DrawRectangle(new SolidColorBrush(canvasColor), null, new Rect(0, 0, Width, Height));
switch (item)
{
case Layout layout:
DrawLayout(context, layout);
break;
case Theme theme:
DrawTheme(context, theme);
break;
}
}
RenderTargetBitmap bitmap = new(Width, Height, 96, 96, PixelFormats.Pbgra32);
bitmap.Render(visual);
PngBitmapEncoder encoder = new();
encoder.Frames.Add(BitmapFrame.Create(bitmap));
using FileStream stream = File.Create(path);
encoder.Save(stream);
}
private static void DrawLayout(DrawingContext context, Layout layout)
{
const double padding = 60;
double aspectRatio = layout.ScreenAspectRatio > 0 ? layout.ScreenAspectRatio : 16.0 / 9.0;
double availableWidth = Width - (padding * 2);
double availableHeight = Height - (padding * 2);
double screenWidth = Math.Min(availableWidth, availableHeight * aspectRatio);
double screenHeight = screenWidth / aspectRatio;
Rect screen = new((Width - screenWidth) / 2, (Height - screenHeight) / 2, screenWidth, screenHeight);
context.DrawRoundedRectangle(new SolidColorBrush(screenColor), null, screen, 12, 12);
foreach ((uint pluginId, PluginSettings pluginSettings) in layout.Plugins.Where(kvp => kvp.Value.Enabled))
{
Theme theme = pluginSettings.Theme;
Rect widget = new(
screen.X + (pluginSettings.Position.X * screen.Width),
screen.Y + (pluginSettings.Position.Y * screen.Height),
Math.Max(4, pluginSettings.Size.X * screen.Width),
Math.Max(4, pluginSettings.Size.Y * screen.Height));
widget.Intersect(screen);
if (widget.IsEmpty)
{
continue;
}
Color background = MultiColorConverter.ConvertToMediaColor(theme.BackgroundColor);
if (background.A < 0x30)
{
background = Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF);
}
Color primary = MultiColorConverter.ConvertToMediaColor(theme.PrimaryColor);
double radius = Math.Min(theme.CornerRadius * screen.Width / 1920, Math.Min(widget.Width, widget.Height) / 2);
context.DrawRoundedRectangle(new SolidColorBrush(background), new Pen(new SolidColorBrush(primary), 2), widget, radius, radius);
string name = Manager.Instance.Plugins.TryGetValue(pluginId, out InternalPluginData? pluginData) ? pluginData.Metadata.Name : pluginSettings.Metadata.Name;
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
FormattedText text = CreateText(name, theme.Font, Math.Clamp(widget.Height / 4, 10, 28), primary);
text.MaxTextWidth = Math.Max(1, widget.Width - 12);
text.MaxTextHeight = Math.Max(1, widget.Height - 8);
text.Trimming = TextTrimming.CharacterEllipsis;
context.DrawText(text, new Point(widget.X + 6, widget.Y + ((widget.Height - Math.Min(text.Height, text.MaxTextHeight)) / 2)));
}
}
private static void DrawTheme(DrawingContext context, Theme theme)
{
Color background = MultiColorConverter.ConvertToMediaColor(theme.BackgroundColor);
Color primary = MultiColorConverter.ConvertToMediaColor(theme.PrimaryColor);
Color secondary = MultiColorConverter.ConvertToMediaColor(theme.SecondaryColor);
Rect card = new(120, 100, Width - 240, Height - 200);
double radius = Math.Clamp(theme.CornerRadius * 2, 0, card.Height / 2);
context.DrawRoundedRectangle(new SolidColorBrush(screenColor), null, card, radius, radius);
context.DrawRoundedRectangle(new SolidColorBrush(background), null, card, radius, radius);
FormattedText title = CreateText(theme.Name, theme.Font, 96, primary);
title.MaxTextWidth = card.Width - 120;
title.MaxLineCount = 1;
title.Trimming = TextTrimming.CharacterEllipsis;
context.DrawText(title, new Point(card.X + 60, card.Y + 60));
FormattedText subtitle = CreateText(theme.Font, theme.Font, 48, secondary);
subtitle.MaxTextWidth = card.Width - 120;
subtitle.MaxLineCount = 1;
subtitle.Trimming = TextTrimming.CharacterEllipsis;
context.DrawText(subtitle, new Point(card.X + 60, card.Y + 60 + title.Height + 10));
Color[] swatches = [primary, secondary, background];
for (int i = 0; i < swatches.Length; i++)
{
Point center = new(card.X + 110 + (i * 130), card.Bottom - 110);
context.DrawEllipse(new SolidColorBrush(swatches[i]), new Pen(new SolidColorBrush(Color.FromArgb(0x60, 0x80, 0x80, 0x80)), 3), center, 50, 50);
}
}
private static FormattedText CreateText(string text, string font, double size, Color color)
{
return new FormattedText(text, CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, new Typeface(font), size, new SolidColorBrush(color), 1.0);
}
}
@@ -0,0 +1,89 @@
using IWshRuntimeLibrary;
using System;
using System.IO;
using System.Reflection;
namespace DesktopMagic.Helpers;
public enum AutoStartStatus
{
Success = 0,
InvalidParameters = 1,
AlreadyEnabled = 2,
AlreadyDisabled = 3,
UnexpectedError = 4
}
public static class StartupManager
{
private static readonly string appPath = Path.ChangeExtension(Assembly.GetEntryAssembly()?.Location ?? throw new InvalidOperationException("Failed to get the application path."), ".exe");
private static readonly string startupFolder = Environment.GetFolderPath(Environment.SpecialFolder.Startup);
private static readonly string shortcutPath = Path.Combine(startupFolder, Path.GetFileNameWithoutExtension(appPath) + ".lnk");
public static bool IsAutoStartEnabled()
{
return System.IO.File.Exists(shortcutPath);
}
public static AutoStartStatus EnableAutoStart()
{
if (IsAutoStartEnabled())
{
return AutoStartStatus.AlreadyEnabled;
}
try
{
WshShell shell = new WshShell();
IWshShortcut shortcut = (IWshShortcut)shell.CreateShortcut(shortcutPath);
shortcut.TargetPath = appPath;
shortcut.WorkingDirectory = Path.GetDirectoryName(appPath);
shortcut.Save();
return AutoStartStatus.Success;
}
catch
{
return AutoStartStatus.UnexpectedError;
}
}
public static AutoStartStatus DisableAutoStart()
{
if (!IsAutoStartEnabled())
{
return AutoStartStatus.AlreadyDisabled;
}
try
{
System.IO.File.Delete(shortcutPath);
return AutoStartStatus.Success;
}
catch
{
return AutoStartStatus.UnexpectedError;
}
}
public static async void ToggleAutoStart()
{
AutoStartStatus status = IsAutoStartEnabled() ? DisableAutoStart() : EnableAutoStart();
string message = status switch
{
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();
}
}
+32
View File
@@ -10,6 +10,18 @@ internal class W32
public const int SM_CYSCREEN = 1;
public const int DWMWA_CLOAKED = 14;
public const int GWL_EXSTYLE = -20;
public const uint GW_HWNDNEXT = 2;
public const uint GW_HWNDPREV = 3;
public const int LWA_ALPHA = 0x2;
public const long WS_EX_LAYERED = 0x00080000L;
public const int SPI_SETDESKWALLPAPER = 20;
public const int SPIF_SENDWININICHANGE = 0x02;
public const int SPIF_UPDATEINIFILE = 0x01;
@@ -55,6 +67,26 @@ internal class W32
[DllImport("dwmapi.dll")]
public static extern int DwmSetWindowAttribute(nint hwnd, int attr, ref int attrValue, int attrSize);
[DllImport("dwmapi.dll")]
public static extern int DwmGetWindowAttribute(nint hwnd, int dwAttribute, out int pvAttribute, int cbAttribute);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetWindowRect(nint hWnd, out RECT lpRect);
[DllImport("user32.dll")]
public static extern int GetWindowLong(nint hWnd, int nIndex);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetLayeredWindowAttributes(nint hWnd, out int colorKey, out byte alpha, out int dwFlags);
[DllImport("user32.dll")]
public static extern nint GetWindow(nint hWnd, uint uCmd);
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(nint hWnd, out uint processId);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, nint lParam);
+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;
+118 -138
View File
@@ -1,153 +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"
StateChanged="Window_StateChanged"
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="*" />
<RowDefinition Height="1*" />
</Grid.RowDefinitions>
<Grid Margin="{StaticResource DefaultMargin}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<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="Auto"/>
</Grid.RowDefinitions>
<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>
<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>
<Border Grid.Row="1" Background="{DynamicResource ControlFillColorDefaultBrush}" BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}" BorderThickness="0,1,0,0" Padding="5 2 5 5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</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>
<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>
<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" />
</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" />
<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>
<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">
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
</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" Content="{DynamicResource newLayout}" Margin="0,0,0,5" HorizontalAlignment="Left" VerticalAlignment="Bottom" Width="170" Click="NewLayoutButton_Click" FontWeight="Regular" />
<Button x:Name="removeLayoutButton" Content="{DynamicResource deleteLayout}" HorizontalAlignment="Left" VerticalAlignment="Bottom" Width="170" Click="RemoveLayoutButton_Click" FontWeight="Regular" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="{StaticResource DefaultMargin}" VerticalAlignment="Bottom">
<Button x:Name="updatePluginsButton" Content="{DynamicResource updatePlugins}" Margin="0,0,0,5" HorizontalAlignment="Center" Width="170" Click="UpdatePluginsButton_Click" FontWeight="Regular" />
<Button x:Name="githubButton" Content="GitHub" HorizontalAlignment="Center" Width="170" Click="GitHubButton_Click" FontWeight="Regular" Cursor="Hand" />
</StackPanel>
<StackPanel Grid.Column="2" Margin="{StaticResource DefaultMargin}" VerticalAlignment="Bottom">
<Button x:Name="downloadPluginsButton" Content="{DynamicResource pluginManager}" Margin="0,0,0,5" HorizontalAlignment="Right" Width="170" Click="PluginManagerButton_Click" FontWeight="Regular" Cursor="Hand" />
<Button x:Name="openPluginsFolderButton" Content="{DynamicResource openPluginsFolder}" HorizontalAlignment="Right" Width="170" Click="OpenPluginsFolderButton_Click" FontWeight="Regular" />
</StackPanel>
</Grid>
</Grid>
</Grid>
</Border>
</Grid>
</busyIndicator:BusyMask>
</Window>
<ContentPresenter x:Name="rootContentDialog" Grid.Row="1" Grid.RowSpan="2" />
</Grid>
</ui:FluentWindow>
+255 -630
View File
@@ -1,680 +1,305 @@
using DesktopMagic.BuiltInWindowElements;
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.IO.Packaging;
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 Manager _manager = Manager.Instance;
private readonly MainWindowDataContext _mainWindowDataContext = new();
private OcclusionMonitor? occlusionMonitor;
[Obsolete]
public MainWindow()
{
private readonly System.Windows.Forms.NotifyIcon notifyIcon = new();
SystemThemeWatcher.Watch(this);
private readonly MainWindowDataContext mainWindowDataContext = new();
InitializeComponent();
private readonly Dictionary<PluginMetadata, Type> builtInPlugins = new()
DataContext = _mainWindowDataContext;
Resources.MergedDictionaries.Add(App.LanguageDictionary);
App.DialogService.SetDialogHost(rootContentDialog);
}
private async void Window_Loaded(object sender, RoutedEventArgs e)
{
try
{
{new("Music Visualizer", 1), typeof(MusicVisualizerPlugin)},
{new("Time",2), typeof(TimePlugin)},
{new("Date",3), typeof(DatePlugin)},
{new("Cpu Usage", 4), typeof(CpuMonitorPlugin)}
};
App.Logger.LogInfo("Loading application", source: "MainWindow");
private bool loaded = false;
private bool blockWindowsClosing = true;
public static List<PluginWindow> Windows { get; } = [];
public static List<string> WindowNames { get; } = [];
internal static bool EditMode { get; set; } = false;
Microsoft.Win32.SystemEvents.DisplaySettingsChanged += SystemEvents_DisplaySettingsChanged;
private DesktopMagicSettings Settings
{
get => mainWindowDataContext.Settings;
set => mainWindowDataContext.Settings = value;
}
_mainWindowDataContext.IsLoading = true;
public MainWindow()
{
DataContext = mainWindowDataContext;
// Load plugins and settings through manager
_manager.LoadSettings();
_mainWindowDataContext.Settings = _manager.Settings;
_manager.LoadPlugins();
_manager.LoadLayout();
try
// Pause plugin rendering while widgets are fully occluded by other windows.
occlusionMonitor = new OcclusionMonitor();
occlusionMonitor.Start();
if (_manager.Settings.IsFirstRun)
{
Stream iconStream = Application.GetResourceStream(new Uri("pack://application:,,,/DesktopMagic;component/icon.ico")).Stream;
notifyIcon.Click += TaskbarIcon_TrayLeftClick;
notifyIcon.Visible = true;
notifyIcon.Text = App.AppName;
notifyIcon.Icon = new System.Drawing.Icon(iconStream);
InitializeComponent();
SetLanguageDictionary();
#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());
}
}
#region Load
private readonly Dictionary<uint, InternalPluginData> plugins = [];
private void Window_Loaded(object sender, RoutedEventArgs e)
{
try
{
if (!Directory.Exists(App.ApplicationDataPath + "\\Plugins"))
{
_ = Directory.CreateDirectory(App.ApplicationDataPath + "\\Plugins");
}
App.Logger.LogInfo("Created Plugins Folder", source: "Main");
//Write To Log File and Load Elements
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();
loaded = true;
App.Logger.LogInfo("Window Loaded", source: "Main");
}
catch (Exception ex)
{
App.Logger.LogError(ex.Message, source: "Main");
_ = MessageBox.Show(ex.ToString());
}
}
private void LoadPlugins()
{
mainWindowDataContext.IsLoading = true;
plugins.Clear();
foreach (var buildInPlugin in builtInPlugins.Keys)
{
plugins.Add(buildInPlugin.Id, new(buildInPlugin, string.Empty));
}
string pluginsPath = App.ApplicationDataPath + "\\Plugins";
foreach (string directory in Directory.GetDirectories(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)
{
EditMode = 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()
};
}
else
{
window = new PluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath)
{
Title = internalPluginData.Metadata.Id.ToString()
};
}
Action? onPluginLoaded = null;
onPluginLoaded = () =>
{
Dispatcher.Invoke(() =>
{
if (!optionsComboBox.Items.Contains(internalPluginData.Metadata))
{
_ = optionsComboBox.Items.Add(internalPluginData.Metadata);
}
optionsComboBox.SelectedIndex = -1;
optionsComboBox.SelectedIndex = optionsComboBox.Items.IndexOf(internalPluginData.Metadata);
window.PluginLoaded -= onPluginLoaded;
});
};
window.OnExit += () =>
{
pluginSettings.Enabled = false;
};
window.PluginLoaded += onPluginLoaded;
window.ShowInTaskbar = false;
window.Show();
window.ContentRendered += DisplayWindow_ContentRendered;
window.Closing += DisplayWindow_Closing;
Windows.Add(window);
WindowNames.Add(window.Title);
}
private void DisplayWindow_ContentRendered(object? sender, EventArgs e)
{
if (sender is not Window window)
{
return;
}
WindowPos.SendWpfWindowBack(window);
WindowPos.SendWpfWindowBack(window);
}
private void DisplayWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
{
e.Cancel = blockWindowsClosing;
}
private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
MessageBoxResult msbRes = MessageBox.Show((string)FindResource("wantToCloseProgram"), App.AppName, MessageBoxButton.YesNo);
e.Cancel = msbRes != MessageBoxResult.Yes;
SaveSettings();
}
private void Window_Closed(object sender, EventArgs e)
{
Visibility = Visibility.Collapsed;
UpdateLayout();
foreach (Window window in Windows)
{
window.Hide();
}
Environment.Exit(0);
}
private void Window_StateChanged(object? sender, EventArgs? e)
{
if (WindowState == WindowState.Minimized)
{
EditCheckBox.IsChecked = false;
EditCheckBox_Click(null, null);
ShowInTaskbar = false;
Visibility = Visibility.Collapsed;
foreach (Window item in Windows)
{
WindowPos.SendWpfWindowBack(item);
WindowPos.SendWpfWindowBack(item);
}
}
}
#endregion Windows
#region Options
private void FontComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
Settings.CurrentLayout.Theme.Font = fontComboBox.SelectedValue.ToString()!.Replace("System.Windows.Controls.ComboBoxItem: ", "");
}
private void OptionsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
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);
}
}
#endregion Options
internal void RestoreWindow()
{
for (int i = 0; i < 10; i++)
{
ShowInTaskbar = true;
Visibility = Visibility.Visible;
SystemCommands.RestoreWindow(this);
Topmost = true;
_manager.Settings.IsFirstRun = false;
_ = Activate();
Topmost = 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() == 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);
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);
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);
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"));
if (inputDialog.ShowDialog() == true)
{
if (Settings.Layouts.Any(l => l.Name.Trim() == inputDialog.ResponseText.Trim()))
{
_ = MessageBox.Show((string)FindResource("layoutAlreadyExists"));
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;
EditMode = false;
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)
{
WindowState = WindowState.Minimized;
Window_StateChanged(null, null);
Visibility = Visibility.Collapsed;
ShowInTaskbar = false;
}
else
_manager.IsLoaded = true;
_mainWindowDataContext.IsLoading = false;
await ShowLatestReleaseInfoAfterUpdateAsync();
App.Logger.LogInfo("Application loaded", source: "MainWindow");
}
catch (Exception ex)
{
App.Logger.LogError(ex.Message, source: "MainWindow");
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{
WindowState = WindowState.Normal;
Window_StateChanged(null, null);
}
mainWindowDataContext.IsLoading = false;
Title = App.AppName,
Content = ex.ToString(),
CloseButtonText = "Ok"
};
_ = await messageBox.ShowDialogAsync();
}
}
#endregion Layout
private async void NavigationView_Loaded(object sender, RoutedEventArgs e)
{
_ = NavigationView.Navigate(typeof(Pages.MainPage));
}
private void UpdatePluginsButton_Click(object sender, RoutedEventArgs e)
private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
_manager.SaveSettings();
if (_manager.BlockWindowsClosing)
{
LoadPlugins();
LoadLayout(false);
e.Cancel = true;
_manager.SetEditMode(false);
ShowInTaskbar = false;
WindowState = WindowState.Minimized;
Visibility = Visibility.Collapsed;
}
}
private void OpenPluginsFolderButton_Click(object sender, RoutedEventArgs e)
{
_ = Process.Start("explorer.exe", App.ApplicationDataPath + "\\Plugins");
}
private void Window_Closed(object sender, EventArgs e)
{
Microsoft.Win32.SystemEvents.DisplaySettingsChanged -= SystemEvents_DisplaySettingsChanged;
private void ScrollViewer_PreviewMouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e)
{
ScrollViewer scv = (ScrollViewer)sender;
scv.ScrollToVerticalOffset(scv.VerticalOffset - e.Delta);
e.Handled = true;
}
occlusionMonitor?.Dispose();
occlusionMonitor = null;
private void TaskbarIcon_TrayLeftClick(object? sender, EventArgs e)
Visibility = Visibility.Collapsed;
UpdateLayout();
_manager.CloseAllPluginWindows();
Environment.Exit(0);
}
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)
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(() =>
{
string uri = "https://github.com/Stone-Red-Code/DesktopMagic";
ProcessStartInfo psi = new ProcessStartInfo
_mainWindowDataContext.RefreshScreens();
_manager.LoadLayout();
});
}
internal void RestoreWindow()
{
for (int i = 0; i < 10; i++)
{
ShowInTaskbar = true;
Visibility = Visibility.Visible;
SystemCommands.RestoreWindow(this);
Topmost = true;
_ = Activate();
Topmost = false;
}
}
private void Quit()
{
_manager.BlockWindowsClosing = false;
Close();
}
private void ReportBugNavigationViewItem_Click(object sender, RoutedEventArgs e)
{
string uri = "https://github.com/Stone-Red-Code/DesktopMagic/issues/new?template=bug_report.md";
ProcessStartInfo psi = new()
{
UseShellExecute = true,
FileName = uri
};
_ = Process.Start(psi);
}
private void ReportBugEmailNavigationViewItem_Click(object sender, RoutedEventArgs e)
{
string uri = "mailto:[email protected]";
ProcessStartInfo psi = new()
{
UseShellExecute = true,
FileName = uri
};
_ = Process.Start(psi);
}
private void RequestFeatureNavigationViewItem_Click(object sender, RoutedEventArgs e)
{
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 = uri
FileName = latestRelease.HtmlUrl
};
_ = Process.Start(psi);
_ = Process.Start(processStartInfo);
}
private void PluginManagerButton_Click(object sender, RoutedEventArgs e)
{
PluginManager pluginManager = new PluginManager();
pluginManager.ShowDialog();
LoadPlugins();
LoadLayout(false);
}
private void SetLanguageDictionary()
{
ResourceDictionary dict = [];
string currentCulture = Thread.CurrentThread.CurrentUICulture.ToString();
if (currentCulture.Contains("de"))
{
dict.Source = new Uri("..\\Resources\\Strings\\StringResources.de.xaml", UriKind.Relative);
}
else
{
dict.Source = new Uri("..\\Resources\\Strings\\StringResources.en.xaml", UriKind.Relative);
}
Resources.MergedDictionaries.Add(dict);
}
_manager.Settings.ReleaseInfoLastAppVersion = currentVersion;
_manager.SaveSettings();
}
internal class InternalPluginData(PluginMetadata pluginMetadata, string directoryPath)
private static async Task<GitHubLatestRelease?> GetLatestReleaseInfoAsync()
{
public PluginMetadata Metadata { get; set; } = pluginMetadata;
public string DirectoryPath { get; set; } = directoryPath;
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;
}
}
}
+610
View File
@@ -0,0 +1,610 @@
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)},
#if DEBUG
{new((string)App.LanguageDictionary["benchmark"], 8) { Author = "Stone_Red" }, typeof(BenchmarkPlugin)},
{new((string)App.LanguageDictionary["skiaBenchmark"], 9) { Author = "Stone_Red" }, typeof(SkiaBenchmarkPlugin)},
#endif
};
// Window management
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>
/// Deletes the layout, drops the screen bindings pointing to it and reloads the screens.
/// </summary>
public void RemoveLayout(Layout layout)
{
_ = Settings.Layouts.Remove(layout);
List<string> boundScreens = Settings.ScreenLayouts
.Where(kvp => kvp.Value == layout.Name)
.Select(kvp => kvp.Key)
.ToList();
foreach (string screenId in boundScreens)
{
_ = Settings.ScreenLayouts.Remove(screenId);
}
SaveSettings();
LoadLayout();
}
/// <summary>
/// Gets all plugin windows currently shown on the given screen.
/// </summary>
public IEnumerable<IPluginWindow> GetWindowsForScreen(string screenDeviceName)
{
return PluginWindows.Where(window => window.ScreenDeviceName == screenDeviceName).ToList();
}
/// <summary>
/// Loads all screens' layouts at once, opening the enabled widgets of every screen.
/// </summary>
public void LoadLayout(Action? onComplete = null)
{
App.Logger.LogInfo("Loading layouts", source: "Manager");
BlockWindowsClosing = false;
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
}
+131
View File
@@ -0,0 +1,131 @@
<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:busyIndicator="https://github.com/moh3ngolshani/BusyIndicator"
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">
<busyIndicator:BusyMask IsBusy="{Binding IsSharing}" IndicatorType="ThreeDots" BusyContent="{DynamicResource uploadingToModIo}" BusyContentMargin="0,20,0,0" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Background="{DynamicResource ControlStrokeColorDefaultBrush}">
<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="shareLayoutButton" Icon="{ui:SymbolIcon Share24}" Grid.Column="0" HorizontalAlignment="Stretch" Click="ShareLayoutButton_Click" FontWeight="Regular">
<TextBlock>
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="{DynamicResource shareOnModIo}" />
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedLayout.ShareState}" Value="Modified">
<Setter Property="Text" Value="{DynamicResource updateOnModIo}" />
</DataTrigger>
<DataTrigger Binding="{Binding SelectedLayout.ShareState}" Value="UpToDate">
<Setter Property="Text" Value="{DynamicResource sharedOnModIo}" />
</DataTrigger>
<DataTrigger Binding="{Binding SelectedLayout.ShareState}" Value="NotOwner">
<Setter Property="Text" Value="{DynamicResource shareCopyOnModIo}" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</ui:Button>
<ui:Button x:Name="newLayoutButton" Content="{DynamicResource newLayout}" Icon="{ui:SymbolIcon Add24}" Grid.Column="2" HorizontalAlignment="Stretch" Click="NewLayoutButton_Click" FontWeight="Regular" />
<ui:Button x:Name="removeLayoutButton" Content="{DynamicResource deleteLayout}" Icon="{ui:SymbolIcon Delete24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="RemoveLayoutButton_Click" FontWeight="Regular" />
</Grid>
</Grid>
</ui:Card>
</Grid>
</busyIndicator:BusyMask>
</Page>
+377
View File
@@ -0,0 +1,377 @@
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;
_manager.SettingsChanged += OnSettingsChanged;
Loaded += MainPage_Loaded;
Unloaded += MainPage_Unloaded;
}
private void MainPage_Loaded(object sender, RoutedEventArgs e)
{
// Initialize edit checkbox state
editCheckBox.IsChecked = _manager.IsEditMode;
_dataContext.RefreshScreens();
RefreshShareState();
}
private void MainPage_Unloaded(object sender, RoutedEventArgs e)
{
// Unsubscribe from events
_manager.PluginsChanged -= OnPluginsChanged;
_manager.EditModeChanged -= OnEditModeChanged;
_manager.SettingsChanged -= OnSettingsChanged;
}
private void OnPluginsChanged()
{
Dispatcher.Invoke(() =>
{
_dataContext.Settings = _manager.Settings;
ApplyPluginsFilter();
RefreshShareState();
});
}
private void OnSettingsChanged()
{
Dispatcher.Invoke(RefreshShareState);
}
private void RefreshShareState()
{
ModIoService.RefreshShareState(_manager.SelectedLayout);
}
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();
RefreshShareState();
}
private async void ShareLayoutButton_Click(object sender, RoutedEventArgs e)
{
shareLayoutButton.IsEnabled = false;
try
{
await ModIoService.ShareAsync(_manager.SelectedLayout, Window.GetWindow(this), isBusy => _dataContext.IsSharing = isBusy);
}
finally
{
shareLayoutButton.IsEnabled = true;
}
}
/// <summary>
/// Binds the currently selected layout to the currently selected screen and reloads it.
/// No-op when the layout is already the one bound to the screen (e.g. programmatic resets).
/// </summary>
private void ApplySelectedLayout()
{
if (_dataContext.SelectedScreenId is null)
{
return;
}
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;
}
// A subscribed layout would be downloaded again on the next sync.
await ModIoService.UnsubscribeAsync(layout);
_manager.RemoveLayout(layout);
_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();
}
}
+75
View File
@@ -0,0 +1,75 @@
<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:busyIndicator="https://github.com/moh3ngolshani/BusyIndicator"
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">
<busyIndicator:BusyMask IsBusy="{Binding IsSharing}" IndicatorType="ThreeDots" BusyContent="{DynamicResource uploadingToModIo}" BusyContentMargin="0,20,0,0" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Background="{DynamicResource ControlStrokeColorDefaultBrush}">
<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="shareThemeButton" Icon="{ui:SymbolIcon Share24}" Margin="0 0 5 0" Click="ShareThemeButton_Click" FontWeight="Regular" Tag="{Binding}">
<TextBlock>
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="{DynamicResource shareOnModIo}" />
<Style.Triggers>
<DataTrigger Binding="{Binding ShareState}" Value="Modified">
<Setter Property="Text" Value="{DynamicResource updateOnModIo}" />
</DataTrigger>
<DataTrigger Binding="{Binding ShareState}" Value="UpToDate">
<Setter Property="Text" Value="{DynamicResource sharedOnModIo}" />
</DataTrigger>
<DataTrigger Binding="{Binding ShareState}" Value="NotOwner">
<Setter Property="Text" Value="{DynamicResource shareCopyOnModIo}" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</ui:Button>
<ui:Button x:Name="editThemeButton" Content="{DynamicResource editTheme}" Icon="{ui:SymbolIcon Color24}" HorizontalAlignment="Stretch" Click="EditThemeButton_Click" FontWeight="Regular" Tag="{Binding}">
</ui:Button>
</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>
</busyIndicator:BusyMask>
</Page>
+165
View File
@@ -0,0 +1,165 @@
using DesktopMagic.DataContexts;
using DesktopMagic.Dialogs;
using DesktopMagic.Helpers;
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;
Loaded += ThemePage_Loaded;
Unloaded += ThemePage_Unloaded;
}
private void ThemePage_Loaded(object sender, RoutedEventArgs e)
{
RefreshShareStates();
}
private void ThemePage_Unloaded(object sender, RoutedEventArgs e)
{
// Unsubscribe from events
_manager.SettingsChanged -= OnSettingsChanged;
}
private void OnSettingsChanged()
{
Dispatcher.Invoke(() =>
{
_dataContext.Settings = _manager.Settings;
RefreshShareStates();
});
}
private void RefreshShareStates()
{
foreach (Theme theme in _manager.Settings.Themes)
{
ModIoService.RefreshShareState(theme);
}
}
private async void ShareThemeButton_Click(object sender, RoutedEventArgs e)
{
if (sender is not System.Windows.Controls.Button button || button.Tag is not Theme theme)
{
return;
}
button.IsEnabled = false;
try
{
await ModIoService.ShareAsync(theme, Window.GetWindow(this), isBusy => _dataContext.IsSharing = isBusy);
}
finally
{
button.IsEnabled = true;
}
}
private async void AddThemeButton_Click(object sender, RoutedEventArgs e)
{
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)
{
// A subscribed theme would be downloaded again on the next sync.
await ModIoService.UnsubscribeAsync(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();
ModIoService.RefreshShareState(theme);
}
}
+33
View File
@@ -0,0 +1,33 @@
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);
/// <summary>
/// Pauses or resumes rendering of the plugin window (e.g. when it is fully occluded by other windows).
/// </summary>
void SetRenderPaused(bool paused);
event System.ComponentModel.CancelEventHandler Closing;
}
-43
View File
@@ -1,43 +0,0 @@
<UserControl x:Class="DesktopMagic.Plugins.ModEntry"
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: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}"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800"
Background="{DynamicResource MaterialDesignPaper}"
FontFamily="{DynamicResource MaterialDesignFont}"
Visibility="{Binding Visibility}">
<Grid Background="White" Margin="10">
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Border HorizontalAlignment="Left" Width="100" Height="100" BorderThickness="1" BorderBrush="LightGray">
<Image Source="{Binding Logo, FallbackValue={StaticResource ModioCogBlue}, TargetNullValue={StaticResource ModioCogBlue}}" Width="100" Height="100" />
</Border>
<StackPanel Grid.Column="1" HorizontalAlignment="Stretch">
<Label Content="{Binding FormattedDateAdded}" HorizontalAlignment="Center" ContentStringFormat="Added: {0}" />
<Label Content="{Binding FormattedDateUpdated}" HorizontalAlignment="Center" ContentStringFormat="Updated: {0}" />
<Button Command="{Binding Command}" Visibility="{Binding InstallButtonVisibility}" HorizontalAlignment="Stretch" Margin="10" Content="Install" />
<Button Command="{Binding Command}" Visibility="{Binding RemoveButtonVisibility}" HorizontalAlignment="Stretch" Margin="10" Content="Remove" />
</StackPanel>
<StackPanel Grid.Row="1" Grid.ColumnSpan="2">
<Label Content="{Binding Name}" FontSize="20" />
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
</StackPanel>
</Grid>
</UserControl>
+67 -2
View File
@@ -1,26 +1,91 @@
using DesktopMagic.Api;
using CuteUtils.Logging;
using DesktopMagic.Api;
using DesktopMagic.Settings;
using System;
using System.Drawing;
using Wpf.Ui.Controls;
namespace DesktopMagic.Plugins;
internal class PluginData(PluginWindow window, PluginSettings pluginSettings) : IPluginData
{
private readonly PluginWindow window = window;
private Size windowPixelSize = new(1, 1);
public ITheme Theme { get; } = pluginSettings.Theme;
public ITheme Theme => pluginSettings.Theme;
public Size WindowSize => new Size((int)window.ActualWidth, (int)window.ActualHeight);
public Size WindowPixelSize => windowPixelSize;
/// <summary>
/// Refreshes the cached device-pixel window size. Must be called from the UI thread
/// so the value can be read safely by plugins on their render thread.
/// </summary>
internal void UpdateWindowPixelSize(int width, int height)
{
windowPixelSize = new Size(Math.Max(1, width), Math.Max(1, height));
}
public Point WindowPosition => new Point((int)window.Left, (int)window.Top);
public string PluginName => window.PluginMetadata.Name;
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();
}
}
+53
View File
@@ -0,0 +1,53 @@
<UserControl x:Class="DesktopMagic.Plugins.ModEntry"
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:local="clr-namespace:DesktopMagic.Plugins"
xmlns:dataContext="clr-namespace:DesktopMagic.DataContexts"
d:DataContext="{d:DesignInstance Type=dataContext:PluginEntryDataContext}"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800"
Background="{DynamicResource MaterialDesignPaper}"
FontFamily="{DynamicResource MaterialDesignFont}"
Visibility="{Binding Visibility}">
<ui:Card Padding="5">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="2*" />
<RowDefinition Height="auto" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<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, 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" Margin="0 5">
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<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="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>
@@ -1,6 +1,7 @@
using System.Windows.Controls;
namespace DesktopMagic.Plugins;
/// <summary>
/// Interaction logic for ModEntry.xaml
/// </summary>
@@ -10,4 +11,4 @@ public partial class ModEntry : UserControl
{
InitializeComponent();
}
}
}
+55 -35
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="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,36 @@
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition Width="5" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Label Content="All plugins" FontSize="20" />
<ui:Card Grid.ColumnSpan="3" Padding="5" Margin="0 0 0 5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="10" />
<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" />
<ComboBox x:Name="categoryComboBox" Grid.Column="0" MinWidth="130" VerticalAlignment="Center" SelectedIndex="{Binding CategoryIndex, Mode=TwoWay}" SelectionChanged="CategoryComboBox_SelectionChanged">
<ComboBoxItem Content="{DynamicResource plugins}" />
<ComboBoxItem Content="{DynamicResource layouts}" />
<ComboBoxItem Content="{DynamicResource themes}" />
</ComboBox>
<ui:TextBox Grid.Column="2" Text="{Binding AllPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="AllPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchModIo}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
<ui:TextBox Grid.Column="4" Text="{Binding InstalledPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="InstalledPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchInstalled}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
</Grid>
</ui:Card>
<busyIndicator:BusyMask Grid.Row="2" IsBusy="{Binding IsSearching}" IndicatorType="Ring" BusyContent="Searching..." BusyContentMargin="0,50,0,0" IsBusyAtStartup="False">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding AllPlugins}">
<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 ShownAll}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="LightGray" BorderThickness="1">
<local:ModEntry />
</Border>
<local:ModEntry Margin="0 0 0 5" />
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.Style>
@@ -52,7 +63,7 @@
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<TextBlock Text="No plugins found!" FontSize="20" VerticalAlignment="Center" HorizontalAlignment="Center" />
<TextBlock Text="{DynamicResource nothingFound}" FontSize="20" VerticalAlignment="Center" HorizontalAlignment="Center" />
</ControlTemplate>
</Setter.Value>
</Setter>
@@ -64,17 +75,11 @@
</ScrollViewer>
</busyIndicator:BusyMask>
<Label Grid.Column="1" Content="Installed plugins" 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 ShownInstalled}" 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>
@@ -84,7 +89,7 @@
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<TextBlock Text="No plugins found!" FontSize="20" VerticalAlignment="Center" HorizontalAlignment="Center" />
<TextBlock Text="{DynamicResource nothingFound}" FontSize="20" VerticalAlignment="Center" HorizontalAlignment="Center" />
</ControlTemplate>
</Setter.Value>
</Setter>
@@ -95,9 +100,24 @@
</ItemsControl>
</ScrollViewer>
<Border Grid.Row="3" Grid.ColumnSpan="2" Padding="0 5 0 0" BorderThickness="0 2 0 0" BorderBrush="DarkGray">
<Image Source="{StaticResource ModioLogoBlueDark}" Cursor="Hand" Height="30" MouseUp="Image_MouseUp" HorizontalAlignment="Left" />
</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>
File diff suppressed because it is too large Load Diff
+18 -1
View File
@@ -1,6 +1,8 @@
using Modio.NET.Models;
using Modio.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
namespace DesktopMagic.Plugins;
@@ -15,24 +17,39 @@ public class PluginMetadata
public Uri? IconUri { get; set; }
public Uri? ProfileUri { get; set; }
public DateTime? Added { get; set; }
public DateTime? Updated { get; set; }
public string? Description { get; set; }
public string? Summary { get; set; }
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();
Id = mod.Id;
Author = mod.SubmittedBy?.Username;
IconUri = mod.Logo?.Thumb320x180;
ProfileUri = mod.ProfileUrl;
Added = DateTimeOffset.FromUnixTimeSeconds(mod.DateAdded).DateTime;
Updated = DateTimeOffset.FromUnixTimeSeconds(mod.DateUpdated).DateTime;
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)
+16 -12
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,25 +9,28 @@
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">
<Grid>
<Rectangle x:Name="panel" Fill="#4CBAFFEF" Stroke="White" Margin="0,0,0,0" Visibility="Collapsed" />
<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" />
<Viewbox x:Name="viewBox" StretchDirection="Both" Stretch="Uniform">
<Viewbox.Clip>
<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" />
</Border>
</Viewbox>
</Grid>
<Border SizeChanged="Border_SizeChanged" x:Name="border" />
<Viewbox SizeChanged="ViewBox_SizeChanged" x:Name="viewBox" StretchDirection="Both" Stretch="Uniform">
<Viewbox.Clip>
<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" 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
+18 -1
View File
@@ -7,15 +7,27 @@ namespace DesktopMagic.Plugins;
public class SettingElement
{
private string jsonValue = string.Empty;
private bool shareable;
[JsonIgnore]
public Setting Input { get; set; }
public Setting? Input { get; set; }
public string Name { get; set; }
public int OrderIndex { get; set; }
public string Id { get; set; }
/// <summary>
/// Whether the value may be included when sharing a layout on mod.io.
/// Free-text values (URLs, locations, file paths, ...) can contain personal data and are never shared.
/// Persisted so the decision stays stable while the plugin is not loaded.
/// </summary>
public bool Shareable
{
get => Input is null ? shareable : IsShareableInput(Input);
set => shareable = value;
}
public string JsonValue
{
get => Input?.GetJsonValue() ?? jsonValue;
@@ -40,6 +52,11 @@ public class SettingElement
OrderIndex = orderIndex;
}
private static bool IsShareableInput(Setting input)
{
return input is CheckBox or ColorPicker or ComboBox or IntegerUpDown or Slider;
}
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
[JsonConstructor]
@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Threading;
namespace DesktopMagic.Plugins;
/// <summary>
/// Keeps the per-instance plugin settings of every window sharing the same layout in sync.
/// Each window owns its own <see cref="DesktopMagic.Api.Settings.Setting"/> objects (plugins
/// hold them in readonly fields and subscribe to them in Start), so a value change on one
/// window has to be mirrored onto the sibling windows.
/// </summary>
internal sealed class SettingSynchronizer
{
private readonly object _lock = new();
private readonly List<IPluginWindow> windows = [];
private readonly HashSet<(IPluginWindow Window, string Id)> inFlight = [];
/// <summary>
/// Registers a window so it receives mirrored settings from the other windows of this layout.
/// </summary>
public void Register(IPluginWindow window)
{
lock (_lock)
{
if (!windows.Contains(window))
{
windows.Add(window);
}
}
}
/// <summary>
/// Unregisters a window and returns whether no windows remain.
/// </summary>
public bool Unregister(IPluginWindow window)
{
lock (_lock)
{
windows.Remove(window);
return windows.Count == 0;
}
}
/// <summary>
/// Mirrors a setting value change onto every other registered window.
/// </summary>
public void SettingChanged(IPluginWindow origin, string id, string value)
{
Mirror(origin, id, window => window.ApplySettingValue(id, value));
}
/// <summary>
/// Mirrors a button click onto every other registered window.
/// </summary>
public void ButtonClicked(IPluginWindow origin, string id)
{
Mirror(origin, id, window => window.ApplyButtonClick(id));
}
private void Mirror(IPluginWindow origin, string id, Action<IPluginWindow> apply)
{
IPluginWindow[] snapshot;
lock (_lock)
{
snapshot = windows.ToArray();
}
foreach (IPluginWindow window in snapshot)
{
if (ReferenceEquals(window, origin) || !window.IsRunning)
{
continue;
}
lock (_lock)
{
if (!inFlight.Add((window, id)))
{
continue;
}
}
if (window is not DispatcherObject dispatcherObject)
{
lock (_lock)
{
inFlight.Remove((window, id));
}
continue;
}
_ = dispatcherObject.Dispatcher.InvokeAsync(() =>
{
try
{
apply(window);
}
finally
{
lock (_lock)
{
inFlight.Remove((window, id));
}
}
});
}
}
}
+30 -2
View File
@@ -1,5 +1,6 @@
using DesktopMagic.Api;
using DesktopMagic.Helpers;
using DesktopMagic.Settings;
using System.ComponentModel;
using System.Runtime.CompilerServices;
@@ -11,16 +12,43 @@ using Color = System.Drawing.Color;
namespace DesktopMagic.Plugins;
public class Theme : ITheme, INotifyPropertyChanged
public class Theme(string name) : ITheme, INotifyPropertyChanged, IModIoShareable
{
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;
private ModIoShareState shareState;
public ModIoLink? ModIo { get; set; }
[JsonIgnore]
public ModIoShareState ShareState
{
get => shareState;
set
{
if (shareState != value)
{
shareState = value;
OnPropertyChanged();
}
}
}
public string Name
{
get => name;
set
{
name = value;
OnPropertyChanged();
}
}
public Color 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,827 @@
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;
}
}
public void SetRenderPaused(bool paused)
{
if (webView.CoreWebView2 is null)
{
return;
}
if (paused)
{
_ = webView.CoreWebView2.TrySuspendAsync();
}
else
{
webView.CoreWebView2.Resume();
}
}
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
WindowInteropHelper helper = new(this);
_ = WindowPos.SetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE,
WindowPos.GetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE) | WindowPos.WS_EX_NOACTIVATE);
HwndSource.FromHwnd(helper.Handle)?.AddHook((IntPtr hwnd, int msg, IntPtr _, IntPtr lParam, ref bool _) =>
ScreenUtilities.ClampToScreenHook(hwnd, msg, lParam, screenBounds));
}
private async void Window_ContentRendered(object? sender, EventArgs e)
{
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;
}
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,34 +3,100 @@
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="updatePlugins">Neu Laden</system:String>
<system:String x:Key="openPluginsFolder">Plugins Ordner Öffnen</system:String>
<system:String x:Key="pluginManager">Plugin Manager</system:String>
<system:String x:Key="folder">Ordner</system:String>
<system:String x:Key="autoStart">Starten beim Hochfahren</system:String>
<system:String x:Key="unknown">Unbekannt</system:String>
<system:String x:Key="open">Öffnen</system:String>
<system:String x:Key="quit">Schließen</system:String>
<system:String x:Key="login">Einloggen</system:String>
<system:String x:Key="logout">Ausloggen</system:String>
<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="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="googleCalendar">Google Kalender</system:String>
<system:String x:Key="musicVisualizer">Musik Visualizer</system:String>
<system:String x:Key="musicVisualizerOptions">Musik Visualizer Einstellungen:</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>
<system:String x:Key="authorFormat">Author: {0}</system:String>
<system:String x:Key="addedFormat">Erstellt: {0}</system:String>
<system:String x:Key="updatedFormat">Aktualisiert: {0}</system:String>
<system:String x:Key="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>
<system:String x:Key="close">Schließen</system:String>
<system:String x:Key="overwrite">Überschreiben</system:String>
<system:String x:Key="keepMine">Meine behalten</system:String>
<system:String x:Key="shareOnModIo">Auf mod.io teilen</system:String>
<system:String x:Key="updateOnModIo">Auf mod.io aktualisieren</system:String>
<system:String x:Key="sharedOnModIo">Auf mod.io ansehen</system:String>
<system:String x:Key="shareCopyOnModIo">Als neu auf mod.io teilen</system:String>
<system:String x:Key="enterModIoSummary">Kurze Zusammenfassung für mod.io eingeben:</system:String>
<system:String x:Key="enterModIoChangelog">Was hat sich geändert? (optional)</system:String>
<system:String x:Key="modIoShareLayoutWarning">Jeder auf mod.io kann dieses Layout herunterladen. Es enthält die aktivierten Widgets, ihre Positionen, die verwendeten Themes und ihre Nicht-Text-Einstellungen. Text-Einstellungen (z. B. URLs, Orte oder Dateipfade) und gespeicherte Widget-Daten werden nicht geteilt.</system:String>
<system:String x:Key="modIoShareThemeWarning">Jeder auf mod.io kann dieses Theme herunterladen.</system:String>
<system:String x:Key="modIoLocalPluginsWarning">Diese Widgets sind lokale Plugins, die andere nicht installieren können: {0}</system:String>
<system:String x:Key="modIoShareSuccess">"{0}" wurde auf mod.io veröffentlicht.</system:String>
<system:String x:Key="modIoUpdateSuccess">Das Update von "{0}" wurde auf mod.io veröffentlicht.</system:String>
<system:String x:Key="modIoShareFailed">Teilen auf mod.io fehlgeschlagen: {0}</system:String>
<system:String x:Key="modIoSessionExpired">Ihre mod.io-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.</system:String>
<system:String x:Key="modIoUpdateConflict">"{0}" wurde auf mod.io aktualisiert, aber Sie haben es auch lokal geändert. Ihre Änderungen mit der neuen Version überschreiben?</system:String>
<system:String x:Key="cannotShareEmptyLayout">Das leere Layout kann nicht geteilt werden!</system:String>
<system:String x:Key="layouts">Layouts</system:String>
<system:String x:Key="searchModIo">mod.io durchsuchen...</system:String>
<system:String x:Key="searchInstalled">Installierte durchsuchen...</system:String>
<system:String x:Key="nothingFound">Nichts gefunden!</system:String>
<system:String x:Key="modIoInstallFailed">Installation von "{0}" fehlgeschlagen: {1}</system:String>
<system:String x:Key="uploadingToModIo">Wird auf mod.io hochgeladen...</system:String>
<col:ArrayList x:Key="musicVisualizerOptionsComboboxItems">
<system:String>Unten</system:String>
<system:String>Mitte</system:String>
<system:String>Oben</system:String>
</col:ArrayList>
</ResourceDictionary>
</ResourceDictionary>
@@ -3,34 +3,102 @@
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="updatePlugins">Reload</system:String>
<system:String x:Key="openPluginsFolder">Open Plugins Folder</system:String>
<system:String x:Key="pluginManager">Plugin Manager</system:String>
<system:String x:Key="folder">Folder</system:String>
<system:String x:Key="autoStart">Launch on startup</system:String>
<system:String x:Key="unknown">Unknown</system:String>
<system:String x:Key="open">Open</system:String>
<system:String x:Key="quit">Quit</system:String>
<system:String x:Key="login">Log In</system:String>
<system:String x:Key="logout">Log Out</system:String>
<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="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="googleCalendar">Google Calendar</system:String>
<system:String x:Key="musicVisualizer">Music Visualizer</system:String>
<system:String x:Key="musicVisualizerOptions">Music Visualizer Options:</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>
<system:String x:Key="authorFormat">Author: {0}</system:String>
<system:String x:Key="addedFormat">Added: {0}</system:String>
<system:String x:Key="updatedFormat">Updated: {0}</system:String>
<system:String x:Key="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>
<system:String x:Key="close">Close</system:String>
<system:String x:Key="overwrite">Overwrite</system:String>
<system:String x:Key="keepMine">Keep mine</system:String>
<system:String x:Key="shareOnModIo">Share on mod.io</system:String>
<system:String x:Key="updateOnModIo">Update on mod.io</system:String>
<system:String x:Key="sharedOnModIo">View on mod.io</system:String>
<system:String x:Key="shareCopyOnModIo">Share as new on mod.io</system:String>
<system:String x:Key="enterModIoSummary">Enter a short summary shown on mod.io:</system:String>
<system:String x:Key="enterModIoChangelog">What changed? (optional)</system:String>
<system:String x:Key="modIoShareLayoutWarning">Everyone on mod.io will be able to download this layout. It includes the enabled widgets, their positions, the used themes and their non-text settings. Text settings (e.g. URLs, locations or file paths) and saved widget data are not shared.</system:String>
<system:String x:Key="modIoShareThemeWarning">Everyone on mod.io will be able to download this theme.</system:String>
<system:String x:Key="modIoLocalPluginsWarning">These widgets are local plugins that others cannot install: {0}</system:String>
<system:String x:Key="modIoShareSuccess">"{0}" was published on mod.io.</system:String>
<system:String x:Key="modIoUpdateSuccess">The update of "{0}" was published on mod.io.</system:String>
<system:String x:Key="modIoShareFailed">Sharing on mod.io failed: {0}</system:String>
<system:String x:Key="modIoSessionExpired">Your mod.io session has expired. Please log in again.</system:String>
<system:String x:Key="modIoUpdateConflict">"{0}" was updated on mod.io, but you also changed it locally. Overwrite your changes with the new version?</system:String>
<system:String x:Key="cannotShareEmptyLayout">The empty layout cannot be shared!</system:String>
<system:String x:Key="layouts">Layouts</system:String>
<system:String x:Key="searchModIo">Search mod.io...</system:String>
<system:String x:Key="searchInstalled">Search installed...</system:String>
<system:String x:Key="nothingFound">Nothing found!</system:String>
<system:String x:Key="modIoInstallFailed">Installing "{0}" failed: {1}</system:String>
<system:String x:Key="uploadingToModIo">Uploading to mod.io...</system:String>
<col:ArrayList x:Key="musicVisualizerOptionsComboboxItems">
<system:String>Bottom</system:String>
<system:String>Middle</system:String>
<system:String>Top</system:String>
</col:ArrayList>
</ResourceDictionary>
</ResourceDictionary>
@@ -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,40 @@ 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));
}
}
}
+70 -8
View File
@@ -1,27 +1,60 @@
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, IModIoShareable
{
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;
private ModIoShareState shareState;
public Theme Theme
public ModIoLink? ModIo { get; set; }
[JsonIgnore]
public ModIoShareState ShareState
{
get => theme;
get => shareState;
set
{
theme = value;
OnPropertyChanged();
if (shareState != value)
{
shareState = value;
OnPropertyChanged();
}
}
}
[JsonIgnore]
public Theme 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
{
if (currentThemeName != value)
{
currentThemeName = value;
UpdateTheme();
}
}
}
@@ -45,9 +78,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)
+48
View File
@@ -0,0 +1,48 @@
using System;
namespace DesktopMagic.Settings;
/// <summary>
/// Connects a local layout or theme to the mod.io mod it was published as or downloaded from.
/// </summary>
public class ModIoLink
{
public uint ModId { get; set; }
/// <summary>
/// The modfile (version) the local copy is based on.
/// </summary>
public uint ModfileId { get; set; }
/// <summary>
/// Hash of the shareable content at the last publish or download, used to detect local changes.
/// </summary>
public string? PublishedHash { get; set; }
/// <summary>
/// Whether the logged in mod.io user submitted the mod and is therefore allowed to upload new versions.
/// </summary>
public bool IsOwner { get; set; }
public Uri? ProfileUri { get; set; }
}
public enum ModIoShareState
{
NotShared,
UpToDate,
Modified,
NotOwner
}
/// <summary>
/// A local item (layout or theme) that can be shared on mod.io.
/// </summary>
public interface IModIoShareable
{
string Name { get; }
ModIoLink? ModIo { get; set; }
ModIoShareState ShareState { get; set; }
}
+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,6 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<OutputType>Library</OutputType>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
+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.");
}
}
@@ -1,36 +1,56 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<Company>Stone_Red</Company>
<Product>Stone_Red</Product>
<Version>0.0.0.5</Version>
<Version>1.1.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.1.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'">
<DocumentationFile>C:\Users\David\Programmieren\DesktopMagic\src\DesktopMagicPluginAPI\DesktopMagicPluginAPI.xml</DocumentationFile>
<DocumentationFile>$([System.IO.Path]::Combine($(MSBuildProjectDirectory), "DesktopMagicPluginAPI.xml"))</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<DocumentationFile>$([System.IO.Path]::Combine($(MSBuildProjectDirectory), "DesktopMagicPluginAPI.xml"))</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DocumentationFile>C:\Users\David\Programmieren\DesktopMagic\src\DesktopMagicPluginAPI\DesktopMagicPluginAPI.xml</DocumentationFile>
<DocumentationFile>$([System.IO.Path]::Combine($(MSBuildProjectDirectory), "DesktopMagicPluginAPI.xml"))</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<DocumentationFile>$([System.IO.Path]::Combine($(MSBuildProjectDirectory), "DesktopMagicPluginAPI.xml"))</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\LICENSE">
<Pack>True</Pack>
<PackagePath></PackagePath>
<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>
</Project>
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Drawing;
@@ -6,6 +7,8 @@ namespace DesktopMagic.Api.Drawing;
internal class FontComparer : IEqualityComparer<Font>
{
private const float Tolerance = 0.01f;
public bool Equals(Font? font1, Font? font2)
{
if (font1 is null || font2 is null)
@@ -18,7 +21,7 @@ internal class FontComparer : IEqualityComparer<Font>
return false;
}
if (font1.SizeInPoints != font2.SizeInPoints)
if (Math.Abs(font1.SizeInPoints - font2.SizeInPoints) > Tolerance)
{
return false;
}
+30 -1
View File
@@ -13,10 +13,16 @@ public interface IPluginData
ITheme Theme { get; }
/// <summary>
/// Gets the window size of the plugin window.
/// Gets the window size of the plugin window in device independent pixels.
/// </summary>
Size WindowSize { get; }
/// <summary>
/// Gets the window size of the plugin window in physical (device) pixels.
/// Use this to create a rendering surface that maps 1:1 to the display.
/// </summary>
Size WindowPixelSize { get; }
/// <summary>
/// Gets the window position of the plugin window.
/// </summary>
@@ -36,4 +42,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,
}
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2021 Stone_Red
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+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
{
}
+50 -5
View File
@@ -7,20 +7,35 @@ 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("horizontalAlignment", "Horizontal Alignment", -999)]
[Setting("desktopmagic-horizontal-alignment", "Horizontal Alignment", -999)]
internal ComboBox horizontalAlignment = new ComboBox("Center", "Left", "Right");
[Setting("verticalAlignment", "Vertical Alignment", -998)]
[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>
/// Informations about the main application.
/// Information about the main application.
/// </summary>
public IPluginData Application
{
@@ -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);
}
}
}

Some files were not shown because too many files have changed in this diff Show More