diff --git a/OpenNest.Desktop.Tests/WindowPlacementTests.cs b/OpenNest.Desktop.Tests/WindowPlacementTests.cs new file mode 100644 index 00000000..df5bb82c --- /dev/null +++ b/OpenNest.Desktop.Tests/WindowPlacementTests.cs @@ -0,0 +1,129 @@ +namespace OpenNest.Desktop.Tests; + +public class WindowPlacementTests +{ + private static readonly PixelRect Primary = new(0, 0, 1920, 1040); + private static readonly PixelRect Right = new(1920, 0, 2560, 1400); + + [Fact] + public void Read_TheWinFormsAppsMaximizedWindow() + { + // As DesktopPC's real user-settings.json holds it. + var settings = new Dictionary + { + ["MainWindowLocation"] = "-8, -8", + ["MainWindowSize"] = "1936, 1048", + ["MainWindowState"] = "Maximized", + }; + + var placement = WindowPlacement.Read(settings, WindowPlacement.MainWindow); + + Assert.Equal(new WindowPlacement((-8, -8), (1936, 1048), true), placement); + } + + [Fact] + public void Read_NothingSaved_IsNull() + { + var empty = new Dictionary(); + + Assert.Null(WindowPlacement.Read(empty, WindowPlacement.MainWindow)); + } + + [Theory] + [InlineData("Minimized")] + [InlineData("Normal")] + [InlineData("garbage")] + public void Read_OnlyMaximizedRestoresMaximized(string state) + { + var settings = new Dictionary + { + ["MainWindowSize"] = "800, 600", + ["MainWindowState"] = state, + }; + + Assert.False(WindowPlacement.Read(settings, WindowPlacement.MainWindow)!.Maximized); + } + + [Theory] + [InlineData("abc")] + [InlineData("800")] + [InlineData("800, 600, 4")] + [InlineData("0, 600")] + [InlineData("800, -1")] + public void Read_UnusableSizeIsDropped(string size) + { + var settings = new Dictionary + { + ["MainWindowLocation"] = "10, 20", + ["MainWindowSize"] = size, + }; + + var placement = WindowPlacement.Read(settings, WindowPlacement.MainWindow); + + Assert.Equal(new WindowPlacement((10, 20), null, false), placement); + } + + [Fact] + public void WriteTo_UsesTheWinFormsNamesAndFormat() + { + var settings = new Dictionary { ["Other"] = "kept" }; + + var placement = new WindowPlacement((100, 50), (1200, 800), false); + + placement.WriteTo(settings, WindowPlacement.MainWindow); + + Assert.Equal(new Dictionary + { + ["Other"] = "kept", + ["MainWindowLocation"] = "100, 50", + ["MainWindowSize"] = "1200, 800", + ["MainWindowState"] = "Normal", + }, settings); + } + + [Fact] + public void WriteThenRead_RoundTrips() + { + var settings = new Dictionary(); + var placement = new WindowPlacement((-1500, 30), (1400, 860), true); + + placement.WriteTo(settings, "DrawingSetsWindow"); + + Assert.Equal(placement, WindowPlacement.Read(settings, "DrawingSetsWindow")); + } + + [Fact] + public void FitTo_KeepsAPlacementOnASecondScreen() + { + var placement = new WindowPlacement((2000, 100), (2400, 1300), false); + + Assert.Equal(placement, placement.FitTo([Primary, Right])); + } + + [Fact] + public void FitTo_DropsAPositionOffEveryScreen() + { + // The second monitor was unplugged since the window closed there. + var placement = new WindowPlacement((2000, 100), (1200, 800), false); + + Assert.Equal(new WindowPlacement(null, (1200, 800), false), placement.FitTo([Primary])); + } + + [Fact] + public void FitTo_DropsASizeTooBigForItsScreen() + { + var placement = new WindowPlacement((100, 100), (2400, 1300), false); + + var fitted = placement.FitTo([Primary, Right]); + + Assert.Equal(new WindowPlacement((100, 100), null, false), fitted); + } + + [Fact] + public void FitTo_KeepsMaximized() + { + var placement = new WindowPlacement((-8, -8), (1936, 1048), true); + + Assert.Equal(new WindowPlacement(null, null, true), placement.FitTo([Primary])); + } +} diff --git a/OpenNest.Desktop/WindowPlacement.cs b/OpenNest.Desktop/WindowPlacement.cs new file mode 100644 index 00000000..6e683350 --- /dev/null +++ b/OpenNest.Desktop/WindowPlacement.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace OpenNest.Desktop; + +/// A rectangle in screen pixels, standing in for each front end's own type. +public readonly record struct PixelRect(int X, int Y, int Width, int Height) +{ + public bool Contains(int x, int y) => x >= X && y >= Y && x < X + Width && y < Y + Height; +} + +/// +/// A window's remembered size, position and maximized state, stored in the shared +/// user-settings file under the names the WinForms app already uses +/// (<name>Location "x, y", <name>Size "width, height" and +/// <name>State "Normal" or "Maximized", all in screen pixels). A window is never +/// restored minimized. +/// +public sealed record WindowPlacement( + (int X, int Y)? Location, + (int Width, int Height)? Size, + bool Maximized) +{ + /// + /// The main window's names: MainWindowLocation, MainWindowSize and MainWindowState. + /// + public const string MainWindow = "MainWindow"; + + /// The placement saved under , or null if none. + public static WindowPlacement Read(IReadOnlyDictionary settings, string name) + { + ArgumentNullException.ThrowIfNull(settings); + var location = settings.TryGetValue(name + "Location", out var l) ? ParsePair(l) : null; + var size = settings.TryGetValue(name + "Size", out var s) ? ParsePair(s) : null; + if (size is { } pair && (pair.A <= 0 || pair.B <= 0)) + size = null; + var maximized = settings.TryGetValue(name + "State", out var state) + && string.Equals(state?.Trim(), "Maximized", StringComparison.OrdinalIgnoreCase); + + if (location is null && size is null && !maximized) + return null; + + return new WindowPlacement( + location is { } at ? (at.A, at.B) : null, + size is { } wh ? (wh.A, wh.B) : null, + maximized); + } + + /// Stores it under in the WinForms format. + public void WriteTo(IDictionary settings, string name) + { + ArgumentNullException.ThrowIfNull(settings); + if (Location is { } at) + settings[name + "Location"] = Format(at.X, at.Y); + if (Size is { } size) + settings[name + "Size"] = Format(size.Width, size.Height); + settings[name + "State"] = Maximized ? "Maximized" : "Normal"; + } + + /// + /// What still fits the current screens: the position is kept only when it lies on one of + /// , and the size only when it fits that screen (the first + /// screen when the position was dropped), as the WinForms app restores its main window. + /// + public WindowPlacement FitTo(IReadOnlyList workingAreas) + { + ArgumentNullException.ThrowIfNull(workingAreas); + if (workingAreas.Count == 0) + return this with { Location = null, Size = null }; + + PixelRect? screen = null; + if (Location is { } at) + { + foreach (var area in workingAreas) + { + if (area.Contains(at.X, at.Y)) + { + screen = area; + break; + } + } + } + + var target = screen ?? workingAreas[0]; + var fits = Size is { } wh && wh.Width <= target.Width && wh.Height <= target.Height; + var size = fits ? Size : null; + return this with { Location = screen is null ? null : Location, Size = size }; + } + + private static (int A, int B)? ParsePair(string text) + { + var parts = text?.Split(','); + if (parts is not { Length: 2 }) + return null; + + return TryParseInt(parts[0], out var a) && TryParseInt(parts[1], out var b) ? (a, b) : null; + } + + private static bool TryParseInt(string text, out int value) => + int.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + + private static string Format(int a, int b) => + string.Create(CultureInfo.InvariantCulture, $"{a}, {b}"); +}