diff --git a/src/DesktopMagic/Controls/ScreenSelector.xaml b/src/DesktopMagic/Controls/ScreenSelector.xaml new file mode 100644 index 0000000..742afbe --- /dev/null +++ b/src/DesktopMagic/Controls/ScreenSelector.xaml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DesktopMagic/Controls/ScreenSelector.xaml.cs b/src/DesktopMagic/Controls/ScreenSelector.xaml.cs new file mode 100644 index 0000000..5ea791d --- /dev/null +++ b/src/DesktopMagic/Controls/ScreenSelector.xaml.cs @@ -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; + +/// +/// Visual screen selector showing the connected monitors as a mini-map +/// scaled to the control's size while preserving their relative positions. +/// +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; + + /// + /// Raised when the user picks a screen by clicking it. + /// + 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; + } + + /// + /// Scales the real screen bounds so they fit the control while keeping their + /// relative positions (including negative coordinates), then centers them. + /// + private void UpdateCanvas() + { + if (ItemsSource is null || ActualWidth <= 0 || ActualHeight <= 0) + { + return; + } + + List screens = ItemsSource.Cast().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; + } +} diff --git a/src/DesktopMagic/DataContexts/MainWindowDataContext.cs b/src/DesktopMagic/DataContexts/MainWindowDataContext.cs index 3a2e8fd..829d9ca 100644 --- a/src/DesktopMagic/DataContexts/MainWindowDataContext.cs +++ b/src/DesktopMagic/DataContexts/MainWindowDataContext.cs @@ -51,6 +51,19 @@ internal class MainWindowDataContext : INotifyPropertyChanged } } + 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 @@ -111,16 +124,26 @@ internal class MainWindowDataContext : INotifyPropertyChanged public void RefreshScreens() { List allScreens = ScreenUtilities.GetAllScreens(); + Dictionary 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++) { - Screens.Add(new ScreenDisplay(allScreens[i], 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)) { - SelectedScreenId = Screens.FirstOrDefault()?.DeviceName; + ScreenDisplay? byHardwareId = previousSelected is null + ? null + : Screens.FirstOrDefault(screen => screen.HardwareId == previousSelected.HardwareId); + SelectedScreenId = byHardwareId?.DeviceName ?? Screens.FirstOrDefault()?.DeviceName; } else { @@ -144,6 +167,7 @@ internal class MainWindowDataContext : INotifyPropertyChanged Manager.Instance.SelectedScreenDeviceName = selectedScreenDeviceName; selectedLayoutName = Manager.Instance.SelectedLayout.Name; OnPropertyChanged(nameof(SelectedScreenId)); + OnPropertyChanged(nameof(SelectedScreen)); OnPropertyChanged(nameof(SelectedLayout)); OnPropertyChanged(nameof(SelectedLayoutName)); } @@ -157,15 +181,100 @@ internal class MainWindowDataContext : INotifyPropertyChanged /// /// A detected screen displayed in the UI. /// -public class ScreenDisplay +public class ScreenDisplay : INotifyPropertyChanged { - public ScreenDisplay(System.Windows.Forms.Screen screen, int index) + 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; } + /// + /// Stable hardware identifier of the monitor (see ), + /// used to persist screen bindings across display changes. + /// + 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; + + /// + /// Whether this screen is currently selected in the screen selector. + /// + 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)); + } } \ No newline at end of file diff --git a/src/DesktopMagic/Dialogs/ScreenSelectorDialog.xaml b/src/DesktopMagic/Dialogs/ScreenSelectorDialog.xaml new file mode 100644 index 0000000..c3cbc70 --- /dev/null +++ b/src/DesktopMagic/Dialogs/ScreenSelectorDialog.xaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + diff --git a/src/DesktopMagic/Dialogs/ScreenSelectorDialog.xaml.cs b/src/DesktopMagic/Dialogs/ScreenSelectorDialog.xaml.cs new file mode 100644 index 0000000..c9ba581 --- /dev/null +++ b/src/DesktopMagic/Dialogs/ScreenSelectorDialog.xaml.cs @@ -0,0 +1,26 @@ +using DesktopMagic.DataContexts; + +using System.Windows; + +namespace DesktopMagic.Dialogs; + +/// +/// Modal picker for selecting a screen from a visual monitor layout. +/// Closes itself once a screen has been picked. +/// +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; + } +} diff --git a/src/DesktopMagic/Helpers/ScreenUtilities.cs b/src/DesktopMagic/Helpers/ScreenUtilities.cs index f7f0187..0e8bf80 100644 --- a/src/DesktopMagic/Helpers/ScreenUtilities.cs +++ b/src/DesktopMagic/Helpers/ScreenUtilities.cs @@ -2,6 +2,8 @@ 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; @@ -168,25 +170,106 @@ public static class ScreenUtilities /// or an empty string when it cannot be determined. /// public static string GetFriendlyName(System.Windows.Forms.Screen screen) + { + return GetMonitorDisplayDevice(screen)?.DeviceString ?? string.Empty; + } + + /// + /// 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. + /// + 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}"; + } + + /// + /// Gets the monitor device info (second-level entry) for + /// the given screen, or null when it cannot be determined. + /// + private static DISPLAY_DEVICE? GetMonitorDisplayDevice(System.Windows.Forms.Screen screen) { try { - var device = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf() }; + DISPLAY_DEVICE 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() }; + DISPLAY_DEVICE monitor = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf() }; if (EnumDisplayDevices(device.DeviceName, 0, ref monitor, 0)) { - return monitor.DeviceString; + return monitor; } } } catch (Exception) { - // Fall through to an empty name. + // Fall through to null. } - return string.Empty; + return null; + } + + /// + /// 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. + /// + public static Dictionary GetDisplayPhysicalBounds() + { + Dictionary result = new Dictionary(); + + 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() }; + 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; } /// @@ -195,11 +278,7 @@ public static class ScreenUtilities 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); + return new Rect(bounds.Left / scale, bounds.Top / scale, bounds.Width / scale, bounds.Height / scale); } /// @@ -209,7 +288,7 @@ public static class ScreenUtilities { try { - var center = new POINT + POINT center = new POINT { X = bounds.Left + (bounds.Width / 2), Y = bounds.Top + (bounds.Height / 2) @@ -263,4 +342,41 @@ public static class ScreenUtilities 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; + } } diff --git a/src/DesktopMagic/MainWindow.xaml.cs b/src/DesktopMagic/MainWindow.xaml.cs index 22233d6..41d163c 100644 --- a/src/DesktopMagic/MainWindow.xaml.cs +++ b/src/DesktopMagic/MainWindow.xaml.cs @@ -39,6 +39,8 @@ public partial class MainWindow : FluentWindow { App.Logger.LogInfo("Loading application", source: "MainWindow"); + Microsoft.Win32.SystemEvents.DisplaySettingsChanged += SystemEvents_DisplaySettingsChanged; + _mainWindowDataContext.IsLoading = true; // Load plugins and settings through manager @@ -99,6 +101,8 @@ public partial class MainWindow : FluentWindow private void Window_Closed(object sender, EventArgs e) { + Microsoft.Win32.SystemEvents.DisplaySettingsChanged -= SystemEvents_DisplaySettingsChanged; + Visibility = Visibility.Collapsed; UpdateLayout(); _manager.CloseAllPluginWindows(); @@ -113,6 +117,16 @@ public partial class MainWindow : FluentWindow } } + private void SystemEvents_DisplaySettingsChanged(object? sender, EventArgs e) + { + // Re-enumerate screens and reload all widget windows when monitors are added or removed. + _ = Application.Current.Dispatcher.BeginInvoke(() => + { + _mainWindowDataContext.RefreshScreens(); + _manager.LoadLayout(); + }); + } + internal void RestoreWindow() { for (int i = 0; i < 10; i++) diff --git a/src/DesktopMagic/Manager.cs b/src/DesktopMagic/Manager.cs index 70c6267..2e08308 100644 --- a/src/DesktopMagic/Manager.cs +++ b/src/DesktopMagic/Manager.cs @@ -398,7 +398,7 @@ public sealed class Manager if (Settings.ScreenLayouts.Count == 0) { - Settings.ScreenLayouts[primaryScreen.DeviceName] = Settings.CurrentLayoutName ?? "Default"; + Settings.ScreenLayouts[ScreenUtilities.GetMonitorHardwareId(primaryScreen)] = Settings.CurrentLayoutName ?? "Default"; } App.Logger.LogInfo("Settings migrated to screen-aware layouts", source: "Manager"); @@ -429,7 +429,7 @@ public sealed class Manager /// public Layout GetLayoutForScreen(System.Windows.Forms.Screen screen) { - if (Settings.ScreenLayouts.TryGetValue(screen.DeviceName, out string? layoutName)) + if (Settings.ScreenLayouts.TryGetValue(ScreenUtilities.GetMonitorHardwareId(screen), out string? layoutName)) { Layout? bound = Settings.Layouts.FirstOrDefault(layout => layout.Name == layoutName); if (bound is not null) @@ -457,7 +457,7 @@ public sealed class Manager /// public void BindLayoutToScreen(System.Windows.Forms.Screen screen, Layout layout) { - Settings.ScreenLayouts[screen.DeviceName] = layout.Name; + Settings.ScreenLayouts[ScreenUtilities.GetMonitorHardwareId(screen)] = layout.Name; SaveSettings(); } diff --git a/src/DesktopMagic/Pages/MainPage.xaml b/src/DesktopMagic/Pages/MainPage.xaml index 6d1517d..138b40a 100644 --- a/src/DesktopMagic/Pages/MainPage.xaml +++ b/src/DesktopMagic/Pages/MainPage.xaml @@ -76,18 +76,32 @@ - + - + + + + + + + - - - - + + + + + + + + + + + + diff --git a/src/DesktopMagic/Pages/MainPage.xaml.cs b/src/DesktopMagic/Pages/MainPage.xaml.cs index 12ea36e..8234516 100644 --- a/src/DesktopMagic/Pages/MainPage.xaml.cs +++ b/src/DesktopMagic/Pages/MainPage.xaml.cs @@ -77,6 +77,16 @@ public partial class MainPage : Page _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)