diff --git a/CursorLang.Settings/Services/MainWindowPlacement.cs b/CursorLang.Settings/Services/MainWindowPlacement.cs index 17b288f..672d625 100644 --- a/CursorLang.Settings/Services/MainWindowPlacement.cs +++ b/CursorLang.Settings/Services/MainWindowPlacement.cs @@ -1,5 +1,7 @@ using System.Windows; using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Threading; using CursorLang.Core.Interop; using CursorLang.Settings.Interop; @@ -23,6 +25,15 @@ public sealed class MainWindowPlacement // 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; + /// /// Takes over the placement of the window: puts it in place by the first show /// and follows where the user moves it. @@ -31,6 +42,7 @@ public sealed class MainWindowPlacement { window.SourceInitialized += OnSourceInitialized; window.LocationChanged += OnLocationChanged; + window.DpiChanged += OnDpiChanged; } /// @@ -83,10 +95,108 @@ public sealed class MainWindowPlacement } 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)