diff --git a/src/DesktopMagic/DataContexts/MainWindowDataContext.cs b/src/DesktopMagic/DataContexts/MainWindowDataContext.cs index d99941a..3a2e8fd 100644 --- a/src/DesktopMagic/DataContexts/MainWindowDataContext.cs +++ b/src/DesktopMagic/DataContexts/MainWindowDataContext.cs @@ -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 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; } + /// + /// Refreshes the list of detected screens and ensures a screen is selected. + /// + public void RefreshScreens() + { + List 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(); + } + } + + /// + /// Raises change notifications for the currently selected screen's layout. + /// + public void RefreshSelection() + { + UpdateSelection(); + } + + /// + /// Keeps the manager's screen selection in sync and raises notifications for the selected layout. + /// + 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)); } +} + +/// +/// A detected screen displayed in the UI. +/// +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; } } \ No newline at end of file diff --git a/src/DesktopMagic/Helpers/ScreenUtilities.cs b/src/DesktopMagic/Helpers/ScreenUtilities.cs new file mode 100644 index 0000000..f7f0187 --- /dev/null +++ b/src/DesktopMagic/Helpers/ScreenUtilities.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Windows; + +namespace DesktopMagic.Helpers; + +/// +/// 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 , +/// , and +/// are expressed in device independent pixels, while +/// is expressed in physical pixels. Each screen is therefore converted to DIP space +/// using its own DPI scaling factor. +/// +public static class ScreenUtilities +{ + private const uint MonitorDefaultToNearest = 0x00000002; + + private const int MDT_EFFECTIVE_DPI = 0; + + /// + /// Gets the list of all screens currently connected, in the order reported by Windows. + /// + public static List GetAllScreens() + { + return System.Windows.Forms.Screen.AllScreens.ToList(); + } + + /// + /// Gets the screen matching the given device name (e.g. "\\.\DISPLAY1"), or null. + /// + 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)); + } + + /// + /// Gets the primary screen, or the first available screen as a fallback. + /// + 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."); + } + + /// + /// Aspect ratio (Width / Height) of the screen's bounds. + /// + public static double GetAspectRatio(System.Windows.Forms.Screen screen) + { + if (screen.Bounds.Height == 0) + { + return 0; + } + + return screen.Bounds.Width / (double)screen.Bounds.Height; + } + + /// + /// Converts a percentage based position (0..1 relative to the screen bounds) to an absolute + /// WPF position (DIPs) on that screen. + /// + 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)); + } + + /// + /// Converts an absolute WPF position (DIPs) to a percentage (0..1) of the screen bounds. + /// + 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); + } + + /// + /// Converts a percentage based size (0..1 of the screen bounds) to an absolute WPF size (DIPs). + /// + 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); + } + + /// + /// Converts an absolute WPF size (DIPs) to a percentage (0..1) of the screen bounds. + /// + 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); + } + + /// + /// 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. + /// + 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); + } + + /// + /// Builds a human readable label for a screen, e.g. "Display 1 · DELL U2715H · 3840x2160". + /// + 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}"; + } + + /// + /// Gets the friendly monitor model name (e.g. "DELL U2715H") for the given screen, + /// or an empty string when it cannot be determined. + /// + public static string GetFriendlyName(System.Windows.Forms.Screen screen) + { + try + { + var device = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf() }; + if (EnumDisplayDevices(screen.DeviceName, 0, ref device, 0)) + { + var monitor = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf() }; + if (EnumDisplayDevices(device.DeviceName, 0, ref monitor, 0)) + { + return monitor.DeviceString; + } + } + } + catch (Exception) + { + // Fall through to an empty name. + } + + return string.Empty; + } + + /// + /// Gets the screen bounds converted to WPF DIP space using the screen's own DPI scaling factor. + /// + 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); + } + + /// + /// Gets the DPI scaling factor (relative to 96 DPI) of the screen containing the given bounds. + /// + 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; + } +} diff --git a/src/DesktopMagic/Manager.cs b/src/DesktopMagic/Manager.cs index 045656d..350a8c1 100644 --- a/src/DesktopMagic/Manager.cs +++ b/src/DesktopMagic/Manager.cs @@ -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 _plugins = []; private readonly Dictionary _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? onPluginLoaded = null) + /// + /// 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. + /// + public void LoadPlugin(uint pluginId, Layout layout, Action? 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(); + } + + /// + /// Creates or closes the plugin window for a single screen, based on the plugin settings. + /// + private void EnsurePluginWindow(System.Windows.Forms.Screen screen, Layout layout, InternalPluginData internalPluginData, PluginSettings pluginSettings, Action? 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); - BlockWindowsClosing = false; - window.Close(); - BlockWindowsClosing = true; + // 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; }; @@ -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(); } + /// + /// 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. + /// + 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 + /// + /// 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. + /// + 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"); + } + + /// + /// Binds the given layout to the given screen on this machine. + /// + public void BindLayoutToScreen(System.Windows.Forms.Screen screen, Layout layout) + { + Settings.ScreenLayouts[screen.DeviceName] = layout.Name; + SaveSettings(); + } + + /// + /// Gets all plugin windows currently shown on the given screen. + /// + public IEnumerable GetWindowsForScreen(string screenDeviceName) + { + return PluginWindows.Where(window => window.ScreenDeviceName == screenDeviceName).ToList(); + } + + /// + /// Loads all screens' layouts at once, opening the enabled widgets of every screen. + /// 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"); + } + + /// + /// Reloads the widgets of a single screen using the layout currently bound to it. + /// + public void ReloadScreen(System.Windows.Forms.Screen screen) + { + App.Logger.LogInfo($"Reloading screen {screen.DeviceName}", source: "Manager"); + + List 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 pluginIdsToRemove = Settings.CurrentLayout.Plugins.Keys.Where(id => !_plugins.ContainsKey(id)).ToList(); + List 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 diff --git a/src/DesktopMagic/Pages/MainPage.xaml b/src/DesktopMagic/Pages/MainPage.xaml index e477d81..6d1517d 100644 --- a/src/DesktopMagic/Pages/MainPage.xaml +++ b/src/DesktopMagic/Pages/MainPage.xaml @@ -32,7 +32,7 @@ - + @@ -69,18 +69,25 @@ - + + + + + + - - - + - - - + + + + + + + diff --git a/src/DesktopMagic/Pages/MainPage.xaml.cs b/src/DesktopMagic/Pages/MainPage.xaml.cs index e652933..12ea36e 100644 --- a/src/DesktopMagic/Pages/MainPage.xaml.cs +++ b/src/DesktopMagic/Pages/MainPage.xaml.cs @@ -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(); + } + + /// + /// 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). + /// + 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) + System.Windows.Forms.Screen? screen = ScreenUtilities.GetScreenByDeviceName(_dataContext.SelectedScreenId); + if (screen is null) { - if (e.RemovedItems[0] == e.AddedItems[0]) - { - return; - } + return; } - try + string? layoutName = _dataContext.SelectedLayoutName; + if (layoutName is null) { - _isLoadingLayout = true; - _manager.SaveSettings(); - _manager.LoadLayout(); + return; } - finally + + Layout? layout = _manager.Settings.Layouts.FirstOrDefault(l => l.Name == layoutName); + if (layout is null) { - _isLoadingLayout = false; + return; } + + if (_manager.GetLayoutForScreen(screen) == layout) + { + return; + } + + _manager.BindLayoutToScreen(screen, layout); + _manager.ReloadScreen(screen); + _dataContext.RefreshSelection(); } private async void NewLayoutButton_Click(object sender, RoutedEventArgs e) @@ -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; - } + _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(); } } @@ -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); - _manager.SaveSettings(); - _manager.LoadLayout(); + return; } - finally + + if (layout.Name == Manager.EmptyLayoutName) { - _isLoadingLayout = false; + Wpf.Ui.Controls.MessageBox cannotDeleteMessageBox = new Wpf.Ui.Controls.MessageBox + { + Title = App.AppName, + Content = (string)FindResource("cannotDeleteEmptyLayout"), + CloseButtonText = "Ok" + }; + _ = await cannotDeleteMessageBox.ShowDialogAsync(); + return; } + + _ = _manager.Settings.Layouts.Remove(layout); + + // Remove any screen bindings pointing to the deleted layout + List boundScreens = _manager.Settings.ScreenLayouts + .Where(kvp => kvp.Value == layout.Name) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (string screenId in boundScreens) + { + _ = _manager.Settings.ScreenLayouts.Remove(screenId); + } + + _manager.SaveSettings(); + _manager.LoadLayout(); + _dataContext.RefreshSelection(); } #endregion diff --git a/src/DesktopMagic/Pages/ThemePage.xaml b/src/DesktopMagic/Pages/ThemePage.xaml index 2efdfba..9da1ee5 100644 --- a/src/DesktopMagic/Pages/ThemePage.xaml +++ b/src/DesktopMagic/Pages/ThemePage.xaml @@ -15,7 +15,7 @@ - + diff --git a/src/DesktopMagic/Pages/ThemePage.xaml.cs b/src/DesktopMagic/Pages/ThemePage.xaml.cs index 1bfa67d..a2c5877 100644 --- a/src/DesktopMagic/Pages/ThemePage.xaml.cs +++ b/src/DesktopMagic/Pages/ThemePage.xaml.cs @@ -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(); } } diff --git a/src/DesktopMagic/Plugins/IPluginWindow.cs b/src/DesktopMagic/Plugins/IPluginWindow.cs index d5e8ec4..1dce738 100644 --- a/src/DesktopMagic/Plugins/IPluginWindow.cs +++ b/src/DesktopMagic/Plugins/IPluginWindow.cs @@ -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); diff --git a/src/DesktopMagic/Plugins/PluginWindow.xaml.cs b/src/DesktopMagic/Plugins/PluginWindow.xaml.cs index a5e16f6..4376fae 100644 --- a/src/DesktopMagic/Plugins/PluginWindow.xaml.cs +++ b/src/DesktopMagic/Plugins/PluginWindow.xaml.cs @@ -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; } diff --git a/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs b/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs index 08f0b5a..fe2e56a 100644 --- a/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs +++ b/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs @@ -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; } diff --git a/src/DesktopMagic/Resources/Strings/StringResources.de.xaml b/src/DesktopMagic/Resources/Strings/StringResources.de.xaml index be372a9..4c31fd7 100644 --- a/src/DesktopMagic/Resources/Strings/StringResources.de.xaml +++ b/src/DesktopMagic/Resources/Strings/StringResources.de.xaml @@ -55,6 +55,7 @@ Möchten Sie dieses Theme wirklich löschen? Layoutnamen eingeben: Layout existiert bereits! + Das leere Layout kann nicht gelöscht werden! Themenamen eingeben: Theme existiert bereits! Installieren diff --git a/src/DesktopMagic/Resources/Strings/StringResources.en.xaml b/src/DesktopMagic/Resources/Strings/StringResources.en.xaml index 6b9b75b..6ab7306 100644 --- a/src/DesktopMagic/Resources/Strings/StringResources.en.xaml +++ b/src/DesktopMagic/Resources/Strings/StringResources.en.xaml @@ -56,6 +56,7 @@ Enter layout name Layout already exists! Can't delete last layout! + Can't delete the empty layout! Enter theme name Theme already exists! Can't delete last theme! diff --git a/src/DesktopMagic/Settings/DesktopMagicSettings.cs b/src/DesktopMagic/Settings/DesktopMagicSettings.cs index e10e3af..ad18bf5 100644 --- a/src/DesktopMagic/Settings/DesktopMagicSettings.cs +++ b/src/DesktopMagic/Settings/DesktopMagicSettings.cs @@ -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 } } + /// + /// 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. + /// + public Dictionary ScreenLayouts { get; set; } = []; + + /// + /// Version of the settings schema, used to migrate older settings files. + /// 0 = legacy (layouts not screen aware, pixel based positions). + /// + 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)); diff --git a/src/DesktopMagic/Settings/Layout.cs b/src/DesktopMagic/Settings/Layout.cs index a92dff7..60e157c 100644 --- a/src/DesktopMagic/Settings/Layout.cs +++ b/src/DesktopMagic/Settings/Layout.cs @@ -16,6 +16,7 @@ public class Layout(string name) : INotifyPropertyChanged private string name = name; private string? currentThemeName = null; private Dictionary plugins = []; + private double screenAspectRatio = 0; [JsonIgnore] public Theme Theme @@ -60,6 +61,23 @@ public class Layout(string name) : INotifyPropertyChanged } } + /// + /// 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. + /// + public double ScreenAspectRatio + { + get => screenAspectRatio; + set + { + if (screenAspectRatio != value) + { + screenAspectRatio = value; + OnPropertyChanged(); + } + } + } + public void UpdatePlugins() { plugins = plugins.ToDictionary(); diff --git a/src/DesktopMagic/Settings/PluginSettings.cs b/src/DesktopMagic/Settings/PluginSettings.cs index 5655032..dac3bb3 100644 --- a/src/DesktopMagic/Settings/PluginSettings.cs +++ b/src/DesktopMagic/Settings/PluginSettings.cs @@ -19,20 +19,24 @@ public class PluginSettings : INotifyPropertyChanged private List settings = []; private Dictionary 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 } } + /// + /// Position of the plugin window as percentages (0..1) of the owning screen's bounds. + /// public Point Position { get => position; @@ -106,6 +113,9 @@ public class PluginSettings : INotifyPropertyChanged } } + /// + /// Size of the plugin window as percentages (0..1) of the owning screen's bounds. + /// public Point Size { get => size;