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.
This commit is contained in:
+41
-16
@@ -42,31 +42,56 @@ public partial class MainWindow : Window
|
|||||||
_viewModel.InitWebView2();
|
_viewModel.InitWebView2();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Window geometry persistence (launch size/position) ───
|
// ─── Window geometry (launch position/size) ───
|
||||||
// Saved on close (RestoreBounds so a maximized window remembers its
|
// Launch POSITION is derived, never restored: upper-left of the work area plus
|
||||||
// un-maximize rect); restored in the ctor before the window is shown.
|
// padding (creator ruling 2026-10-04). The old restore clamped the saved position
|
||||||
// Values clamp to the minimums and to the primary work area — a position
|
// to [work.Left - width + 120, work.Right - 120] horizontally and
|
||||||
// saved on a since-disconnected secondary monitor falls back on-screen.
|
// [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.
|
||||||
|
|
||||||
|
/// <summary>Launch inset from the work-area corner, in device-independent units —
|
||||||
|
/// so it scales with DPI rather than being a fixed physical pixel count.</summary>
|
||||||
|
internal const double LaunchPadding = 75;
|
||||||
|
|
||||||
|
/// <summary>Upper-left of <paramref name="workArea"/> 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.</summary>
|
||||||
|
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()
|
private void RestoreWindowGeometry()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var saved = _viewModel.RestoreWindowState();
|
|
||||||
if (saved == null) return;
|
|
||||||
var (left, top, width, height, state) = saved.Value;
|
|
||||||
|
|
||||||
var work = SystemParameters.WorkArea;
|
var work = SystemParameters.WorkArea;
|
||||||
width = Math.Clamp(width, MinWidth, work.Width);
|
var saved = _viewModel.RestoreWindowState();
|
||||||
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);
|
|
||||||
|
|
||||||
Left = left;
|
// No saved state: keep the XAML-declared size rather than collapsing to Min*.
|
||||||
Top = top;
|
var width = Math.Clamp(saved?.Width ?? Width, MinWidth, work.Width);
|
||||||
|
var height = Math.Clamp(saved?.Height ?? Height, MinHeight, work.Height);
|
||||||
Width = width;
|
Width = width;
|
||||||
Height = height;
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
assert the persisted canonical `RewardEvent` rows (type + amount/currency/tier/memberLevel/participant
|
||||||
ids) round-trip into SQLite.
|
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)
|
### Real-MainWindow tests MUST be hermetic (DB pollution bug)
|
||||||
|
|
||||||
The integration test boots a real `MainWindow` → `MainViewModel` → real `LayoutStore`
|
The integration test boots a real `MainWindow` → `MainViewModel` → real `LayoutStore`
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public sealed class BroadcastPullOutTests
|
|||||||
private readonly RealAppHost _app;
|
private readonly RealAppHost _app;
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote()
|
public void Metadata_Persists_Window_Launches_TopLeft_And_UpdatePatchesRemote()
|
||||||
{
|
{
|
||||||
_app.Run(Run);
|
_app.Run(Run);
|
||||||
}
|
}
|
||||||
@@ -89,11 +89,16 @@ public sealed class BroadcastPullOutTests
|
|||||||
using (var writer = new LayoutStore(tempDb))
|
using (var writer = new LayoutStore(tempDb))
|
||||||
writer.SaveWindowState(100, 50, 1500, 900, "Normal");
|
writer.SaveWindowState(100, 50, 1500, 900, "Normal");
|
||||||
|
|
||||||
|
var work = System.Windows.SystemParameters.WorkArea;
|
||||||
|
|
||||||
window = new MainWindow();
|
window = new MainWindow();
|
||||||
Assert.Equal(1500, window.Width);
|
Assert.Equal(1500, window.Width);
|
||||||
Assert.Equal(900, window.Height);
|
Assert.Equal(900, window.Height);
|
||||||
Assert.Equal(100, window.Left);
|
// Launch POSITION is derived, never restored (2026-10-04). The 100,50 saved
|
||||||
Assert.Equal(50, window.Top);
|
// 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.Close();
|
||||||
window = null;
|
window = null;
|
||||||
@@ -102,11 +107,15 @@ public sealed class BroadcastPullOutTests
|
|||||||
writer.SaveWindowState(-99999, -99999, 800, 500, "Normal");
|
writer.SaveWindowState(-99999, -99999, 800, 500, "Normal");
|
||||||
|
|
||||||
window = new MainWindow();
|
window = new MainWindow();
|
||||||
var work = System.Windows.SystemParameters.WorkArea;
|
Assert.Equal(1366, window.Width); // saved 800 clamps up to MinWidth
|
||||||
Assert.Equal(1366, window.Width);
|
Assert.Equal(768, window.Height); // saved 500 clamps up to MinHeight
|
||||||
Assert.Equal(768, window.Height);
|
// A wildly off-screen saved position (-99999) no longer influences placement
|
||||||
Assert.InRange(window.Left, work.Left - window.Width + 120, work.Right - 120);
|
// at all — the old clamp tolerated Left as low as work.Left - Width + 120,
|
||||||
Assert.InRange(window.Top, work.Top, work.Bottom - 80);
|
// 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");
|
var auth = new YouTubeAuthService("test-client", "test-secret");
|
||||||
auth.SetSession(new YouTubeChannel
|
auth.SetSession(new YouTubeChannel
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Launch placement (2026-10-04). The window used to restore a saved position clamped to
|
||||||
|
/// <c>[work.Left - width + 120, work.Right - 120]</c> horizontally and
|
||||||
|
/// <c>[work.Top, work.Bottom - 80]</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public class WindowPlacementTests
|
||||||
|
{
|
||||||
|
/// <summary>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 <i>wholly</i> 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).</summary>
|
||||||
|
[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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Padding is applied to a work area that does not start at the origin — the
|
||||||
|
/// secondary-monitor case. A hardcoded <c>Left = 75</c> would drop the window off the
|
||||||
|
/// left edge of a monitor whose work area begins at a negative X.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user