Add per-screen layouts with DPI-aware widget positioning and an empty layout option

This commit is contained in:
Stone_Red
2026-08-02 01:01:59 +02:00
parent df650afd00
commit a6696a87d8
15 changed files with 877 additions and 87 deletions
@@ -1,7 +1,10 @@
using DesktopMagic.Helpers;
using DesktopMagic.Settings;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
namespace DesktopMagic.DataContexts;
@@ -11,6 +14,8 @@ internal class MainWindowDataContext : INotifyPropertyChanged
public event PropertyChangedEventHandler? PropertyChanged;
private static DesktopMagicSettings settings = new();
private static string? selectedScreenDeviceName;
private string? selectedLayoutName;
private bool isLoading = true;
private string? pluginsSearchText;
@@ -34,6 +39,30 @@ internal class MainWindowDataContext : INotifyPropertyChanged
}
}
public ObservableCollection<ScreenDisplay> Screens { get; } = [];
public string? SelectedScreenId
{
get => selectedScreenDeviceName;
set
{
selectedScreenDeviceName = value;
UpdateSelection();
}
}
public Layout SelectedLayout => Manager.Instance.SelectedLayout;
public string? SelectedLayoutName
{
get => selectedLayoutName;
set
{
selectedLayoutName = value;
OnPropertyChanged();
}
}
public bool IsLoading
{
get => isLoading;
@@ -76,8 +105,67 @@ internal class MainWindowDataContext : INotifyPropertyChanged
return settings;
}
/// <summary>
/// Refreshes the list of detected screens and ensures a screen is selected.
/// </summary>
public void RefreshScreens()
{
List<System.Windows.Forms.Screen> allScreens = ScreenUtilities.GetAllScreens();
Screens.Clear();
for (int i = 0; i < allScreens.Count; i++)
{
Screens.Add(new ScreenDisplay(allScreens[i], i));
}
if (selectedScreenDeviceName is null || !Screens.Any(screen => screen.DeviceName == selectedScreenDeviceName))
{
SelectedScreenId = 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(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
{
public ScreenDisplay(System.Windows.Forms.Screen screen, int index)
{
DeviceName = screen.DeviceName;
DisplayName = ScreenUtilities.GetScreenLabel(screen, index);
}
public string DeviceName { get; }
public string DisplayName { get; }
}
+266
View File
@@ -0,0 +1,266 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Windows;
namespace DesktopMagic.Helpers;
/// <summary>
/// Helpers for enumerating screens and converting between percentage based
/// positions/sizes (relative to a screen's bounds) and absolute positions.
/// All conversions work in WPF DIP space because WPF window <see cref="Window.Left"/>,
/// <see cref="Window.Top"/>, <see cref="Window.Width"/> and <see cref="Window.Height"/>
/// are expressed in device independent pixels, while <see cref="System.Windows.Forms.Screen.Bounds"/>
/// is expressed in physical pixels. Each screen is therefore converted to DIP space
/// using its own DPI scaling factor.
/// </summary>
public static class ScreenUtilities
{
private const uint MonitorDefaultToNearest = 0x00000002;
private const int MDT_EFFECTIVE_DPI = 0;
/// <summary>
/// Gets the list of all screens currently connected, in the order reported by Windows.
/// </summary>
public static List<System.Windows.Forms.Screen> GetAllScreens()
{
return System.Windows.Forms.Screen.AllScreens.ToList();
}
/// <summary>
/// Gets the screen matching the given device name (e.g. "\\.\DISPLAY1"), or null.
/// </summary>
public static System.Windows.Forms.Screen? GetScreenByDeviceName(string? deviceName)
{
if (string.IsNullOrWhiteSpace(deviceName))
{
return null;
}
return System.Windows.Forms.Screen.AllScreens.FirstOrDefault(screen => string.Equals(screen.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Gets the primary screen, or the first available screen as a fallback.
/// </summary>
public static System.Windows.Forms.Screen GetPrimaryScreen()
{
return System.Windows.Forms.Screen.PrimaryScreen ?? System.Windows.Forms.Screen.AllScreens.FirstOrDefault()
?? throw new InvalidOperationException("No screens detected.");
}
/// <summary>
/// Aspect ratio (Width / Height) of the screen's bounds.
/// </summary>
public static double GetAspectRatio(System.Windows.Forms.Screen screen)
{
if (screen.Bounds.Height == 0)
{
return 0;
}
return screen.Bounds.Width / (double)screen.Bounds.Height;
}
/// <summary>
/// Converts a percentage based position (0..1 relative to the screen bounds) to an absolute
/// WPF position (DIPs) on that screen.
/// </summary>
public static Point PercentToPosition(Point percent, System.Drawing.Rectangle bounds)
{
Rect dips = GetScreenDips(bounds);
return new Point(
dips.Left + (percent.X * dips.Width),
dips.Top + (percent.Y * dips.Height));
}
/// <summary>
/// Converts an absolute WPF position (DIPs) to a percentage (0..1) of the screen bounds.
/// </summary>
public static Point PositionToPercent(Point position, System.Drawing.Rectangle bounds)
{
Rect dips = GetScreenDips(bounds);
if (dips.Width == 0 || dips.Height == 0)
{
return new Point(0.05, 0.05);
}
return new Point(
(position.X - dips.Left) / dips.Width,
(position.Y - dips.Top) / dips.Height);
}
/// <summary>
/// Converts a percentage based size (0..1 of the screen bounds) to an absolute WPF size (DIPs).
/// </summary>
public static Point PercentSizeToSize(Point percent, System.Drawing.Rectangle bounds)
{
Rect dips = GetScreenDips(bounds);
return new Point(
percent.X * dips.Width,
percent.Y * dips.Height);
}
/// <summary>
/// Converts an absolute WPF size (DIPs) to a percentage (0..1) of the screen bounds.
/// </summary>
public static Point SizeToPercent(Point size, System.Drawing.Rectangle bounds)
{
Rect dips = GetScreenDips(bounds);
if (dips.Width == 0 || dips.Height == 0)
{
return new Point(0.3, 0.3);
}
return new Point(
size.X / dips.Width,
size.Y / dips.Height);
}
/// <summary>
/// Clamps an absolute WPF position (DIPs) so that the window (of the given size)
/// stays within the screen bounds. This prevents widgets from being moved to another screen.
/// </summary>
public static Point ClampToScreenBounds(Point topLeft, Size size, System.Drawing.Rectangle bounds)
{
Rect dips = GetScreenDips(bounds);
double x = topLeft.X;
double y = topLeft.Y;
if (size.Width <= dips.Width)
{
x = Math.Clamp(x, dips.Left, dips.Right - size.Width);
}
else
{
x = dips.Left;
}
if (size.Height <= dips.Height)
{
y = Math.Clamp(y, dips.Top, dips.Bottom - size.Height);
}
else
{
y = dips.Top;
}
return new Point(x, y);
}
/// <summary>
/// Builds a human readable label for a screen, e.g. "Display 1 · DELL U2715H · 3840x2160".
/// </summary>
public static string GetScreenLabel(System.Windows.Forms.Screen screen, int index)
{
string name = GetFriendlyName(screen);
string resolution = $"{screen.Bounds.Width}x{screen.Bounds.Height}";
return string.IsNullOrWhiteSpace(name) || string.Equals(name, "Generic PnP Monitor", StringComparison.OrdinalIgnoreCase)
? $"Display {index + 1} · {resolution}"
: $"Display {index + 1} · {name} · {resolution}";
}
/// <summary>
/// Gets the friendly monitor model name (e.g. "DELL U2715H") for the given screen,
/// or an empty string when it cannot be determined.
/// </summary>
public static string GetFriendlyName(System.Windows.Forms.Screen screen)
{
try
{
var device = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf<DISPLAY_DEVICE>() };
if (EnumDisplayDevices(screen.DeviceName, 0, ref device, 0))
{
var monitor = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf<DISPLAY_DEVICE>() };
if (EnumDisplayDevices(device.DeviceName, 0, ref monitor, 0))
{
return monitor.DeviceString;
}
}
}
catch (Exception)
{
// Fall through to an empty name.
}
return string.Empty;
}
/// <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
{
var 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;
}
}
+213 -19
View File
@@ -5,6 +5,7 @@ using DesktopMagic.Settings;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Text.Json;
@@ -35,6 +36,9 @@ public sealed class Manager
}
}
// Name of the built-in layout that shows no widgets on a screen.
public const string EmptyLayoutName = "Empty";
// Plugin management
private readonly Dictionary<uint, InternalPluginData> _plugins = [];
private readonly Dictionary<PluginMetadata, Type> _builtInPlugins = new()
@@ -65,6 +69,13 @@ public sealed class Manager
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() }
@@ -137,20 +148,46 @@ public sealed class Manager
App.Logger.LogInfo($"Loaded {_plugins.Count} plugins", source: "Manager");
}
public void LoadPlugin(uint pluginId, Action<InternalPluginData>? onPluginLoaded = null)
/// <summary>
/// Loads (or unloads) the given plugin on every screen that currently uses the given layout.
/// This keeps screens sharing a layout in sync when plugins are enabled or disabled.
/// </summary>
public void LoadPlugin(uint pluginId, Layout layout, Action<InternalPluginData>? onPluginLoaded = null)
{
if (!_plugins.TryGetValue(pluginId, out InternalPluginData? internalPluginData))
{
return;
}
if (!Settings.CurrentLayout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings))
if (!layout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings))
{
pluginSettings = new PluginSettings();
Settings.CurrentLayout.Plugins.Add(pluginId, pluginSettings);
layout.Plugins.Add(pluginId, pluginSettings);
}
IPluginWindow? existingWindow = PluginWindows.FirstOrDefault(w => w.PluginMetadata.Id == internalPluginData.Metadata.Id);
pluginSettings.Metadata = internalPluginData.Metadata;
pluginSettings.Owner = layout;
foreach (System.Windows.Forms.Screen screen in ScreenUtilities.GetAllScreens())
{
if (GetLayoutForScreen(screen) == layout)
{
EnsurePluginWindow(screen, layout, internalPluginData, pluginSettings, onPluginLoaded);
}
}
layout.UpdatePlugins();
}
/// <summary>
/// Creates or closes the plugin window for a single screen, based on the plugin settings.
/// </summary>
private void EnsurePluginWindow(System.Windows.Forms.Screen screen, Layout layout, InternalPluginData internalPluginData, PluginSettings pluginSettings, Action<InternalPluginData>? onPluginLoaded)
{
string screenDeviceName = screen.DeviceName;
Rectangle screenBounds = screen.Bounds;
IPluginWindow? existingWindow = PluginWindows.FirstOrDefault(w => w.PluginMetadata.Id == internalPluginData.Metadata.Id && w.ScreenDeviceName == screenDeviceName);
if (existingWindow is not null || !pluginSettings.Enabled)
{
@@ -176,21 +213,21 @@ public sealed class Manager
if (_builtInPlugins.TryGetValue(internalPluginData.Metadata, out Type? pluginType))
{
window = new PluginWindow((Api.Plugin)Activator.CreateInstance(pluginType)!, internalPluginData.Metadata, pluginSettings)
window = new PluginWindow((Api.Plugin)Activator.CreateInstance(pluginType)!, internalPluginData.Metadata, pluginSettings, screenBounds, screenDeviceName)
{
Title = internalPluginData.Metadata.Id.ToString()
};
}
else if (internalPluginData.Type == PluginType.Web)
{
window = new WebPluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath)
window = new WebPluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath, screenBounds, screenDeviceName)
{
Title = internalPluginData.Metadata.Id.ToString()
};
}
else
{
window = new PluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath)
window = new PluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath, screenBounds, screenDeviceName)
{
Title = internalPluginData.Metadata.Id.ToString()
};
@@ -205,10 +242,21 @@ public sealed class Manager
Action exitHandler = () =>
{
PluginWindows.Remove(window);
// Close the widget on every screen using this layout
foreach (System.Windows.Forms.Screen sharedScreen in ScreenUtilities.GetAllScreens())
{
if (GetLayoutForScreen(sharedScreen) == layout)
{
IPluginWindow? sharedWindow = PluginWindows.FirstOrDefault(w => w.PluginMetadata.Id == internalPluginData.Metadata.Id && w.ScreenDeviceName == sharedScreen.DeviceName);
if (sharedWindow is not null)
{
PluginWindows.Remove(sharedWindow);
BlockWindowsClosing = false;
window.Close();
sharedWindow.Close();
BlockWindowsClosing = true;
}
}
}
pluginSettings.Enabled = false;
};
@@ -255,7 +303,9 @@ public sealed class Manager
{
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;
}
@@ -272,9 +322,62 @@ public sealed class Manager
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[primaryScreen.DeviceName] = Settings.CurrentLayoutName ?? "Default";
}
App.Logger.LogInfo("Settings migrated to screen-aware layouts", source: "Manager");
}
public void SaveSettings()
{
if (!IsLoaded)
@@ -292,9 +395,60 @@ public sealed class Manager
#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(screen.DeviceName, 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[screen.DeviceName] = layout.Name;
SaveSettings();
}
/// <summary>
/// Gets all plugin windows currently shown on the given screen.
/// </summary>
public IEnumerable<IPluginWindow> GetWindowsForScreen(string screenDeviceName)
{
return PluginWindows.Where(window => window.ScreenDeviceName == screenDeviceName).ToList();
}
/// <summary>
/// Loads all screens' layouts at once, opening the enabled widgets of every screen.
/// </summary>
public void LoadLayout(Action? onComplete = null)
{
App.Logger.LogInfo("Loading layout", source: "Manager");
App.Logger.LogInfo("Loading layouts", source: "Manager");
BlockWindowsClosing = false;
foreach (IPluginWindow window in PluginWindows)
@@ -305,37 +459,77 @@ public sealed class Manager
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 (!Settings.CurrentLayout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings))
if (!layout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings))
{
Settings.CurrentLayout.Plugins.Add(pluginId, new PluginSettings() { Metadata = internalPluginData.Metadata });
layout.Plugins.Add(pluginId, new PluginSettings() { Metadata = internalPluginData.Metadata, Owner = layout });
continue;
}
pluginSettings.Metadata = internalPluginData.Metadata;
pluginSettings.Owner = layout;
if (pluginSettings.Enabled)
{
LoadPlugin(pluginId);
EnsurePluginWindow(screen, layout, internalPluginData, pluginSettings, null);
}
}
// Remove plugins that are not loaded anymore
List<uint> pluginIdsToRemove = Settings.CurrentLayout.Plugins.Keys.Where(id => !_plugins.ContainsKey(id)).ToList();
List<uint> pluginIdsToRemove = layout.Plugins.Keys.Where(id => !_plugins.ContainsKey(id)).ToList();
foreach (uint pluginId in pluginIdsToRemove)
{
Settings.CurrentLayout.Plugins.Remove(pluginId);
layout.Plugins.Remove(pluginId);
}
Settings.CurrentLayout.UpdatePlugins();
onComplete?.Invoke();
App.Logger.LogInfo("Layout loaded", source: "Manager");
layout.UpdatePlugins();
}
#endregion
+14 -7
View File
@@ -32,7 +32,7 @@
</ui:Card>
<ScrollViewer Background="#FFBBBBBB" PreviewMouseWheel="ScrollViewer_PreviewMouseWheel" Grid.Row="1" VerticalAlignment="Stretch" VerticalContentAlignment="Top">
<ItemsControl x:Name="pluginsItemsControl" ItemsSource="{Binding Settings.CurrentLayout.Plugins}">
<ItemsControl x:Name="pluginsItemsControl" ItemsSource="{Binding SelectedLayout.Plugins}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<ui:CardExpander Tag="{Binding}" Margin="0 0 0 5" Expanded="OptionsCardExpander_Expanded">
@@ -70,17 +70,24 @@
<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="1*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="0.5*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="0.5*" />
<ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions>
<ComboBox x:Name="layoutsComboBox" ItemsSource="{Binding Settings.Layouts}" DisplayMemberPath="Name" SelectedValue="{Binding Settings.CurrentLayoutName}" SelectedValuePath="Name" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Padding="4" SelectionChanged="LayoutsComboBox_SelectionChanged" />
<ui:Button x:Name="newLayoutButton" Content="{DynamicResource newLayout}" Grid.Column="2" Icon="{ui:SymbolIcon Add24}" 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"/>
<ComboBox x:Name="screensComboBox" ItemsSource="{Binding Screens}" DisplayMemberPath="DisplayName" SelectedValue="{Binding SelectedScreenId}" SelectedValuePath="DeviceName" VerticalAlignment="Center" />
<ComboBox x:Name="layoutsComboBox" Grid.Column="2" ItemsSource="{Binding Settings.Layouts}" DisplayMemberPath="Name" SelectedValue="{Binding SelectedLayoutName}" SelectedValuePath="Name" VerticalAlignment="Center" SelectionChanged="LayoutsComboBox_SelectionChanged" />
<StackPanel Grid.Row="2" Grid.ColumnSpan="3" Orientation="Horizontal" HorizontalAlignment="Right">
<ui:Button x:Name="newLayoutButton" Content="{DynamicResource newLayout}" Icon="{ui:SymbolIcon Add24}" Margin="0,0,5,0" Click="NewLayoutButton_Click" FontWeight="Regular" />
<ui:Button x:Name="removeLayoutButton" Content="{DynamicResource deleteLayout}" Icon="{ui:SymbolIcon Delete24}" Margin="0,0,5,0" Click="RemoveLayoutButton_Click" FontWeight="Regular" />
</StackPanel>
</Grid>
</ui:Card>
</Grid>
+64 -33
View File
@@ -20,7 +20,6 @@ public partial class MainPage : Page
{
private readonly Manager _manager = Manager.Instance;
private readonly MainWindowDataContext _dataContext;
private bool _isLoadingLayout = false;
public MainPage()
{
@@ -45,6 +44,8 @@ public partial class MainPage : Page
{
// Initialize edit checkbox state
editCheckBox.IsChecked = _manager.IsEditMode;
_dataContext.RefreshScreens();
}
private void MainPage_Unloaded(object sender, RoutedEventArgs e)
@@ -85,7 +86,7 @@ public partial class MainPage : Page
uint pluginId = uint.Parse(checkBox.Tag.ToString()!);
_manager.LoadPlugin(pluginId, (internalPluginData) =>
_manager.LoadPlugin(pluginId, _manager.SelectedLayout, (internalPluginData) =>
{
Dispatcher.Invoke(() =>
{
@@ -145,32 +146,46 @@ public partial class MainPage : Page
private void LayoutsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
// Prevent recursive calls and only process if fully loaded
if (_isLoadingLayout || !_manager.IsLoaded || !IsLoaded)
ApplySelectedLayout();
}
/// <summary>
/// Binds the currently selected layout to the currently selected screen and reloads it.
/// No-op when the layout is already the one bound to the screen (e.g. programmatic resets).
/// </summary>
private void ApplySelectedLayout()
{
if (_dataContext.SelectedScreenId is null)
{
return;
}
// Check if this is actually a user-initiated change
// by verifying that the removed and added items are different
if (e.RemovedItems.Count > 0 && e.AddedItems.Count > 0)
{
if (e.RemovedItems[0] == e.AddedItems[0])
System.Windows.Forms.Screen? screen = ScreenUtilities.GetScreenByDeviceName(_dataContext.SelectedScreenId);
if (screen is null)
{
return;
}
string? layoutName = _dataContext.SelectedLayoutName;
if (layoutName is null)
{
return;
}
try
Layout? layout = _manager.Settings.Layouts.FirstOrDefault(l => l.Name == layoutName);
if (layout is null)
{
_isLoadingLayout = true;
_manager.SaveSettings();
_manager.LoadLayout();
return;
}
finally
if (_manager.GetLayoutForScreen(screen) == layout)
{
_isLoadingLayout = false;
return;
}
_manager.BindLayoutToScreen(screen, layout);
_manager.ReloadScreen(screen);
_dataContext.RefreshSelection();
}
private async void NewLayoutButton_Click(object sender, RoutedEventArgs e)
@@ -194,18 +209,11 @@ public partial class MainPage : Page
return;
}
try
{
_isLoadingLayout = true;
_manager.Settings.Layouts.Add(new Layout(inputDialog.ResponseText.Trim()));
_manager.Settings.CurrentLayoutName = inputDialog.ResponseText.Trim();
_manager.SaveSettings();
_manager.LoadLayout();
}
finally
{
_isLoadingLayout = false;
}
// Select the new layout so the user can apply it to the current screen
_dataContext.SelectedLayoutName = inputDialog.ResponseText.Trim();
}
}
@@ -237,17 +245,40 @@ public partial class MainPage : Page
return;
}
try
Layout? layout = _manager.Settings.Layouts.FirstOrDefault(l => l.Name == _dataContext.SelectedLayoutName);
if (layout is null)
{
_isLoadingLayout = true;
_ = _manager.Settings.Layouts.Remove(_manager.Settings.CurrentLayout);
return;
}
if (layout.Name == Manager.EmptyLayoutName)
{
Wpf.Ui.Controls.MessageBox cannotDeleteMessageBox = new Wpf.Ui.Controls.MessageBox
{
Title = App.AppName,
Content = (string)FindResource("cannotDeleteEmptyLayout"),
CloseButtonText = "Ok"
};
_ = await cannotDeleteMessageBox.ShowDialogAsync();
return;
}
_ = _manager.Settings.Layouts.Remove(layout);
// Remove any screen bindings pointing to the deleted layout
List<string> boundScreens = _manager.Settings.ScreenLayouts
.Where(kvp => kvp.Value == layout.Name)
.Select(kvp => kvp.Key)
.ToList();
foreach (string screenId in boundScreens)
{
_ = _manager.Settings.ScreenLayouts.Remove(screenId);
}
_manager.SaveSettings();
_manager.LoadLayout();
}
finally
{
_isLoadingLayout = false;
}
_dataContext.RefreshSelection();
}
#endregion
+1 -1
View File
@@ -15,7 +15,7 @@
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<ui:ListView x:Name="themesListBox" Grid.Row="0" Grid.ColumnSpan="1" ItemsSource="{Binding Settings.Themes}" SelectedValue="{Binding Settings.CurrentLayout.CurrentThemeName}" SelectedValuePath="Name" SelectedIndex="2" SelectionMode="Single">
<ui:ListView x:Name="themesListBox" Grid.Row="0" Grid.ColumnSpan="1" ItemsSource="{Binding Settings.Themes}" SelectedValue="{Binding SelectedLayout.CurrentThemeName}" SelectedValuePath="Name" SelectedIndex="2" SelectionMode="Single">
<ui:ListView.ItemTemplate>
<DataTemplate>
<Grid>
+1 -1
View File
@@ -69,7 +69,7 @@ public partial class ThemePage : Page
}
_manager.Settings.Themes.Add(new Theme(inputDialog.ResponseText.Trim()));
_manager.Settings.CurrentLayout.CurrentThemeName = inputDialog.ResponseText.Trim();
_manager.SelectedLayout.CurrentThemeName = inputDialog.ResponseText.Trim();
_manager.SaveSettings();
}
}
@@ -13,6 +13,7 @@ public interface IPluginWindow
PluginMetadata PluginMetadata { get; }
string PluginFolderPath { get; }
string Title { get; set; }
string ScreenDeviceName { get; }
void Exit();
void SetEditMode(bool enabled);
+82 -8
View File
@@ -39,6 +39,10 @@ public partial class PluginWindow : Window, IPluginWindow
private Plugin? pluginClassInstance;
private AssemblyLoadContext assemblyLoadContext;
private readonly Rectangle screenBounds;
private readonly string screenDeviceName;
private bool isUpdatingPosition = false;
private CancellationTokenSource? pluginCancellationTokenSource;
private FileSystemWatcher? pluginFileWatcher;
private System.Timers.Timer? reloadDebounceTimer;
@@ -50,8 +54,9 @@ public partial class PluginWindow : Window, IPluginWindow
public bool IsRunning { get; private set; } = true;
public PluginMetadata PluginMetadata { get; private set; }
public string PluginFolderPath { get; private set; }
public string ScreenDeviceName => screenDeviceName;
public PluginWindow(PluginMetadata pluginMetadata, PluginSettings settings, string pluginFolderPath)
public PluginWindow(PluginMetadata pluginMetadata, PluginSettings settings, string pluginFolderPath, Rectangle screenBounds, string screenDeviceName)
{
InitializeComponent();
@@ -81,6 +86,14 @@ public partial class PluginWindow : Window, IPluginWindow
};
ThemeChanged();
}
else if (s.PropertyName == nameof(PluginSettings.Position))
{
UpdatePosition();
}
else if (s.PropertyName == nameof(PluginSettings.Size))
{
UpdateSize();
}
};
settings.Theme.PropertyChanged += (se, ev) =>
@@ -90,11 +103,15 @@ public partial class PluginWindow : Window, IPluginWindow
PluginMetadata = pluginMetadata;
this.settings = settings;
this.screenBounds = screenBounds;
this.screenDeviceName = screenDeviceName;
Left = settings.Position.X;
Top = settings.Position.Y;
Width = settings.Size.X;
Height = settings.Size.Y;
System.Windows.Point position = ScreenUtilities.PercentToPosition(settings.Position, screenBounds);
System.Windows.Point size = ScreenUtilities.PercentSizeToSize(settings.Size, screenBounds);
Left = position.X;
Top = position.Y;
Width = size.X;
Height = size.Y;
PluginFolderPath = pluginFolderPath;
@@ -107,7 +124,7 @@ public partial class PluginWindow : Window, IPluginWindow
}
}
public PluginWindow(Plugin pluginClassInstance, PluginMetadata pluginMetadata, PluginSettings settings) : this(pluginMetadata, settings, string.Empty)
public PluginWindow(Plugin pluginClassInstance, PluginMetadata pluginMetadata, PluginSettings settings, Rectangle screenBounds, string screenDeviceName) : this(pluginMetadata, settings, string.Empty, screenBounds, screenDeviceName)
{
this.pluginClassInstance = pluginClassInstance;
}
@@ -993,16 +1010,73 @@ public partial class PluginWindow : Window, IPluginWindow
_ = StopPlugin(unloadAssembly: true);
}
private void UpdatePosition()
{
if (isUpdatingPosition)
{
return;
}
System.Windows.Point position = ScreenUtilities.PercentToPosition(settings.Position, screenBounds);
if (position == new System.Windows.Point(Left, Top))
{
return;
}
isUpdatingPosition = true;
Left = position.X;
Top = position.Y;
isUpdatingPosition = false;
}
private void UpdateSize()
{
if (isUpdatingPosition)
{
return;
}
System.Windows.Point size = ScreenUtilities.PercentSizeToSize(settings.Size, screenBounds);
if (size == new System.Windows.Point(Width, Height))
{
return;
}
isUpdatingPosition = true;
Width = size.X;
Height = size.Y;
isUpdatingPosition = false;
}
#region Window Events
private void Window_LocationChanged(object sender, EventArgs e)
{
settings.Position = new System.Windows.Point(Left, Top);
if (isUpdatingPosition)
{
return;
}
System.Windows.Point topLeft = new(Left, Top);
System.Windows.Point clamped = ScreenUtilities.ClampToScreenBounds(topLeft, new System.Windows.Size(ActualWidth, ActualHeight), screenBounds);
if (clamped != topLeft)
{
isUpdatingPosition = true;
Left = clamped.X;
Top = clamped.Y;
isUpdatingPosition = false;
}
settings.Position = ScreenUtilities.PositionToPercent(new System.Windows.Point(Left, Top), screenBounds);
}
private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
{
settings.Size = new System.Windows.Point(Width, Height);
if (!isUpdatingPosition)
{
settings.Size = ScreenUtilities.SizeToPercent(new System.Windows.Point(Width, Height), screenBounds);
}
tileBar.CaptionHeight = ActualHeight - 10;
}
@@ -29,11 +29,16 @@ public partial class WebPluginWindow : Window, IPluginWindow
private System.Timers.Timer? reloadDebounceTimer;
private bool isReloading = false;
private readonly System.Drawing.Rectangle screenBounds;
private readonly string screenDeviceName;
private bool isUpdatingPosition = 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)
public WebPluginWindow(PluginMetadata pluginMetadata, PluginSettings settings, string pluginFolderPath, System.Drawing.Rectangle screenBounds, string screenDeviceName)
{
InitializeComponent();
@@ -63,6 +68,14 @@ public partial class WebPluginWindow : Window, IPluginWindow
};
ThemeChanged();
}
else if (s.PropertyName == nameof(PluginSettings.Position))
{
UpdatePosition();
}
else if (s.PropertyName == nameof(PluginSettings.Size))
{
UpdateSize();
}
};
settings.Theme.PropertyChanged += (se, ev) =>
@@ -72,11 +85,15 @@ public partial class WebPluginWindow : Window, IPluginWindow
PluginMetadata = pluginMetadata;
this.settings = settings;
this.screenBounds = screenBounds;
this.screenDeviceName = screenDeviceName;
Left = settings.Position.X;
Top = settings.Position.Y;
Width = settings.Size.X;
Height = settings.Size.Y;
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;
@@ -263,14 +280,71 @@ public partial class WebPluginWindow : Window, IPluginWindow
}
}
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)
{
settings.Position = new System.Windows.Point(Left, Top);
if (isUpdatingPosition)
{
return;
}
Point topLeft = new(Left, Top);
Point clamped = ScreenUtilities.ClampToScreenBounds(topLeft, new Size(ActualWidth, ActualHeight), screenBounds);
if (clamped != topLeft)
{
isUpdatingPosition = true;
Left = clamped.X;
Top = clamped.Y;
isUpdatingPosition = false;
}
settings.Position = ScreenUtilities.PositionToPercent(new Point(Left, Top), screenBounds);
}
private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
{
settings.Size = new System.Windows.Point(Width, Height);
if (!isUpdatingPosition)
{
settings.Size = ScreenUtilities.SizeToPercent(new Point(Width, Height), screenBounds);
}
tileBar.CaptionHeight = ActualHeight - 10;
}
@@ -55,6 +55,7 @@
<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>
@@ -56,6 +56,7 @@
<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>
@@ -1,5 +1,6 @@
using DesktopMagic.Plugins;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
@@ -22,7 +23,13 @@ public class DesktopMagicSettings : INotifyPropertyChanged
init
{
themes = value;
themes.CollectionChanged += (s, e) => CurrentLayout.UpdateTheme();
themes.CollectionChanged += (s, e) =>
{
foreach (Layout layout in layouts)
{
layout.UpdateTheme();
}
};
OnPropertyChanged();
}
}
@@ -62,6 +69,18 @@ public class DesktopMagicSettings : INotifyPropertyChanged
}
}
/// <summary>
/// Maps screen device names (e.g. "\\.\DISPLAY1") to the layout applied on that screen.
/// The layouts themselves are portable and store their target aspect ratio.
/// </summary>
public Dictionary<string, string> ScreenLayouts { get; set; } = [];
/// <summary>
/// Version of the settings schema, used to migrate older settings files.
/// 0 = legacy (layouts not screen aware, pixel based positions).
/// </summary>
public int SchemaVersion { get; set; }
public string? ModIoAccessToken { get; set; }
public string? ReleaseInfoLastAppVersion { get; set; }
@@ -70,7 +89,13 @@ public class DesktopMagicSettings : INotifyPropertyChanged
public DesktopMagicSettings()
{
themes.CollectionChanged += (s, e) => CurrentLayout.UpdateTheme();
themes.CollectionChanged += (s, e) =>
{
foreach (Layout layout in layouts)
{
layout.UpdateTheme();
}
};
layouts.CollectionChanged += (s, e) => OnPropertyChanged(nameof(CurrentLayout));
layouts.CollectionChanged += (s, e) => OnPropertyChanged(nameof(CurrentLayoutName));
+18
View File
@@ -16,6 +16,7 @@ public class Layout(string name) : INotifyPropertyChanged
private string name = name;
private string? currentThemeName = null;
private Dictionary<uint, PluginSettings> plugins = [];
private double screenAspectRatio = 0;
[JsonIgnore]
public Theme Theme
@@ -60,6 +61,23 @@ public class Layout(string name) : INotifyPropertyChanged
}
}
/// <summary>
/// Aspect ratio (Width / Height) of the screen this layout was designed for.
/// Used to match layouts to screens and to make layouts portable across screens.
/// </summary>
public double ScreenAspectRatio
{
get => screenAspectRatio;
set
{
if (screenAspectRatio != value)
{
screenAspectRatio = value;
OnPropertyChanged();
}
}
}
public void UpdatePlugins()
{
plugins = plugins.ToDictionary();
+13 -3
View File
@@ -19,20 +19,24 @@ public class PluginSettings : INotifyPropertyChanged
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);
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 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) ?? settings.CurrentLayout.Theme;
return settings.Themes.FirstOrDefault(t => t.Name == currentThemeName) ?? Owner?.Theme ?? settings.CurrentLayout.Theme;
}
}
@@ -93,6 +97,9 @@ public class PluginSettings : INotifyPropertyChanged
}
}
/// <summary>
/// Position of the plugin window as percentages (0..1) of the owning screen's bounds.
/// </summary>
public Point Position
{
get => position;
@@ -106,6 +113,9 @@ public class PluginSettings : INotifyPropertyChanged
}
}
/// <summary>
/// Size of the plugin window as percentages (0..1) of the owning screen's bounds.
/// </summary>
public Point Size
{
get => size;