From 509ef36aa073bffd864ec8f4515e87f03e7aeabd Mon Sep 17 00:00:00 2001 From: gramps Date: Sun, 4 Oct 2026 15:28:05 -0700 Subject: [PATCH] Launch upper-left with 75px padding; stop restoring window position Creator ruling: open in the upper-left of the screen with ~75px padding. The restore path was placing the window below the fold. RestoreWindowGeometry now ignores the saved position and calls the pure TopLeftPlacement(workArea, w, h, padding): work-area origin + LaunchPadding (75, in DIPs so it scales with DPI), clamped so the window is never pushed off-screen. A window that fits stays wholly visible; one too big to fit falls back to the work-area origin. Size and maximized state are still persisted -- only position was dropped. The size clamp is unchanged (saved values clamp to [MinWidth, work.Width], which is why a saved 800x500 returns as 1366x768 per the XAML minimums). The old clamp tolerated Left as low as work.Left - width + 120 and Top up to work.Bottom - 80, ranges wide enough to leave the window almost entirely off-screen while still looking like a successful clamp. A clamp that permits the window to be unreachable is worse than none: it reads as a guarantee it does not keep. BroadcastPullOutTests is renamed ..._Window_Launches_TopLeft_... and now asserts the constructor applies the derived placement for both an on-screen and a -99999 saved position. Placement math is covered by WindowPlacementTests as a pure function needing no HWND. Suite 395/395, 0 warnings. --- MainWindow.xaml.cs | 57 +++++++++++++++++++-------- ai.md | 25 ++++++++++++ ytLive.Tests/BroadcastPullOutTests.cs | 25 ++++++++---- ytLive.Tests/WindowPlacementTests.cs | 53 +++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 24 deletions(-) create mode 100644 ytLive.Tests/WindowPlacementTests.cs diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 11e3751..680980d 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -42,31 +42,56 @@ public partial class MainWindow : Window _viewModel.InitWebView2(); } - // ─── Window geometry persistence (launch size/position) ─── - // Saved on close (RestoreBounds so a maximized window remembers its - // un-maximize rect); restored in the ctor before the window is shown. - // Values clamp to the minimums and to the primary work area — a position - // saved on a since-disconnected secondary monitor falls back on-screen. + // ─── Window geometry (launch position/size) ─── + // Launch POSITION is derived, never restored: upper-left of the work area plus + // padding (creator ruling 2026-10-04). The old restore clamped the saved position + // to [work.Left - width + 120, work.Right - 120] horizontally and + // [work.Top, work.Bottom - 80] vertically — ranges wide enough to leave the window + // almost entirely off-screen, which is what the creator was seeing ("below the + // fold"). Size and maximized state are still persisted. Placement math is pure and + // tested (WindowPlacementTests); changing it back to restore position is reverting + // this block only, since the saved values are still written. + + /// Launch inset from the work-area corner, in device-independent units — + /// so it scales with DPI rather than being a fixed physical pixel count. + internal const double LaunchPadding = 75; + + /// Upper-left of plus padding, clamped so the + /// window is never pushed off-screen. The clamp is the point: a window that fits + /// stays wholly visible, and one too big to fit falls back to the work-area origin + /// rather than hanging off an edge. + internal static Point TopLeftPlacement(Rect workArea, double width, double height, double padding) + { + var left = workArea.Left + padding; + var top = workArea.Top + padding; + + if (width <= workArea.Width) left = Math.Min(left, workArea.Right - width); + else left = workArea.Left; + + if (height <= workArea.Height) top = Math.Min(top, workArea.Bottom - height); + else top = workArea.Top; + + return new Point(left, top); + } private void RestoreWindowGeometry() { try { - var saved = _viewModel.RestoreWindowState(); - if (saved == null) return; - var (left, top, width, height, state) = saved.Value; - var work = SystemParameters.WorkArea; - width = Math.Clamp(width, MinWidth, work.Width); - height = Math.Clamp(height, MinHeight, work.Height); - left = Math.Clamp(left, work.Left - width + 120, work.Right - 120); - top = Math.Clamp(top, work.Top, work.Bottom - 80); + var saved = _viewModel.RestoreWindowState(); - Left = left; - Top = top; + // No saved state: keep the XAML-declared size rather than collapsing to Min*. + var width = Math.Clamp(saved?.Width ?? Width, MinWidth, work.Width); + var height = Math.Clamp(saved?.Height ?? Height, MinHeight, work.Height); Width = width; Height = height; - if (state == "Maximized") WindowState = WindowState.Maximized; + + var place = TopLeftPlacement(work, width, height, LaunchPadding); + Left = place.X; + Top = place.Y; + + if (saved?.State == "Maximized") WindowState = WindowState.Maximized; } catch (Exception ex) { diff --git a/ai.md b/ai.md index a0606c9..29604f8 100644 --- a/ai.md +++ b/ai.md @@ -189,6 +189,31 @@ tests here when it ships: one real chat-poll payload containing all seven reward assert the persisted canonical `RewardEvent` rows (type + amount/currency/tier/memberLevel/participant ids) round-trip into SQLite. +### Launch POSITION is derived, never restored (2026-10-04) + +Creator ruling: the window must open in the **upper-left of its screen with ~75px padding**, because +the restore path was putting it below the fold. `MainWindow.RestoreWindowGeometry` now ignores the +saved position entirely and calls the pure `TopLeftPlacement(workArea, w, h, padding)`, which takes +the work-area origin plus `LaunchPadding = 75` and clamps so the window is never pushed off-screen: +a window that fits stays wholly visible, one too big to fit falls back to the work-area origin. +`LaunchPadding` is in **device-independent units**, so it scales with DPI instead of being a fixed +physical pixel count. + +**Size and maximized state are still persisted and restored** — only position was dropped. The +clamp still applies to size: saved values are `Math.Clamp`ed to `[MinWidth, work.Width]`, which is why +a saved 800×500 comes back as 1366×768 (the XAML `MinWidth`/`MinHeight`). Reverting this is +reverting the one block in `MainWindow.xaml.cs`; the saved values are still written, so nothing is lost. + +**Why the old clamp was wrong (do not "fix" it back):** it tolerated `Left` as low as +`work.Left - width + 120` and `Top` up to `work.Bottom - 80` — ranges wide enough to leave the window +almost entirely off-screen while still looking like a successful clamp. A clamp that permits the +window to be ~unreachable is worse than no clamp, because it reads as a guarantee it does not keep. + +`BroadcastPullOutTests.Metadata_Persists_Window_Launches_TopLeft_And_UpdatePatchesRemote` (renamed +from `..._WindowRestores_Clamped_...`) is the integration guard — it asserts the constructor applies +the derived placement for both an on-screen and a wildly off-screen (`-99999`) saved position. +Placement math itself is covered by `WindowPlacementTests` (pure function, no HWND needed). + ### Real-MainWindow tests MUST be hermetic (DB pollution bug) The integration test boots a real `MainWindow` → `MainViewModel` → real `LayoutStore` diff --git a/ytLive.Tests/BroadcastPullOutTests.cs b/ytLive.Tests/BroadcastPullOutTests.cs index 6c952a7..40b6cce 100644 --- a/ytLive.Tests/BroadcastPullOutTests.cs +++ b/ytLive.Tests/BroadcastPullOutTests.cs @@ -23,7 +23,7 @@ public sealed class BroadcastPullOutTests private readonly RealAppHost _app; [Fact] - public void Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote() + public void Metadata_Persists_Window_Launches_TopLeft_And_UpdatePatchesRemote() { _app.Run(Run); } @@ -89,11 +89,16 @@ public sealed class BroadcastPullOutTests using (var writer = new LayoutStore(tempDb)) writer.SaveWindowState(100, 50, 1500, 900, "Normal"); + var work = System.Windows.SystemParameters.WorkArea; + window = new MainWindow(); Assert.Equal(1500, window.Width); Assert.Equal(900, window.Height); - Assert.Equal(100, window.Left); - Assert.Equal(50, window.Top); + // Launch POSITION is derived, never restored (2026-10-04). The 100,50 saved + // above is deliberately ignored: restoring it is what let the window come + // back below the fold, which is the whole reason this behaviour changed. + Assert.Equal(Math.Min(work.Left + 75, work.Right - 1500), window.Left); + Assert.Equal(Math.Min(work.Top + 75, work.Bottom - 900), window.Top); window.Close(); window = null; @@ -102,11 +107,15 @@ public sealed class BroadcastPullOutTests writer.SaveWindowState(-99999, -99999, 800, 500, "Normal"); window = new MainWindow(); - var work = System.Windows.SystemParameters.WorkArea; - Assert.Equal(1366, window.Width); - Assert.Equal(768, window.Height); - Assert.InRange(window.Left, work.Left - window.Width + 120, work.Right - 120); - Assert.InRange(window.Top, work.Top, work.Bottom - 80); + Assert.Equal(1366, window.Width); // saved 800 clamps up to MinWidth + Assert.Equal(768, window.Height); // saved 500 clamps up to MinHeight + // A wildly off-screen saved position (-99999) no longer influences placement + // at all — the old clamp tolerated Left as low as work.Left - Width + 120, + // i.e. the window could sit almost entirely off the left edge. + Assert.Equal(Math.Min(work.Left + 75, work.Right - window.Width), window.Left); + Assert.Equal(Math.Min(work.Top + 75, work.Bottom - window.Height), window.Top); + Assert.InRange(window.Left, work.Left, work.Right - window.Width); + Assert.InRange(window.Top, work.Top, work.Bottom - window.Height); var auth = new YouTubeAuthService("test-client", "test-secret"); auth.SetSession(new YouTubeChannel diff --git a/ytLive.Tests/WindowPlacementTests.cs b/ytLive.Tests/WindowPlacementTests.cs new file mode 100644 index 0000000..cc0f2c2 --- /dev/null +++ b/ytLive.Tests/WindowPlacementTests.cs @@ -0,0 +1,53 @@ +using System.Windows; +using Xunit; + +namespace ytLive.Tests; + +/// +/// Launch placement (2026-10-04). The window used to restore a saved position clamped to +/// [work.Left - width + 120, work.Right - 120] horizontally and +/// [work.Top, work.Bottom - 80] vertically — ranges wide enough to leave most of the +/// window off-screen (the creator's "below the fold"). Launch position is now derived: +/// upper-left of the work area plus padding. +/// +public class WindowPlacementTests +{ + /// Three properties in one guard, because the third is the regression: + /// (1) the requested inset, (2) a window that nearly fills the screen is pulled back + /// so it stays wholly visible — padding must never win over visibility, or a + /// large window re-creates "below the fold" — and (3) a window too big to fit falls + /// back to the work-area origin rather than hanging off an edge. Work area is + /// 1920x1040 at the origin (a taskbar-less secondary-style rect) to prove the maths + /// does not quietly assume (0,0). + [Theory] + [InlineData(1280, 800, 75, 75)] // padding wins when it fits + [InlineData(1900, 1000, 20, 40)] // does not fit -> pulled back on-screen + [InlineData(2400, 1200, 0, 0)] // larger than the work area -> origin + public void TopLeftPlacement_PadsButNeverHangsOffScreen(int width, int height, double expX, double expY ) + { + var work = new Rect(0, 0, 1920, 1040); + + var place = MainWindow.TopLeftPlacement(work, width, height, 75); + + Assert.Equal(expX, place.X); + Assert.Equal(expY, place.Y); + + // Whatever the size, the window must not start beyond the work area when it fits. + if (width <= work.Width) Assert.True(place.X >= work.Left, $"left {place.X} hangs off"); + if (height <= work.Height) Assert.True(place.Y >= work.Top, $"top {place.Y} hangs off"); + } + + /// Padding is applied to a work area that does not start at the origin — the + /// secondary-monitor case. A hardcoded Left = 75 would drop the window off the + /// left edge of a monitor whose work area begins at a negative X. + [Fact] + public void TopLeftPlacement_OffsetsFromTheWorkAreaOrigin_NotFromZero() + { + var secondary = new Rect(-1920, -200, 1920, 1040); + + var place = MainWindow.TopLeftPlacement(secondary, 1280, 800, 75); + + Assert.Equal(-1845, place.X); + Assert.Equal(-125, place.Y); + } +}