290 lines
11 KiB
C#
290 lines
11 KiB
C#
using System.Windows;
|
|
using System.Windows.Interop;
|
|
using System.Windows.Media;
|
|
using System.Windows.Threading;
|
|
using CursorLang.Core.Interop;
|
|
using CursorLang.Settings.Interop;
|
|
|
|
namespace CursorLang.Settings.Services;
|
|
|
|
/// <summary>
|
|
/// Decides where the settings window shows up: for the first time in a session — in
|
|
/// the centre of the monitor the user is working on, and after that — where they
|
|
/// left that window.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The position lives in memory only and is not kept between launches: the set of
|
|
/// monitors may be different by the next launch, while "in the centre of the active
|
|
/// one" is always right.
|
|
/// </remarks>
|
|
public sealed class MainWindowPlacement
|
|
{
|
|
private PopupWindowNative.Point? _position;
|
|
|
|
// The window reports a move when we move it ourselves as well;
|
|
// what has to be remembered is only what the user chose
|
|
private bool _isPlacing;
|
|
|
|
// Captured once, before anything narrows MaxHeight to a particular monitor —
|
|
// otherwise a later, more generous monitor would stay capped at whatever a
|
|
// previous, shorter one left behind
|
|
private double? _designMaxHeight;
|
|
|
|
// Captured once, so a DPI change has a known-correct value to reassert — see
|
|
// RestoreDesignWidth
|
|
private double? _designWidth;
|
|
|
|
/// <summary>
|
|
/// Takes over the placement of the window: puts it in place by the first show
|
|
/// and follows where the user moves it.
|
|
/// </summary>
|
|
public void Attach(Window window)
|
|
{
|
|
window.SourceInitialized += OnSourceInitialized;
|
|
window.LocationChanged += OnLocationChanged;
|
|
window.DpiChanged += OnDpiChanged;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the window to the remembered place, and when it has not been shown
|
|
/// yet during this session — puts it in the centre of the active monitor.
|
|
/// </summary>
|
|
public void Apply(Window window)
|
|
{
|
|
// A minimized window has no meaningful bounds: it is restored in its former
|
|
// place, and it can be positioned only after that
|
|
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;
|
|
}
|
|
|
|
// The window height adapts to its content and is unknown until the first layout
|
|
// pass — an empty window frame would end up in the centre. So we ask for the
|
|
// layout to be computed right away: by that moment the window is not shown yet,
|
|
// so it will not flash in its former place
|
|
private void OnSourceInitialized(object? sender, EventArgs e)
|
|
{
|
|
if (sender is not Window window)
|
|
{
|
|
return;
|
|
}
|
|
|
|
window.SourceInitialized -= OnSourceInitialized;
|
|
_designMaxHeight = window.MaxHeight;
|
|
_designWidth = window.Width;
|
|
|
|
LimitHeightToOwnMonitor(window);
|
|
window.UpdateLayout();
|
|
Apply(window);
|
|
}
|
|
|
|
// A monitor is not necessarily final at creation time — Windows may place the new
|
|
// window on one monitor before Apply moves it to another, and the user is free to
|
|
// drag it to a third one later. Every one of those is a real DPI change, and this
|
|
// runs again for each: recomputing the cap from whichever monitor holds the window
|
|
// right now, rather than trusting a value worked out for a previous one.
|
|
private void OnDpiChanged(object sender, DpiChangedEventArgs e)
|
|
{
|
|
if (sender is not Window window)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Deferred rather than run inline: this event fires while WPF is still in
|
|
// the middle of its own response to the same DPI change (its per-monitor
|
|
// rescale of Width touches it after this handler if the fix-up runs
|
|
// synchronously, undoing it). Posting behind that on the dispatcher queue
|
|
// lets our fix-up run once WPF's own pass has finished.
|
|
window.Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(() =>
|
|
{
|
|
LimitHeightToOwnMonitor(window);
|
|
RestoreDesignWidth(window);
|
|
ReapplySizeToContent(window);
|
|
}));
|
|
}
|
|
|
|
// The width is a plain, explicit value rather than something SizeToContent
|
|
// computes, and WPF's own per-monitor rescaling does not reliably keep it at the
|
|
// same logical width when the system's scaling changes live under an
|
|
// already-open window, as opposed to the window being dragged onto a different
|
|
// monitor — it can come out scaled by roughly the ratio between the old and the
|
|
// new DPI instead of staying put. Reasserting the original value here is simpler
|
|
// than chasing exactly where that rescale goes wrong.
|
|
private void RestoreDesignWidth(Window window)
|
|
{
|
|
if (_designWidth is { } designWidth)
|
|
{
|
|
window.Width = designWidth;
|
|
}
|
|
}
|
|
|
|
// UpdateLayout alone settles the measure/arrange pass of the visual tree, but
|
|
// does not reliably make WPF redo its own step of resizing the native window to
|
|
// match SizeToContent when the change originates from a DPI event rather than an
|
|
// ordinary content change. Left alone, the window can end up either too tall — a
|
|
// blank strip below the real content, once MaxHeight has just pulled the content
|
|
// shorter — or stuck too short after being dragged back to a monitor with room to
|
|
// grow again. Turning SizeToContent off and back on forces that resizing step to
|
|
// run again from scratch.
|
|
private static void ReapplySizeToContent(Window window)
|
|
{
|
|
SizeToContent original = window.SizeToContent;
|
|
window.SizeToContent = SizeToContent.Manual;
|
|
window.SizeToContent = original;
|
|
window.UpdateLayout();
|
|
}
|
|
|
|
// MaxHeight in XAML is a constant tuned for an ordinary desktop monitor at 100%
|
|
// scaling. At a high scale factor the same number of device-independent pixels
|
|
// turns into more physical pixels than a short or heavily scaled monitor has, and
|
|
// SizeToContent then grows the window past the bottom of the screen instead of
|
|
// asking the ScrollViewer inside it to scroll — with nothing to grab, the excess
|
|
// is unreachable. Capping MaxHeight to what the window's own monitor really offers
|
|
// keeps the whole window on screen and lets the ScrollViewer take over.
|
|
private void LimitHeightToOwnMonitor(Window window)
|
|
{
|
|
if (_designMaxHeight is not { } designMaxHeight)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IntPtr handle = new WindowInteropHelper(window).Handle;
|
|
if (handle == IntPtr.Zero
|
|
|| WindowPlacementNative.TryGetBounds(handle) is not { } bounds
|
|
|| WindowPlacementNative.TryGetWorkAreaNear(bounds) is not { } work
|
|
|| IsEmpty(work))
|
|
{
|
|
return;
|
|
}
|
|
|
|
double scale = VisualTreeHelper.GetDpi(window).DpiScaleY;
|
|
if (scale <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Leaves room for the title bar and a margin from the edges of the screen,
|
|
// in the same units as the work area height once the monitor's scale is
|
|
// divided out
|
|
const double reservedForChrome = 80;
|
|
|
|
double workAreaHeight = (work.Bottom - work.Top) / scale;
|
|
window.MaxHeight = Math.Min(designMaxHeight, Math.Max(200, workAreaHeight - reservedForChrome));
|
|
}
|
|
|
|
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();
|
|
return IsEmpty(work) ? null : Center(bounds, work);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The point at which a window with the given bounds ends up in the centre of the work area.
|
|
/// </summary>
|
|
internal static PopupWindowNative.Point Center(
|
|
PopupWindowNative.Rect bounds, PopupWindowNative.Rect work) => new()
|
|
{
|
|
X = work.Left + (((work.Right - work.Left) - Width(bounds)) / 2),
|
|
Y = work.Top + (((work.Bottom - work.Top) - Height(bounds)) / 2),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Pulls the window into the work area of the nearest monitor.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Needed in two cases. The monitor the user put the window on may be disconnected
|
|
/// during the session — returning the window to its place would then leave the
|
|
/// user without a window, so the remembered position is a wish here rather than an
|
|
/// order. And the window height equals the height of its content and may exceed
|
|
/// the work area on a short monitor — then the title bar of a window placed in the
|
|
/// centre would go past the top edge.
|
|
/// </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 Clamp(position, bounds, work);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pulls the point so that a window with the given bounds fits into the work area
|
|
/// entirely. A window taller than the work area gets its top edge: the title bar
|
|
/// is needed more than the lower part of the window.
|
|
/// </summary>
|
|
internal static PopupWindowNative.Point Clamp(
|
|
PopupWindowNative.Point position,
|
|
PopupWindowNative.Rect bounds,
|
|
PopupWindowNative.Rect work)
|
|
{
|
|
int width = Width(bounds);
|
|
int height = Height(bounds);
|
|
|
|
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;
|
|
|
|
internal static bool IsEmpty(PopupWindowNative.Rect rect) =>
|
|
rect.Right <= rect.Left || rect.Bottom <= rect.Top;
|
|
}
|