mirror of
https://github.com/Stone-Red-Code/DesktopMagic.git
synced 2026-09-04 08:56:15 +02:00
Improve screen selector and make screen IDs stable
This commit is contained in:
@@ -0,0 +1,78 @@
|
|||||||
|
<UserControl x:Class="DesktopMagic.Controls.ScreenSelector"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignHeight="60"
|
||||||
|
d:DesignWidth="600"
|
||||||
|
Loaded="UserControl_Loaded"
|
||||||
|
SizeChanged="UserControl_SizeChanged"
|
||||||
|
Unloaded="UserControl_Unloaded">
|
||||||
|
|
||||||
|
<UserControl.Resources>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<Grid Background="Transparent">
|
||||||
|
<ItemsControl ItemsSource="{Binding ItemsSource, RelativeSource={RelativeSource AncestorType=UserControl}}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<Canvas />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border x:Name="ScreenBorder"
|
||||||
|
Width="{Binding Width}"
|
||||||
|
Height="{Binding Height}"
|
||||||
|
MouseLeftButtonDown="Screen_MouseLeftButtonDown"
|
||||||
|
Cursor="Hand"
|
||||||
|
ToolTipService.ToolTip="{Binding ToolTipText}">
|
||||||
|
<Border.RenderTransform>
|
||||||
|
<TranslateTransform X="{Binding X}" Y="{Binding Y}" />
|
||||||
|
</Border.RenderTransform>
|
||||||
|
<Border.Style>
|
||||||
|
<Style TargetType="Border">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource ControlFillColorDefaultBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="4" />
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource ControlFillColorSecondaryBrush}" />
|
||||||
|
</Trigger>
|
||||||
|
<DataTrigger Binding="{Binding IsSelected}" Value="True">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource AccentFillColorDefaultBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="2" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Border.Style>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<TextBlock
|
||||||
|
Margin="4,1,0,0"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
FontSize="11"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
Text="{Binding Index}" />
|
||||||
|
|
||||||
|
<Ellipse
|
||||||
|
Width="7"
|
||||||
|
Height="7"
|
||||||
|
Margin="0,0,3,3"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Fill="{DynamicResource AccentFillColorDefaultBrush}"
|
||||||
|
Visibility="{Binding IsPrimary, Converter={StaticResource BooleanToVisibilityConverter}}"
|
||||||
|
ToolTipService.ToolTip="Primary screen" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
using DesktopMagic.DataContexts;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
|
||||||
|
namespace DesktopMagic.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visual screen selector showing the connected monitors as a mini-map
|
||||||
|
/// scaled to the control's size while preserving their relative positions.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ScreenSelector : UserControl
|
||||||
|
{
|
||||||
|
public static readonly DependencyProperty ItemsSourceProperty =
|
||||||
|
DependencyProperty.Register(nameof(ItemsSource), typeof(IEnumerable), typeof(ScreenSelector),
|
||||||
|
new PropertyMetadata(null, OnItemsSourceChanged));
|
||||||
|
|
||||||
|
public IEnumerable? ItemsSource
|
||||||
|
{
|
||||||
|
get => (IEnumerable?)GetValue(ItemsSourceProperty);
|
||||||
|
set => SetValue(ItemsSourceProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly DependencyProperty SelectedItemProperty =
|
||||||
|
DependencyProperty.Register(nameof(SelectedItem), typeof(ScreenDisplay), typeof(ScreenSelector),
|
||||||
|
new PropertyMetadata(null, OnSelectedItemChanged));
|
||||||
|
|
||||||
|
public ScreenDisplay? SelectedItem
|
||||||
|
{
|
||||||
|
get => (ScreenDisplay?)GetValue(SelectedItemProperty);
|
||||||
|
set => SetValue(SelectedItemProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private INotifyCollectionChanged? notifySource;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the user picks a screen by clicking it.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? SelectionChanged;
|
||||||
|
|
||||||
|
public ScreenSelector()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
ScreenSelector control = (ScreenSelector)d;
|
||||||
|
control.AttachSource();
|
||||||
|
control.UpdateCanvas();
|
||||||
|
control.UpdateSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnSelectedItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
((ScreenSelector)d).UpdateSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AttachSource()
|
||||||
|
{
|
||||||
|
if (notifySource is INotifyCollectionChanged oldSource)
|
||||||
|
{
|
||||||
|
oldSource.CollectionChanged -= Source_CollectionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifySource = ItemsSource as INotifyCollectionChanged;
|
||||||
|
if (notifySource is not null)
|
||||||
|
{
|
||||||
|
notifySource.CollectionChanged += Source_CollectionChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Source_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
UpdateCanvas();
|
||||||
|
UpdateSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UserControl_Loaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
// Re-attach in case the control was unloaded and the source instance changed.
|
||||||
|
AttachSource();
|
||||||
|
UpdateCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UserControl_SizeChanged(object sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
UpdateCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UserControl_Unloaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (notifySource is INotifyCollectionChanged source)
|
||||||
|
{
|
||||||
|
source.CollectionChanged -= Source_CollectionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifySource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scales the real screen bounds so they fit the control while keeping their
|
||||||
|
/// relative positions (including negative coordinates), then centers them.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateCanvas()
|
||||||
|
{
|
||||||
|
if (ItemsSource is null || ActualWidth <= 0 || ActualHeight <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ScreenDisplay> screens = ItemsSource.Cast<ScreenDisplay>().ToList();
|
||||||
|
if (screens.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union of all monitor bounds (virtual screen space, negative coords kept).
|
||||||
|
Rectangle totalBounds = new Rectangle();
|
||||||
|
foreach (var item in screens)
|
||||||
|
{
|
||||||
|
totalBounds = Rectangle.Union(totalBounds, item.Bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uniform scale factor + a little margin so the mini-map never touches the edge.
|
||||||
|
double factor = Math.Max(totalBounds.Height / ActualHeight, totalBounds.Width / ActualWidth) + 2;
|
||||||
|
|
||||||
|
foreach (var item in screens)
|
||||||
|
{
|
||||||
|
item.X = item.Bounds.Left / factor;
|
||||||
|
item.Y = item.Bounds.Top / factor;
|
||||||
|
item.Width = item.Bounds.Width / factor;
|
||||||
|
item.Height = item.Bounds.Height / factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center the whole arrangement in the control.
|
||||||
|
double minLeft = screens.Min(item => item.X);
|
||||||
|
double maxRight = screens.Max(item => item.X + item.Width);
|
||||||
|
double minTop = screens.Min(item => item.Y);
|
||||||
|
double maxBottom = screens.Max(item => item.Y + item.Height);
|
||||||
|
|
||||||
|
double horizontalOffset = ((maxRight + minLeft) / 2) - (ActualWidth / 2);
|
||||||
|
double verticalOffset = ((maxBottom + minTop) / 2) - (ActualHeight / 2);
|
||||||
|
|
||||||
|
foreach (var item in screens)
|
||||||
|
{
|
||||||
|
item.X -= horizontalOffset;
|
||||||
|
item.Y -= verticalOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSelection()
|
||||||
|
{
|
||||||
|
if (ItemsSource is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (ScreenDisplay item in ItemsSource)
|
||||||
|
{
|
||||||
|
item.IsSelected = item == SelectedItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Screen_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement element && element.DataContext is ScreenDisplay screen)
|
||||||
|
{
|
||||||
|
SelectedItem = screen;
|
||||||
|
SelectionChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 Layout SelectedLayout => Manager.Instance.SelectedLayout;
|
||||||
|
|
||||||
public string? SelectedLayoutName
|
public string? SelectedLayoutName
|
||||||
@@ -111,16 +124,26 @@ internal class MainWindowDataContext : INotifyPropertyChanged
|
|||||||
public void RefreshScreens()
|
public void RefreshScreens()
|
||||||
{
|
{
|
||||||
List<System.Windows.Forms.Screen> allScreens = ScreenUtilities.GetAllScreens();
|
List<System.Windows.Forms.Screen> allScreens = ScreenUtilities.GetAllScreens();
|
||||||
|
Dictionary<string, System.Drawing.Rectangle> physicalBounds = ScreenUtilities.GetDisplayPhysicalBounds();
|
||||||
|
|
||||||
|
// Capture the hardware id of the currently selected screen so the selection can be
|
||||||
|
// preserved across display changes (e.g. unplug/replug renumbers device names).
|
||||||
|
ScreenDisplay? previousSelected = SelectedScreen;
|
||||||
|
|
||||||
Screens.Clear();
|
Screens.Clear();
|
||||||
for (int i = 0; i < allScreens.Count; i++)
|
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))
|
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
|
else
|
||||||
{
|
{
|
||||||
@@ -144,6 +167,7 @@ internal class MainWindowDataContext : INotifyPropertyChanged
|
|||||||
Manager.Instance.SelectedScreenDeviceName = selectedScreenDeviceName;
|
Manager.Instance.SelectedScreenDeviceName = selectedScreenDeviceName;
|
||||||
selectedLayoutName = Manager.Instance.SelectedLayout.Name;
|
selectedLayoutName = Manager.Instance.SelectedLayout.Name;
|
||||||
OnPropertyChanged(nameof(SelectedScreenId));
|
OnPropertyChanged(nameof(SelectedScreenId));
|
||||||
|
OnPropertyChanged(nameof(SelectedScreen));
|
||||||
OnPropertyChanged(nameof(SelectedLayout));
|
OnPropertyChanged(nameof(SelectedLayout));
|
||||||
OnPropertyChanged(nameof(SelectedLayoutName));
|
OnPropertyChanged(nameof(SelectedLayoutName));
|
||||||
}
|
}
|
||||||
@@ -157,15 +181,100 @@ internal class MainWindowDataContext : INotifyPropertyChanged
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// A detected screen displayed in the UI.
|
/// A detected screen displayed in the UI.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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;
|
DeviceName = screen.DeviceName;
|
||||||
DisplayName = ScreenUtilities.GetScreenLabel(screen, index);
|
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; }
|
public string DeviceName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stable hardware identifier of the monitor (see <see cref="ScreenUtilities.GetMonitorHardwareId"/>),
|
||||||
|
/// used to persist screen bindings across display changes.
|
||||||
|
/// </summary>
|
||||||
|
public string HardwareId { get; }
|
||||||
|
|
||||||
public string DisplayName { get; }
|
public string DisplayName { get; }
|
||||||
|
|
||||||
|
public System.Drawing.Rectangle Bounds { get; }
|
||||||
|
|
||||||
|
public bool IsPrimary { get; }
|
||||||
|
|
||||||
|
public int Index { get; }
|
||||||
|
|
||||||
|
public string ToolTipText => IsPrimary ? $"{DisplayName} · Primary" : DisplayName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this screen is currently selected in the screen selector.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSelected
|
||||||
|
{
|
||||||
|
get => isSelected;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
isSelected = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalized (control-local) position/size set by the screen selector.
|
||||||
|
public double X
|
||||||
|
{
|
||||||
|
get => x;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
x = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Y
|
||||||
|
{
|
||||||
|
get => y;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
y = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Width
|
||||||
|
{
|
||||||
|
get => width;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
width = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Height
|
||||||
|
{
|
||||||
|
get => height;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
height = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? name = null)
|
||||||
|
{
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<ui:FluentWindow x:Class="DesktopMagic.Dialogs.ScreenSelectorDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||||
|
xmlns:controls="clr-namespace:DesktopMagic.Controls"
|
||||||
|
xmlns:dataContext="clr-namespace:DesktopMagic.DataContexts"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
d:DataContext="{d:DesignInstance Type=dataContext:MainWindowDataContext}"
|
||||||
|
ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}"
|
||||||
|
ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
WindowCornerPreference="Round"
|
||||||
|
WindowBackdropType="Tabbed"
|
||||||
|
SizeToContent="Height"
|
||||||
|
Width="380"
|
||||||
|
MinWidth="380"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
ExtendsContentIntoTitleBar="True">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<ui:TitleBar x:Name="titleBar" ShowMinimize="False" ShowMaximize="False">
|
||||||
|
<ui:TitleBar.Icon>
|
||||||
|
<ui:ImageIcon Source="{StaticResource Icon}" />
|
||||||
|
</ui:TitleBar.Icon>
|
||||||
|
</ui:TitleBar>
|
||||||
|
|
||||||
|
<controls:ScreenSelector x:Name="screenSelector" Grid.Row="1" Margin="15" Width="340" Height="200" ItemsSource="{Binding Screens}" SelectedItem="{Binding SelectedScreen, Mode=TwoWay}" />
|
||||||
|
</Grid>
|
||||||
|
</ui:FluentWindow>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using DesktopMagic.DataContexts;
|
||||||
|
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace DesktopMagic.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modal picker for selecting a screen from a visual monitor layout.
|
||||||
|
/// Closes itself once a screen has been picked.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ScreenSelectorDialog : Wpf.Ui.Controls.FluentWindow
|
||||||
|
{
|
||||||
|
internal ScreenSelectorDialog(MainWindowDataContext dataContext, string title = App.AppName)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
Resources.MergedDictionaries.Add(App.LanguageDictionary);
|
||||||
|
|
||||||
|
DataContext = dataContext;
|
||||||
|
titleBar.Title = title;
|
||||||
|
Title = title;
|
||||||
|
|
||||||
|
// Close the dialog once a screen has been picked.
|
||||||
|
screenSelector.SelectionChanged += () => DialogResult = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
|
||||||
namespace DesktopMagic.Helpers;
|
namespace DesktopMagic.Helpers;
|
||||||
@@ -168,25 +170,106 @@ public static class ScreenUtilities
|
|||||||
/// or an empty string when it cannot be determined.
|
/// or an empty string when it cannot be determined.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string GetFriendlyName(System.Windows.Forms.Screen screen)
|
public static string GetFriendlyName(System.Windows.Forms.Screen screen)
|
||||||
|
{
|
||||||
|
return GetMonitorDisplayDevice(screen)?.DeviceString ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a stable hardware identifier for the screen: the PnP device instance ID of its
|
||||||
|
/// monitor (e.g. "MONITOR\DEL41F1\{...}\{0001}"), which is derived from the monitor's
|
||||||
|
/// EDID and survives disconnects and reconnects of the same monitor. When no hardware ID
|
||||||
|
/// is available (e.g. virtual or remote displays), a deterministic SHA-256 hash of the
|
||||||
|
/// bounds is used so the identifier is never empty.
|
||||||
|
/// </summary>
|
||||||
|
public static string GetMonitorHardwareId(System.Windows.Forms.Screen screen)
|
||||||
|
{
|
||||||
|
string? deviceId = GetMonitorDisplayDevice(screen)?.DeviceID;
|
||||||
|
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||||
|
{
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.Drawing.Rectangle bounds = screen.Bounds;
|
||||||
|
string boundsString = $"{bounds.X}-{bounds.Y}-{bounds.Width}-{bounds.Height}";
|
||||||
|
string hashString = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(boundsString))).ToLowerInvariant();
|
||||||
|
return $"DISPLAY#{hashString}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the monitor device info (second-level <see cref="EnumDisplayDevices"/> entry) for
|
||||||
|
/// the given screen, or null when it cannot be determined.
|
||||||
|
/// </summary>
|
||||||
|
private static DISPLAY_DEVICE? GetMonitorDisplayDevice(System.Windows.Forms.Screen screen)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var device = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf<DISPLAY_DEVICE>() };
|
DISPLAY_DEVICE device = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf<DISPLAY_DEVICE>() };
|
||||||
if (EnumDisplayDevices(screen.DeviceName, 0, ref device, 0))
|
if (EnumDisplayDevices(screen.DeviceName, 0, ref device, 0))
|
||||||
{
|
{
|
||||||
var monitor = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf<DISPLAY_DEVICE>() };
|
DISPLAY_DEVICE monitor = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf<DISPLAY_DEVICE>() };
|
||||||
if (EnumDisplayDevices(device.DeviceName, 0, ref monitor, 0))
|
if (EnumDisplayDevices(device.DeviceName, 0, ref monitor, 0))
|
||||||
{
|
{
|
||||||
return monitor.DeviceString;
|
return monitor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
// Fall through to an empty name.
|
// Fall through to null.
|
||||||
}
|
}
|
||||||
|
|
||||||
return string.Empty;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates monitors in a Per-Monitor V2 DPI awareness context and returns each
|
||||||
|
/// monitor's bounds in physical (device) pixels on the virtual screen, keyed by device
|
||||||
|
/// name (e.g. "\\.\DISPLAY1"). This uses the same coordinate space as DPI-aware apps
|
||||||
|
/// such as Lively Wallpaper, keeping multi-screen layouts with mixed DPI scaling
|
||||||
|
/// consistent regardless of this app's own DPI awareness mode.
|
||||||
|
/// </summary>
|
||||||
|
public static Dictionary<string, System.Drawing.Rectangle> GetDisplayPhysicalBounds()
|
||||||
|
{
|
||||||
|
Dictionary<string, System.Drawing.Rectangle> result = new Dictionary<string, System.Drawing.Rectangle>();
|
||||||
|
|
||||||
|
IntPtr prevContext = IntPtr.Zero;
|
||||||
|
bool contextChanged = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
prevContext = SetThreadDpiAwarenessContext((IntPtr)DpiAwarenessContextPerMonitorV2);
|
||||||
|
contextChanged = prevContext != IntPtr.Zero;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// SetThreadDpiAwarenessContext unavailable; fall back to default context.
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, (hMonitor, hdcMonitor, lprcMonitor, dwData) =>
|
||||||
|
{
|
||||||
|
MONITORINFOEX info = new MONITORINFOEX { cbSize = (uint)Marshal.SizeOf<MONITORINFOEX>() };
|
||||||
|
if (GetMonitorInfo(hMonitor, ref info))
|
||||||
|
{
|
||||||
|
string deviceName = info.szDevice.TrimEnd('\0');
|
||||||
|
result[deviceName] = new System.Drawing.Rectangle(
|
||||||
|
info.rcMonitor.Left, info.rcMonitor.Top,
|
||||||
|
info.rcMonitor.Right - info.rcMonitor.Left,
|
||||||
|
info.rcMonitor.Bottom - info.rcMonitor.Top);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, IntPtr.Zero);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (contextChanged)
|
||||||
|
{
|
||||||
|
_ = SetThreadDpiAwarenessContext(prevContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -195,11 +278,7 @@ public static class ScreenUtilities
|
|||||||
private static Rect GetScreenDips(System.Drawing.Rectangle bounds)
|
private static Rect GetScreenDips(System.Drawing.Rectangle bounds)
|
||||||
{
|
{
|
||||||
double scale = GetDpiScale(bounds);
|
double scale = GetDpiScale(bounds);
|
||||||
return new Rect(
|
return new Rect(bounds.Left / scale, bounds.Top / scale, bounds.Width / scale, bounds.Height / scale);
|
||||||
bounds.Left / scale,
|
|
||||||
bounds.Top / scale,
|
|
||||||
bounds.Width / scale,
|
|
||||||
bounds.Height / scale);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -209,7 +288,7 @@ public static class ScreenUtilities
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var center = new POINT
|
POINT center = new POINT
|
||||||
{
|
{
|
||||||
X = bounds.Left + (bounds.Width / 2),
|
X = bounds.Left + (bounds.Width / 2),
|
||||||
Y = bounds.Top + (bounds.Height / 2)
|
Y = bounds.Top + (bounds.Height / 2)
|
||||||
@@ -263,4 +342,41 @@ public static class ScreenUtilities
|
|||||||
public int X;
|
public int X;
|
||||||
public int Y;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ public partial class MainWindow : FluentWindow
|
|||||||
{
|
{
|
||||||
App.Logger.LogInfo("Loading application", source: "MainWindow");
|
App.Logger.LogInfo("Loading application", source: "MainWindow");
|
||||||
|
|
||||||
|
Microsoft.Win32.SystemEvents.DisplaySettingsChanged += SystemEvents_DisplaySettingsChanged;
|
||||||
|
|
||||||
_mainWindowDataContext.IsLoading = true;
|
_mainWindowDataContext.IsLoading = true;
|
||||||
|
|
||||||
// Load plugins and settings through manager
|
// Load plugins and settings through manager
|
||||||
@@ -99,6 +101,8 @@ public partial class MainWindow : FluentWindow
|
|||||||
|
|
||||||
private void Window_Closed(object sender, EventArgs e)
|
private void Window_Closed(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
Microsoft.Win32.SystemEvents.DisplaySettingsChanged -= SystemEvents_DisplaySettingsChanged;
|
||||||
|
|
||||||
Visibility = Visibility.Collapsed;
|
Visibility = Visibility.Collapsed;
|
||||||
UpdateLayout();
|
UpdateLayout();
|
||||||
_manager.CloseAllPluginWindows();
|
_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()
|
internal void RestoreWindow()
|
||||||
{
|
{
|
||||||
for (int i = 0; i < 10; i++)
|
for (int i = 0; i < 10; i++)
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ public sealed class Manager
|
|||||||
|
|
||||||
if (Settings.ScreenLayouts.Count == 0)
|
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");
|
App.Logger.LogInfo("Settings migrated to screen-aware layouts", source: "Manager");
|
||||||
@@ -429,7 +429,7 @@ public sealed class Manager
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Layout GetLayoutForScreen(System.Windows.Forms.Screen screen)
|
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);
|
Layout? bound = Settings.Layouts.FirstOrDefault(layout => layout.Name == layoutName);
|
||||||
if (bound is not null)
|
if (bound is not null)
|
||||||
@@ -457,7 +457,7 @@ public sealed class Manager
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void BindLayoutToScreen(System.Windows.Forms.Screen screen, Layout layout)
|
public void BindLayoutToScreen(System.Windows.Forms.Screen screen, Layout layout)
|
||||||
{
|
{
|
||||||
Settings.ScreenLayouts[screen.DeviceName] = layout.Name;
|
Settings.ScreenLayouts[ScreenUtilities.GetMonitorHardwareId(screen)] = layout.Name;
|
||||||
SaveSettings();
|
SaveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,18 +76,32 @@
|
|||||||
<RowDefinition Height="auto" />
|
<RowDefinition Height="auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="1*" />
|
<ColumnDefinition Width="auto" />
|
||||||
<ColumnDefinition Width="5" />
|
<ColumnDefinition Width="5" />
|
||||||
<ColumnDefinition Width="1*" />
|
<ColumnDefinition Width="1*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<ComboBox x:Name="screensComboBox" ItemsSource="{Binding Screens}" DisplayMemberPath="DisplayName" SelectedValue="{Binding SelectedScreenId}" SelectedValuePath="DeviceName" VerticalAlignment="Center" />
|
<ui:Button x:Name="screenSelectorButton" VerticalAlignment="Stretch" Padding="10,4" Click="ScreenSelectorButton_Click">
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock x:Name="screenSelectorButtonText" VerticalAlignment="Center" Text="{Binding SelectedScreen.DisplayName}" />
|
||||||
|
<ui:SymbolIcon Symbol="ChevronDown24" Margin="8,0,0,0" FontSize="12" />
|
||||||
|
</StackPanel>
|
||||||
|
</ui:Button>
|
||||||
|
|
||||||
<ComboBox x:Name="layoutsComboBox" Grid.Column="2" ItemsSource="{Binding Settings.Layouts}" DisplayMemberPath="Name" SelectedValue="{Binding SelectedLayoutName}" SelectedValuePath="Name" VerticalAlignment="Center" SelectionChanged="LayoutsComboBox_SelectionChanged" />
|
<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">
|
<Grid Grid.Row="2" Grid.ColumnSpan="3">
|
||||||
<ui:Button x:Name="newLayoutButton" Content="{DynamicResource newLayout}" Icon="{ui:SymbolIcon Add24}" Margin="0,0,5,0" Click="NewLayoutButton_Click" FontWeight="Regular" />
|
<Grid.ColumnDefinitions>
|
||||||
<ui:Button x:Name="removeLayoutButton" Content="{DynamicResource deleteLayout}" Icon="{ui:SymbolIcon Delete24}" Margin="0,0,5,0" Click="RemoveLayoutButton_Click" FontWeight="Regular" />
|
<ColumnDefinition Width="1*" />
|
||||||
</StackPanel>
|
<ColumnDefinition Width="5" />
|
||||||
|
<ColumnDefinition Width="0.5*" />
|
||||||
|
<ColumnDefinition Width="5" />
|
||||||
|
<ColumnDefinition Width="0.5*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<ui:Button x:Name="newLayoutButton" Content="{DynamicResource newLayout}" Icon="{ui:SymbolIcon Add24}" Grid.Column="2" HorizontalAlignment="Stretch" Click="NewLayoutButton_Click" FontWeight="Regular" />
|
||||||
|
<ui:Button x:Name="removeLayoutButton" Content="{DynamicResource deleteLayout}" Icon="{ui:SymbolIcon Delete24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="RemoveLayoutButton_Click" FontWeight="Regular" />
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</ui:Card>
|
</ui:Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -77,6 +77,16 @@ public partial class MainPage : Page
|
|||||||
_manager.SetEditMode(editCheckBox.IsChecked == true);
|
_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)
|
private void PluginCheckBox_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is not Control checkBox)
|
if (sender is not Control checkBox)
|
||||||
|
|||||||
Reference in New Issue
Block a user