diff --git a/CursorLang/App.xaml.cs b/CursorLang/App.xaml.cs index 42fe88a..48e3caa 100644 --- a/CursorLang/App.xaml.cs +++ b/CursorLang/App.xaml.cs @@ -14,6 +14,7 @@ public partial class App : Application { private ServiceProvider? _services; private SingleInstanceGate? _instanceGate; + private MainWindowPlacement? _placement; protected override void OnStartup(StartupEventArgs e) { @@ -36,6 +37,7 @@ public partial class App : Application _services.GetRequiredService(); + _placement = _services.GetRequiredService(); MainWindow = _services.GetRequiredService(); MainWindow.Show(); @@ -68,6 +70,8 @@ public partial class App : Application MainWindow.WindowState = WindowState.Normal; } + _placement?.Apply(MainWindow); + MainWindow.Activate(); } @@ -81,6 +85,8 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/CursorLang/Interop/WindowPlacementNative.cs b/CursorLang/Interop/WindowPlacementNative.cs new file mode 100644 index 0000000..954b751 --- /dev/null +++ b/CursorLang/Interop/WindowPlacementNative.cs @@ -0,0 +1,52 @@ +using System.Runtime.InteropServices; + +namespace CursorLang.Interop; + +/// +/// Win32 API для размещения окна настроек: его собственные границы и рабочая +/// область монитора, на который окно просят поставить. +/// +/// +/// Границы берутся у системы, а не из Window.Left/Top/Width/Height: +/// высота окна подстраивается под содержимое, и WPF пересчитывает эти свойства +/// по DPI монитора, а рабочая область монитора приходит в пикселях. Считать +/// центр в одних единицах проще, чем переводить туда-обратно. +/// +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; + + /// Границы окна в пикселях экрана — вместе с рамкой и заголовком. + internal static PopupWindowNative.Rect? TryGetBounds(IntPtr hWnd) => + GetWindowRect(hWnd, out PopupWindowNative.Rect bounds) ? bounds : null; + + /// + /// Рабочая область — без панели задач — того монитора, на котором + /// прямоугольник находится целиком или хотя бы большей частью. + /// + internal static PopupWindowNative.Rect? TryGetWorkAreaNear(PopupWindowNative.Rect rect) + { + IntPtr monitor = MonitorFromRect(ref rect, MONITOR_DEFAULTTONEAREST); + + var info = new MonitorInfo { cbSize = Marshal.SizeOf() }; + return GetMonitorInfo(monitor, ref info) ? info.rcWork : null; + } +} diff --git a/CursorLang/Services/MainWindowPlacement.cs b/CursorLang/Services/MainWindowPlacement.cs new file mode 100644 index 0000000..8ee7d96 --- /dev/null +++ b/CursorLang/Services/MainWindowPlacement.cs @@ -0,0 +1,160 @@ +using System.Windows; +using System.Windows.Interop; +using CursorLang.Interop; + +namespace CursorLang.Services; + +/// +/// Решает, где появиться окну настроек: впервые за сеанс — по центру монитора, +/// на котором пользователь работает, а затем — там, где он это окно оставил. +/// +/// +/// Положение живёт только в памяти и между запусками не сохраняется: набор +/// мониторов к следующему запуску может стать другим, а «по центру активного» +/// верно всегда. +/// +public sealed class MainWindowPlacement +{ + private PopupWindowNative.Point? _position; + + // Окно сообщает о переносе и тогда, когда двигаем его мы сами; + // запоминать надо только то, что выбрал пользователь + private bool _isPlacing; + + /// + /// Берёт на себя размещение окна: ставит его на место к первому показу + /// и следит за тем, куда пользователь его переносит. + /// + public void Attach(Window window) + { + window.SourceInitialized += OnSourceInitialized; + window.LocationChanged += OnLocationChanged; + } + + /// + /// Возвращает окно на запомненное место, а если за этот сеанс его ещё + /// не показывали — ставит по центру активного монитора. + /// + 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), + }; + } + + /// + /// Подтягивает окно в рабочую область ближайшего монитора. + /// + /// + /// Нужно в двух случаях. Монитор, на который окно поставил пользователь, + /// за сеанс может быть отключён — возвращать окно на его место значило бы + /// оставить пользователя без окна, поэтому запомненное положение здесь + /// пожелание, а не приказ. А высота окна равна высоте содержимого и на + /// невысоком мониторе может рабочую область превысить — тогда у окна, + /// поставленного по центру, заголовок ушёл бы за верхний край. + /// + 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; +} diff --git a/CursorLang/Views/MainWindow.xaml.cs b/CursorLang/Views/MainWindow.xaml.cs index fd3b8fb..9663ddb 100644 --- a/CursorLang/Views/MainWindow.xaml.cs +++ b/CursorLang/Views/MainWindow.xaml.cs @@ -9,10 +9,11 @@ namespace CursorLang.Views; /// public partial class MainWindow : Window { - public MainWindow(SettingsViewModel viewModel, IThemeService theme) + public MainWindow(SettingsViewModel viewModel, IThemeService theme, MainWindowPlacement placement) { InitializeComponent(); DataContext = viewModel; theme.Register(this); + placement.Attach(this); } }