added saving app placement
This commit is contained in:
@@ -14,6 +14,7 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
private ServiceProvider? _services;
|
private ServiceProvider? _services;
|
||||||
private SingleInstanceGate? _instanceGate;
|
private SingleInstanceGate? _instanceGate;
|
||||||
|
private MainWindowPlacement? _placement;
|
||||||
|
|
||||||
protected override void OnStartup(StartupEventArgs e)
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -36,6 +37,7 @@ public partial class App : Application
|
|||||||
|
|
||||||
_services.GetRequiredService<ThemeService>();
|
_services.GetRequiredService<ThemeService>();
|
||||||
|
|
||||||
|
_placement = _services.GetRequiredService<MainWindowPlacement>();
|
||||||
MainWindow = _services.GetRequiredService<MainWindow>();
|
MainWindow = _services.GetRequiredService<MainWindow>();
|
||||||
MainWindow.Show();
|
MainWindow.Show();
|
||||||
|
|
||||||
@@ -68,6 +70,8 @@ public partial class App : Application
|
|||||||
MainWindow.WindowState = WindowState.Normal;
|
MainWindow.WindowState = WindowState.Normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_placement?.Apply(MainWindow);
|
||||||
|
|
||||||
MainWindow.Activate();
|
MainWindow.Activate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +85,8 @@ public partial class App : Application
|
|||||||
services.AddSingleton<ThemeService>();
|
services.AddSingleton<ThemeService>();
|
||||||
services.AddSingleton<IThemeService>(provider => provider.GetRequiredService<ThemeService>());
|
services.AddSingleton<IThemeService>(provider => provider.GetRequiredService<ThemeService>());
|
||||||
|
|
||||||
|
services.AddSingleton<MainWindowPlacement>();
|
||||||
|
|
||||||
services.AddSingleton<ILocalizationService, LocalizationService>();
|
services.AddSingleton<ILocalizationService, LocalizationService>();
|
||||||
services.AddSingleton<IKeyboardLayoutService, KeyboardLayoutService>();
|
services.AddSingleton<IKeyboardLayoutService, KeyboardLayoutService>();
|
||||||
services.AddSingleton<ILayoutPopupService, LayoutPopupService>();
|
services.AddSingleton<ILayoutPopupService, LayoutPopupService>();
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace CursorLang.Interop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Win32 API для размещения окна настроек: его собственные границы и рабочая
|
||||||
|
/// область монитора, на который окно просят поставить.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Границы берутся у системы, а не из <c>Window.Left/Top/Width/Height</c>:
|
||||||
|
/// высота окна подстраивается под содержимое, и WPF пересчитывает эти свойства
|
||||||
|
/// по DPI монитора, а рабочая область монитора приходит в пикселях. Считать
|
||||||
|
/// центр в одних единицах проще, чем переводить туда-обратно.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class WindowPlacementNative
|
||||||
|
{
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool GetWindowRect(IntPtr hWnd, out PopupWindowNative.Rect lpRect);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr MonitorFromRect(ref PopupWindowNative.Rect lprc, uint dwFlags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfo lpmi);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct MonitorInfo
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public PopupWindowNative.Rect rcMonitor;
|
||||||
|
public PopupWindowNative.Rect rcWork;
|
||||||
|
public uint dwFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const uint MONITOR_DEFAULTTONEAREST = 2;
|
||||||
|
|
||||||
|
/// <summary>Границы окна в пикселях экрана — вместе с рамкой и заголовком.</summary>
|
||||||
|
internal static PopupWindowNative.Rect? TryGetBounds(IntPtr hWnd) =>
|
||||||
|
GetWindowRect(hWnd, out PopupWindowNative.Rect bounds) ? bounds : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Рабочая область — без панели задач — того монитора, на котором
|
||||||
|
/// прямоугольник находится целиком или хотя бы большей частью.
|
||||||
|
/// </summary>
|
||||||
|
internal static PopupWindowNative.Rect? TryGetWorkAreaNear(PopupWindowNative.Rect rect)
|
||||||
|
{
|
||||||
|
IntPtr monitor = MonitorFromRect(ref rect, MONITOR_DEFAULTTONEAREST);
|
||||||
|
|
||||||
|
var info = new MonitorInfo { cbSize = Marshal.SizeOf<MonitorInfo>() };
|
||||||
|
return GetMonitorInfo(monitor, ref info) ? info.rcWork : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using CursorLang.Interop;
|
||||||
|
|
||||||
|
namespace CursorLang.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Решает, где появиться окну настроек: впервые за сеанс — по центру монитора,
|
||||||
|
/// на котором пользователь работает, а затем — там, где он это окно оставил.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Положение живёт только в памяти и между запусками не сохраняется: набор
|
||||||
|
/// мониторов к следующему запуску может стать другим, а «по центру активного»
|
||||||
|
/// верно всегда.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MainWindowPlacement
|
||||||
|
{
|
||||||
|
private PopupWindowNative.Point? _position;
|
||||||
|
|
||||||
|
// Окно сообщает о переносе и тогда, когда двигаем его мы сами;
|
||||||
|
// запоминать надо только то, что выбрал пользователь
|
||||||
|
private bool _isPlacing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Берёт на себя размещение окна: ставит его на место к первому показу
|
||||||
|
/// и следит за тем, куда пользователь его переносит.
|
||||||
|
/// </summary>
|
||||||
|
public void Attach(Window window)
|
||||||
|
{
|
||||||
|
window.SourceInitialized += OnSourceInitialized;
|
||||||
|
window.LocationChanged += OnLocationChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Возвращает окно на запомненное место, а если за этот сеанс его ещё
|
||||||
|
/// не показывали — ставит по центру активного монитора.
|
||||||
|
/// </summary>
|
||||||
|
public void Apply(Window window)
|
||||||
|
{
|
||||||
|
// У свёрнутого окна нет осмысленных границ: его разворачивают на прежнем
|
||||||
|
// месте, и разместить его можно только после этого
|
||||||
|
if (window.WindowState != WindowState.Normal)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IntPtr handle = new WindowInteropHelper(window).Handle;
|
||||||
|
if (handle == IntPtr.Zero || WindowPlacementNative.TryGetBounds(handle) is not { } bounds)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PopupWindowNative.Point? wanted = _position ?? CenterOnActiveMonitor(bounds);
|
||||||
|
if (wanted is null || KeepOnScreen(wanted.Value, bounds) is not { } target)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isPlacing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PopupWindowNative.MoveTo(handle, target.X, target.Y);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isPlacing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_position = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Высота окна подстраивается под содержимое, и до первой раскладки она
|
||||||
|
// неизвестна — по центру встал бы пустой каркас окна. Раскладку поэтому
|
||||||
|
// просим посчитать сразу: окно к этому моменту ещё не показано, так что
|
||||||
|
// на прежнем месте оно не мелькнёт
|
||||||
|
private void OnSourceInitialized(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Window window)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.SourceInitialized -= OnSourceInitialized;
|
||||||
|
window.UpdateLayout();
|
||||||
|
Apply(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLocationChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_isPlacing || sender is not Window { WindowState: WindowState.Normal } window)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IntPtr handle = new WindowInteropHelper(window).Handle;
|
||||||
|
if (handle != IntPtr.Zero && WindowPlacementNative.TryGetBounds(handle) is { } bounds)
|
||||||
|
{
|
||||||
|
_position = new PopupWindowNative.Point { X = bounds.Left, Y = bounds.Top };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PopupWindowNative.Point? CenterOnActiveMonitor(PopupWindowNative.Rect bounds)
|
||||||
|
{
|
||||||
|
(PopupWindowNative.Rect work, _) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||||
|
if (IsEmpty(work))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PopupWindowNative.Point
|
||||||
|
{
|
||||||
|
X = work.Left + (((work.Right - work.Left) - Width(bounds)) / 2),
|
||||||
|
Y = work.Top + (((work.Bottom - work.Top) - Height(bounds)) / 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Подтягивает окно в рабочую область ближайшего монитора.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Нужно в двух случаях. Монитор, на который окно поставил пользователь,
|
||||||
|
/// за сеанс может быть отключён — возвращать окно на его место значило бы
|
||||||
|
/// оставить пользователя без окна, поэтому запомненное положение здесь
|
||||||
|
/// пожелание, а не приказ. А высота окна равна высоте содержимого и на
|
||||||
|
/// невысоком мониторе может рабочую область превысить — тогда у окна,
|
||||||
|
/// поставленного по центру, заголовок ушёл бы за верхний край.
|
||||||
|
/// </remarks>
|
||||||
|
private static PopupWindowNative.Point? KeepOnScreen(
|
||||||
|
PopupWindowNative.Point position, PopupWindowNative.Rect bounds)
|
||||||
|
{
|
||||||
|
int width = Width(bounds);
|
||||||
|
int height = Height(bounds);
|
||||||
|
|
||||||
|
var wanted = new PopupWindowNative.Rect
|
||||||
|
{
|
||||||
|
Left = position.X,
|
||||||
|
Top = position.Y,
|
||||||
|
Right = position.X + width,
|
||||||
|
Bottom = position.Y + height,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (WindowPlacementNative.TryGetWorkAreaNear(wanted) is not { } work || IsEmpty(work))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PopupWindowNative.Point
|
||||||
|
{
|
||||||
|
X = Math.Clamp(position.X, work.Left, Math.Max(work.Left, work.Right - width)),
|
||||||
|
Y = Math.Clamp(position.Y, work.Top, Math.Max(work.Top, work.Bottom - height)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Width(PopupWindowNative.Rect rect) => rect.Right - rect.Left;
|
||||||
|
|
||||||
|
private static int Height(PopupWindowNative.Rect rect) => rect.Bottom - rect.Top;
|
||||||
|
|
||||||
|
private static bool IsEmpty(PopupWindowNative.Rect rect) =>
|
||||||
|
rect.Right <= rect.Left || rect.Bottom <= rect.Top;
|
||||||
|
}
|
||||||
@@ -9,10 +9,11 @@ namespace CursorLang.Views;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
public MainWindow(SettingsViewModel viewModel, IThemeService theme)
|
public MainWindow(SettingsViewModel viewModel, IThemeService theme, MainWindowPlacement placement)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContext = viewModel;
|
DataContext = viewModel;
|
||||||
theme.Register(this);
|
theme.Register(this);
|
||||||
|
placement.Attach(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user