From be1063a2bfde2d729b0979b95907e13df5798615 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Thu, 22 Oct 2026 07:54:45 +1100 Subject: [PATCH] Take the WinForms shell from PlatformHeads.WindowsForms ViewerForm and BuildMonitor's MonitorForm each did what a form pumped by the app's own loop has to, and each had fixes the other lacked. PlatformHeads 0.3.0 has one copy, HeadForm, made from both, which BuildMonitor took in SimonCropp/BuildMonitor#61, and this takes it: - ViewerForm derives from HeadForm. Its placement and its rules, the first size scaled to the display, closing as a request, Raise, Parked and the modal frame timer go to the base. Frame keeps its name and hands the base its ModalFrame; the scroll bar still enters and leaves the modal loop itself. The tests that held those rules call WindowFit. - FormsViewerWindow copies through FormsClipboard, and ViewerApp hands its setup to FormsApp. And the loop idles. FormsViewerWindow waited a fixed frame after every present, so a viewer left open woke sixty times a second with nothing to do, and ten hidden or minimised. It now implements Wait with HeadForm's WaitForFrame, which idles on the message queue and the loop's wake together, and ViewerProgram calls it after each present with a LoopWake. Every change of state sets the wake, and so does every window command, which changes none: MessageHandler's directly, OwnerLink's through Commanded. The idle is a frame at most while a spinner is up, since it turns only as the canvas repaints, and while a copy waits to be offered the clipboard again. The native heads wait inside their present and answer Wait at once, so they pace themselves as before. The five pins name 0.3.0. The package pins already did; the workflow's and the CMake module's were still at 0.2.0. The packages carry PlatformHeads.WindowsForms.dll beside each Windows head, the one change to the entries Packaging.Tests has on record. --- .github/workflows/build-native.yml | 2 +- claude.md | 8 +- native/CMakeLists.txt | 2 +- .../FormsHeadTests.cs | 26 +- .../FrameWaitTests.cs | 74 ---- .../WindowPlacementTests.cs | 24 +- .../DiffEngineViewer.Windows.csproj | 1 + .../FormsViewerWindow.cs | 56 +-- src/DiffEngineViewer.Windows/ViewerApp.cs | 36 +- src/DiffEngineViewer.Windows/ViewerForm.cs | 363 +----------------- src/DiffEngineViewer/Ipc/OwnerLink.cs | 7 + src/DiffEngineViewer/ViewerProgram.cs | 35 +- src/Directory.Packages.props | 1 + .../Package.DiffEngine.verified.txt | 4 +- .../Package.DiffEngineTray.verified.txt | 4 +- ...kage.DiffEngineViewer.Windows.verified.txt | 1 + 16 files changed, 126 insertions(+), 518 deletions(-) delete mode 100644 src/DiffEngineViewer.Windows.Tests/FrameWaitTests.cs diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index b1f3dab9a..f3198a687 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -35,7 +35,7 @@ concurrency: jobs: native: - uses: SimonCropp/PlatformHeads/.github/workflows/build-native.yml@0.2.0 + uses: SimonCropp/PlatformHeads/.github/workflows/build-native.yml@0.3.0 permissions: contents: write pull-requests: write diff --git a/claude.md b/claude.md index 3d3b43534..b57d0bb31 100644 --- a/claude.md +++ b/claude.md @@ -253,7 +253,7 @@ the comment there about not caching "nothing staged" asks for. own loop for every part of the scroll bar, not the thumb alone, and `Present` stops that timer. An Alt chord is no command. A held key gives one accept or discard (the repeat flag, read in `ProcessCmdKey`, for what `ViewerSession.ChangesQueue` names), while navigation keeps its - repeats. A minimised window waits between frames as a hidden one does. The footer wraps its buttons, and + repeats. The form is a `HeadForm` from PlatformHeads.WindowsForms, which opens it where it was left, turns a close into a request, brings a maximised window back maximised, keeps frames coming inside user32's own loops (`ViewerForm.Frame` hands it `ModalFrame`), and idles in `WaitForFrame`: the loop waits there on the message queue and its `LoopWake` together, until input, a change of state or a window command, which `MessageHandler` and `OwnerLink.Commanded` set it for, and no longer than a frame while a spinner is up. It woke sixty times a second with nothing to do. A fix to any of that goes to PlatformHeads. The footer wraps its buttons, and gives a status with no room beside them lines of its own under them (`LayOutFooter`), as the other two heads do; the buttons are the footer's own children, and a taller footer comes off the canvas, which reports the rows it has left. A decoded picture is kept premultiplied, as the @@ -615,10 +615,10 @@ the comment there about not caching "nothing staged" asks for. **Native shim (`native/`), used by the Mac and Linux heads only:** - The build and packaging of the heads is PlatformHeads' (`D:\Code\PlatformHeads`, github.com/SimonCropp/PlatformHeads), which BuildMonitor shares: it was worked out here and - copied there, and fixes made here did not reach the copy. Four pins name one tag of it and move + copied there, and fixes made here did not reach the copy. Five pins name one tag of it and move together: the `uses:` of `.github/workflows/build-native.yml`, the `GIT_TAG` in - `native/CMakeLists.txt`, and `PlatformHeads` and `PlatformHeads.Core` in - `Directory.Packages.props`. The workflow is its + `native/CMakeLists.txt`, and `PlatformHeads`, `PlatformHeads.Core` and + `PlatformHeads.WindowsForms` in `Directory.Packages.props`. The workflow is its reusable `build-native`. Its CMake module pins raylib and Dear ImGui, carries the raylib 6.0 option workarounds and links the library, and `native/CMakeLists.txt` names only the picture formats, the source file and the two libraries the viewer links beyond raylib. Its package's diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index b1847e7a3..ea972f137 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -15,7 +15,7 @@ include(FetchContent) FetchContent_Declare( platformheads GIT_REPOSITORY https://github.com/SimonCropp/PlatformHeads.git - GIT_TAG 0.2.0 + GIT_TAG 0.3.0 GIT_SHALLOW ON) FetchContent_MakeAvailable(platformheads) include(${platformheads_SOURCE_DIR}/cmake/PlatformHeads.cmake) diff --git a/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs b/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs index 4a1b3062a..2c6c031b8 100644 --- a/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs +++ b/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs @@ -715,10 +715,10 @@ screen with [Test] public async Task TheFirstWindowIsSizedForTheDisplay() { - await Assert.That(ViewerForm.InitialClientSize(new(1100, 700), 96, new(1920, 1040))).IsEqualTo(new(1100, 700)); - await Assert.That(ViewerForm.InitialClientSize(new(1100, 700), 192, new(3840, 2100))).IsEqualTo(new(2200, 1400)); + await Assert.That(WindowFit.InitialClientSize(new(1100, 700), 96, new(1920, 1040))).IsEqualTo(new(1100, 700)); + await Assert.That(WindowFit.InitialClientSize(new(1100, 700), 192, new(3840, 2100))).IsEqualTo(new(2200, 1400)); // 1050 tall at 150% does not fit a 1080p working area, and is kept inside it - await Assert.That(ViewerForm.InitialClientSize(new(1100, 700), 144, new(1920, 1040))).IsEqualTo(new(1650, 936)); + await Assert.That(WindowFit.InitialClientSize(new(1100, 700), 144, new(1920, 1040))).IsEqualTo(new(1650, 936)); } /// @@ -743,7 +743,7 @@ public async Task WhatTheFirstWindowHoldsAtEachScale() // Straight from user32: SystemInformation answers 17 at every DPI in an unaware host. var bar = GetSystemMetricsForDpi(verticalScrollBarWidth, (uint) dpi); var footer = 40 * dpi / 96; - var window = ViewerForm.InitialClientSize(new(1100, 700), dpi, new(3840, 2100)); + var window = WindowFit.InitialClientSize(new(1100, 700), dpi, new(3840, 2100)); host.Resize(window.Width - bar, window.Height - footer); typeof(ViewerCanvas).GetField("cell", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(host.Canvas, cell); host.Canvas.Draw(screen); @@ -1279,8 +1279,22 @@ static string Lines(int count, int changedAt = -1) return builder.ToString(); } - static T Field(object target, string name) => - (T) target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(target)!; + /// + /// A private field of or of a class it derives from: the modal frame + /// timer is HeadForm's, and reflection does not look in a base class for a private field. + /// + static T Field(object target, string name) + { + for (var type = target.GetType(); type is not null; type = type.BaseType) + { + if (type.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) is { } field) + { + return (T) field.GetValue(target)!; + } + } + + throw new MissingFieldException(target.GetType().Name, name); + } static (int Cell, float Advance) Measure(Font font, int dpi) { diff --git a/src/DiffEngineViewer.Windows.Tests/FrameWaitTests.cs b/src/DiffEngineViewer.Windows.Tests/FrameWaitTests.cs deleted file mode 100644 index c0eb2bb94..000000000 --- a/src/DiffEngineViewer.Windows.Tests/FrameWaitTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -/// -/// How long the loop waits between frames: a sixtieth of a second for a window somebody can see, -/// a tenth for one nobody can. -/// -[NotInParallel] -[TUnit.Core.Executors.STAThreadExecutor] -public class FrameWaitTests -{ - [Test] - [Arguments(true, FormWindowState.Normal, 15)] - [Arguments(true, FormWindowState.Maximized, 15)] - [Arguments(true, FormWindowState.Minimized, 100)] - [Arguments(false, FormWindowState.Normal, 100)] - [Arguments(false, FormWindowState.Minimized, 100)] - public async Task Waits(bool visible, FormWindowState state, int milliseconds) => - await Assert.That(FormsViewerWindow.FrameWait(visible, state)).IsEqualTo(milliseconds); - - /// - /// Why the state is asked as well: a minimised window is still Visible, which was the whole of - /// the test, so it was taken for one on screen. - /// - [Test] - public async Task AMinimisedWindowIsStillVisible() - { - // Shown, since a window never shown is not Visible whatever its state. Minimising is asked - // of Windows by a call that hands the keyboard on, so on a desktop nobody is at, and - // parked and transparent besides for a machine that has only the one - var (visible, state) = UnseenDesktop.Run(() => - { - using var form = new ViewerForm("title", 800, 600) - { - Opacity = 0, - ShowInTaskbar = false, - StartPosition = FormStartPosition.Manual, - Location = new(-4000, -2000), - Parked = true - }; - form.Show(); - form.WindowState = FormWindowState.Minimized; - return (form.Visible, form.WindowState); - }); - - await Assert.That(visible).IsTrue(); - await Assert.That(FormsViewerWindow.FrameWait(visible, state)).IsEqualTo(100); - } - - /// - /// And the loop is told, since the wait above slows only this head's own frames: what the loop - /// keeps going beside the window went on as for one being read. Shown as the window above is, - /// and for its reasons. - /// - [Test] - public async Task AMinimisedWindowSaysNobodyCanSeeIt() - { - var (shown, minimised) = UnseenDesktop.Run(() => - { - using var form = new ViewerForm("title", 800, 600) - { - Opacity = 0, - ShowInTaskbar = false, - StartPosition = FormStartPosition.Manual, - Location = new(-4000, -2000), - Parked = true - }; - form.Show(); - var shown = form.Drain().Unseen; - form.WindowState = FormWindowState.Minimized; - return (shown, form.Drain().Unseen); - }); - - await Assert.That(shown).IsFalse(); - await Assert.That(minimised).IsTrue(); - } -} diff --git a/src/DiffEngineViewer.Windows.Tests/WindowPlacementTests.cs b/src/DiffEngineViewer.Windows.Tests/WindowPlacementTests.cs index b2ae0b924..5ba2692df 100644 --- a/src/DiffEngineViewer.Windows.Tests/WindowPlacementTests.cs +++ b/src/DiffEngineViewer.Windows.Tests/WindowPlacementTests.cs @@ -13,7 +13,7 @@ public class WindowPlacementTests [Test] public async Task AWindowOnADisplayOpensWhereItWas() { - var restored = ViewerForm.Restorable(new(300, 200, 1100, 700, false), [display]); + var restored = WindowFit.Restorable(new(300, 200, 1100, 700, false), [display]); await Assert.That(restored).IsEqualTo(new Rectangle(300, 200, 1100, 700)); } @@ -25,7 +25,7 @@ public async Task AWindowOnADisplayOpensWhereItWas() [Test] public async Task ASnappedWindowIsLeftExactlyWhereItWas() { - var restored = ViewerForm.Restorable(new(-7, 0, 974, 1047, false), [display]); + var restored = WindowFit.Restorable(new(-7, 0, 974, 1047, false), [display]); await Assert.That(restored).IsEqualTo(new Rectangle(-7, 0, 974, 1047)); } @@ -33,7 +33,7 @@ public async Task ASnappedWindowIsLeftExactlyWhereItWas() [Test] public async Task AWindowOnTheSecondDisplayOpensThere() { - var restored = ViewerForm.Restorable(new(2200, 100, 1500, 900, false), [display, second]); + var restored = WindowFit.Restorable(new(2200, 100, 1500, 900, false), [display, second]); await Assert.That(restored).IsEqualTo(new Rectangle(2200, 100, 1500, 900)); } @@ -45,7 +45,7 @@ public async Task AWindowOnTheSecondDisplayOpensThere() [Test] public async Task AWindowOnADisplayThatHasGoneIsNotUsed() { - var restored = ViewerForm.Restorable(new(2200, 100, 1500, 900, false), [display]); + var restored = WindowFit.Restorable(new(2200, 100, 1500, 900, false), [display]); await Assert.That(restored).IsNull(); } @@ -53,7 +53,7 @@ public async Task AWindowOnADisplayThatHasGoneIsNotUsed() [Test] public async Task AWindowMostlyOffADisplayIsMovedOntoIt() { - var restored = ViewerForm.Restorable(new(1700, 800, 1100, 700, false), [display]); + var restored = WindowFit.Restorable(new(1700, 800, 1100, 700, false), [display]); await Assert.That(restored).IsEqualTo(new Rectangle(820, 340, 1100, 700)); } @@ -65,7 +65,7 @@ public async Task AWindowMostlyOffADisplayIsMovedOntoIt() [Test] public async Task AWindowWithItsTitleBarOffTheTopIsMovedDown() { - var restored = ViewerForm.Restorable(new(300, -200, 1100, 900, false), [display]); + var restored = WindowFit.Restorable(new(300, -200, 1100, 900, false), [display]); await Assert.That(restored).IsEqualTo(new Rectangle(300, 0, 1100, 900)); } @@ -76,7 +76,7 @@ public async Task AWindowWithItsTitleBarOffTheTopIsMovedDown() [Test] public async Task AWindowLargerThanTheDisplayIsCutToIt() { - var restored = ViewerForm.Restorable(new(100, 100, 3000, 2000, false), [display]); + var restored = WindowFit.Restorable(new(100, 100, 3000, 2000, false), [display]); await Assert.That(restored).IsEqualTo(new Rectangle(0, 0, 1920, 1040)); } @@ -84,7 +84,7 @@ public async Task AWindowLargerThanTheDisplayIsCutToIt() [Test] public async Task AWindowTooSmallToHaveBeenLeftIsNotUsed() { - var restored = ViewerForm.Restorable(new(300, 200, 40, 20, false), [display]); + var restored = WindowFit.Restorable(new(300, 200, 40, 20, false), [display]); await Assert.That(restored).IsNull(); } @@ -106,15 +106,15 @@ public async Task AFirstWindowIsCentredForTheSizeItOpensAt() var area = System.Windows.Forms.Screen.FromControl(form).WorkingArea; await Assert.That(form.Width).IsLessThan(area.Width); - await Assert.That(form.Location).IsEqualTo(ViewerForm.Centred(area, form.Size)); + await Assert.That(form.Location).IsEqualTo(WindowFit.Centred(area, form.Size)); await Assert.That(form.Left).IsGreaterThan(area.Left); } [Test] public async Task CentredIsTheMiddleOfTheDisplayItIsOn() { - await Assert.That(ViewerForm.Centred(display, new(1100, 700))).IsEqualTo(new(410, 170)); - await Assert.That(ViewerForm.Centred(second, new(1500, 900))).IsEqualTo(new(2450, 250)); + await Assert.That(WindowFit.Centred(display, new(1100, 700))).IsEqualTo(new(410, 170)); + await Assert.That(WindowFit.Centred(second, new(1500, 900))).IsEqualTo(new(2450, 250)); } /// @@ -123,7 +123,7 @@ public async Task CentredIsTheMiddleOfTheDisplayItIsOn() /// [Test] public async Task AWindowLargerThanTheDisplayIsCentredFromItsTopLeft() => - await Assert.That(ViewerForm.Centred(second, new(3000, 1600))).IsEqualTo(new(1920, 0)); + await Assert.That(WindowFit.Centred(second, new(3000, 1600))).IsEqualTo(new(1920, 0)); [Test] public async Task AFormOpensAtTheBoundsItIsGiven() diff --git a/src/DiffEngineViewer.Windows/DiffEngineViewer.Windows.csproj b/src/DiffEngineViewer.Windows/DiffEngineViewer.Windows.csproj index 993da231c..0216e9325 100644 --- a/src/DiffEngineViewer.Windows/DiffEngineViewer.Windows.csproj +++ b/src/DiffEngineViewer.Windows/DiffEngineViewer.Windows.csproj @@ -72,6 +72,7 @@ + diff --git a/src/DiffEngineViewer.Windows/FormsViewerWindow.cs b/src/DiffEngineViewer.Windows/FormsViewerWindow.cs index cdcc663b1..743d19060 100644 --- a/src/DiffEngineViewer.Windows/FormsViewerWindow.cs +++ b/src/DiffEngineViewer.Windows/FormsViewerWindow.cs @@ -15,12 +15,6 @@ sealed class FormsViewerWindow : IViewerWindow, ILoopHooks { - /// - /// Roughly sixty frames a second, which is what the shim's SetTargetFPS gives the other heads. - /// Without it this loop would spin a core, since DoEvents returns immediately when idle. - /// - const int frameMilliseconds = 16; - readonly ViewerForm form; bool disposed; @@ -70,48 +64,25 @@ public bool Present(Screen screen) return false; } - Wait(); return true; } /// - /// Until the next frame is due or input arrives, whichever is first. Thread.Sleep(16) woke on - /// the default 15.6ms timer tick after the one it asked for, so about 31ms: half the frame - /// rate, with every key and click waiting out the rest of it. 15 lands on the next tick, and - /// input ends the wait at once. Hidden, which a tray keeps it for days, there is nothing to - /// draw, so it wakes a few times a second rather than sixty. + /// HeadForm's: idles on the message queue and the loop's wake together, a frame at most while + /// a spinner is up, and not at all while input is already waiting. It woke sixty times a + /// second with the window open and nothing happening, and ten hidden or minimised. /// - void Wait() + public bool Wait(TimeSpan timeout, WaitHandle wake) { - // Input already waiting is the next frame's, now: a key and a click that land together are - // two frames, and sleeping between them would put the second a frame behind for nothing. - if (form.Pending) + if (disposed || form.IsDisposed) { - return; + return false; } - var timeout = FrameWait(form.Visible, form.WindowState); - MsgWaitForMultipleObjectsEx(0, IntPtr.Zero, (uint) timeout, allInput, inputAvailable); + form.WaitForFrame(timeout, wake); + return true; } - /// - /// How long to wait for the next frame. A minimised window waits as a hidden one does: it is - /// still Visible to WinForms, so it went on at sixty frames a second with nothing of it on - /// screen, for as long as it sat in the taskbar. Not a third state, because what a hidden - /// window has to hear is what a minimised one has to: the wait ends on any message, which is - /// the click that restores it, and the loop reads what the listener queued each time it wakes, - /// so a snapshot that arrives raises the window within a tenth of a second either way. - /// - internal static int FrameWait(bool visible, FormWindowState state) => - visible && state != FormWindowState.Minimized ? frameMilliseconds - 1 : hiddenMilliseconds; - - const int hiddenMilliseconds = 100; - const uint allInput = 0x04FF; - const uint inputAvailable = 0x0004; - - [DllImport("user32.dll")] - static extern uint MsgWaitForMultipleObjectsEx(uint count, IntPtr handles, uint milliseconds, uint wakeMask, uint flags); - public ViewerInput Poll() => form.IsDisposed ? default : form.Drain(); @@ -158,16 +129,13 @@ public void Focus() /// public bool SetClipboard(string text) { - try + if (FormsClipboard.Set(text, out var failure)) { - Clipboard.SetText(text); return true; } - catch (ExternalException exception) - { - Console.Error.WriteLine($"Could not write to the clipboard: {exception.Message}"); - return false; - } + + Console.Error.WriteLine($"Could not write to the clipboard: {failure?.Message}"); + return false; } public bool Capture(Screen screen, int width, int height, string pngPath) diff --git a/src/DiffEngineViewer.Windows/ViewerApp.cs b/src/DiffEngineViewer.Windows/ViewerApp.cs index 3430ae82a..a29ebcc22 100644 --- a/src/DiffEngineViewer.Windows/ViewerApp.cs +++ b/src/DiffEngineViewer.Windows/ViewerApp.cs @@ -1,5 +1,5 @@ /// -/// The process wide WinForms setup. +/// The process wide WinForms setup, through the shell's . /// /// Here rather than in Program.Main because two entry points need it: the app, and the test /// host, which never runs Main and yet renders the same controls into the same committed @@ -9,10 +9,10 @@ /// static class ViewerApp { - static bool configured; - /// - /// The app: DPI aware, so the window follows whichever display it is on. + /// The app: DPI aware, so the window follows whichever display it is on. That is the reason + /// this head exists rather than a sixth copy of the shim: the native renderer has no DPI + /// handling at all and converts pixels to cells by dividing by a constant. /// public static void Configure() => Configure(HighDpiMode.PerMonitorV2); @@ -26,27 +26,9 @@ public static void Configure() => public static void ConfigureUnscaled() => Configure(HighDpiMode.DpiUnaware); - static void Configure(HighDpiMode dpi) - { - // SetCompatibleTextRenderingDefault throws once a window exists, and there are two callers - // that could both run. - if (configured) - { - return; - } - - configured = true; - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); - // The reason this head exists rather than a sixth copy of the shim: the native renderer - // has no DPI handling at all and converts pixels to cells by dividing by a constant. - Application.SetHighDpiMode(dpi); - // Last, and before any handle exists, because the mode is read as each control is created. - // Without it the footer's real controls stay light against a dark canvas. The identifier is - // not a diagnostic on the SDK this builds against; the suppression is insurance for a - // machine whose reference pack still marks these APIs experimental. -#pragma warning disable WFO5001 - Application.SetColorMode(SystemColorMode.Dark); -#pragma warning restore WFO5001 - } + /// + /// Dark always, or the footer's real controls stay light against a dark canvas. + /// + static void Configure(HighDpiMode dpi) => + FormsApp.Configure(dpi, SystemColorMode.Dark); } diff --git a/src/DiffEngineViewer.Windows/ViewerForm.cs b/src/DiffEngineViewer.Windows/ViewerForm.cs index fc5eda778..d58dbf67d 100644 --- a/src/DiffEngineViewer.Windows/ViewerForm.cs +++ b/src/DiffEngineViewer.Windows/ViewerForm.cs @@ -3,7 +3,7 @@ /// which keeps the loop in ViewerProgram identical to the one the native heads run. /// [DesignerCategory("")] -sealed class ViewerForm : Form +sealed class ViewerForm : HeadForm { readonly ViewerCanvas canvas = new() { @@ -115,17 +115,15 @@ readonly record struct Discrete( /// Whether input is waiting for a frame, so the loop takes the next frame now rather than /// sleeping until one is due. /// - public bool Pending => discrete.Count > 0; + public override bool Pending => discrete.Count > 0; int scrollTo = -1; int scrollDelta; int zoomDelta; - bool closeRequested; - bool closingForReal; - public ViewerForm(string title, int width, int height, WindowPlacement? placement = null) + public ViewerForm(string title, int width, int height, WindowPlacement? placement = null) : + base(title, new(width, height), placement) { - Text = title; if (icon is not null) { Icon = icon; @@ -133,24 +131,6 @@ public ViewerForm(string title, int width, int height, WindowPlacement? placemen BackColor = Palette.Background; ForeColor = Palette.Text; - ClientSize = new(width, height); - StartPosition = FormStartPosition.CenterScreen; - if (placement is { } saved && - Restorable(saved, WorkingAreas()) is { } bounds) - { - // Already in device pixels, on the display they were measured on, so not scaled again - sized = true; - restored = bounds; - StartPosition = FormStartPosition.Manual; - Bounds = bounds; - // Before the window is shown, so it comes up maximised rather than growing into it, - // on the display its bounds are on. Those stay what it restores to. - if (saved.Maximized) - { - maximized = true; - WindowState = FormWindowState.Maximized; - } - } KeyPreview = true; @@ -189,14 +169,6 @@ public ViewerForm(string title, int width, int height, WindowPlacement? placemen EnterModal(); }; - modalFrames.Tick += (_, _) => - { - if (Frame is { } frame) - { - Apply(frame()); - Animate(); - } - }; canvas.QueueItemClicked += _ => discrete.Enqueue(new(QueueItem: _)); canvas.QueueItemRightClicked += (row, point) => @@ -227,205 +199,17 @@ public ViewerForm(string title, int width, int height, WindowPlacement? placemen }; } - /// - /// Shown without being made the window the keyboard goes to. For a form that is shown only - /// so that it will paint and take messages, somewhere nobody can see it: a capture, and the - /// tests that post input to one. Shown as a window ordinarily is, it took the keyboard from - /// whatever the person at the machine was typing into for as long as it was up, and what - /// they typed next was this window's commands. - /// - [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] - public bool Parked { get; set; } - - protected override bool ShowWithoutActivation => - Parked; - /// /// Once the handle exists, because DeviceDpi is only meaningful then, and again whenever the - /// window moves to a display with different scaling. + /// window moves to a display with different scaling. The first size and the placement are + /// 's, which has them by the time this runs. /// protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); - if (!sized) - { - sized = true; - var area = System.Windows.Forms.Screen.FromControl(this).WorkingArea; - ClientSize = InitialClientSize(ClientSize, DeviceDpi, area.Size); - - // Centred again, for the size it has now. WinForms centres a window as it creates it, - // which is before this, so it was centred for the size asked for in logical pixels and - // then grew down and to the right from there: at 150% on a 1080p display the footer - // was under the taskbar, and that was the placement remembered for every run after. - if (StartPosition == FormStartPosition.CenterScreen && - WindowState == FormWindowState.Normal) - { - Location = Centred(area, Size); - } - } - else if (restored is { } bounds && - WindowState == FormWindowState.Normal && - Bounds != bounds) - { - // Asked for before there was a handle, on a display whose scaling the form had not - // met yet. Whatever creating it there did to the size, this is the size it had. - Bounds = bounds; - } - - restored = null; ScaleChrome(); } - bool sized; - - /// - /// The bounds a remembered placement asked for, until the handle exists and has them. - /// - Rectangle? restored; - - /// - /// Whether the window is maximised, or was when it was minimised: a minimised window's own - /// state says only that it is minimised, and it comes back as whichever it was. - /// - bool maximized; - - /// - /// See . Read from the window while there is one, and - /// what that last came to once there is not. - /// - [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] - public WindowPlacement? Placement - { - get - { - if (IsHandleCreated && - !IsDisposed) - { - // Maximised or minimised, the bounds are of neither: RestoreBounds is where the - // window goes back to, which is the only size worth opening the next one at. - var bounds = WindowState == FormWindowState.Normal ? Bounds : RestoreBounds; - if (bounds is {Width: > 0, Height: > 0}) - { - field = new(bounds.X, bounds.Y, bounds.Width, bounds.Height, maximized); - } - } - - return field; - } - } - - protected override void OnResize(EventArgs e) - { - base.OnResize(e); - if (WindowState != FormWindowState.Minimized) - { - maximized = WindowState == FormWindowState.Maximized; - } - } - - static List WorkingAreas() => - System.Windows.Forms.Screen.AllScreens - .Select(_ => _.WorkingArea) - .ToList(); - - /// - /// Where a remembered window may open, or null when it should open as a new one does. - /// - /// The displays are not the ones it was remembered on often enough to matter: a laptop taken - /// off its dock remembers a window on a monitor that is no longer there, and opening it there - /// is a viewer that looks as if it did not start. So it opens where it was only when most of - /// it, and its title bar, are on a display there is now; otherwise it is moved onto the one - /// it overlaps most, and with none it is not used at all. - /// - /// - /// Left exactly where it was whenever it can be, rather than tidied into the working area. A - /// window snapped to an edge sits a few pixels past it, the width of a resize border nobody - /// can see, and moving that inside would walk it across the screen a border at a time. - /// - /// - internal static Rectangle? Restorable(WindowPlacement saved, IReadOnlyList workingAreas) - { - var bounds = new Rectangle(saved.X, saved.Y, saved.Width, saved.Height); - // Smaller than anything a person could have left it, so not something a window reported - if (bounds.Width < minimumRestored.Width || - bounds.Height < minimumRestored.Height) - { - return null; - } - - Rectangle? best = null; - long overlap = 0; - foreach (var area in workingAreas) - { - var shared = Rectangle.Intersect(area, bounds); - var size = (long) shared.Width * shared.Height; - if (size > overlap) - { - overlap = size; - best = area; - } - } - - if (best is not { } screen) - { - return null; - } - - if (overlap * 2 >= (long) bounds.Width * bounds.Height && - bounds.Top >= screen.Top - edgeSlack && - bounds.Top < screen.Bottom - edgeSlack) - { - return bounds; - } - - var width = Math.Min(bounds.Width, screen.Width); - var height = Math.Min(bounds.Height, screen.Height); - return new( - Math.Clamp(bounds.X, screen.Left, screen.Right - width), - Math.Clamp(bounds.Y, screen.Top, screen.Bottom - height), - width, - height); - } - - static readonly Size minimumRestored = new(200, 150); - - /// - /// How far past the top of a display a title bar may be and still be reachable: more than the - /// invisible border a snapped window hangs over by, less than the bar itself. - /// - const int edgeSlack = 16; - - /// - /// The size asked for is in logical pixels, and the window is per monitor aware, so it is - /// scaled to the display it opens on - once, before it is shown, and centred again for the - /// size that comes to, since WinForms had already centred it for the other. Unscaled, it was - /// 1100 by 700 device pixels while the text grew with the display: at 200% each pane had room - /// for four characters. Moving to another display afterwards is Windows' to scale. - /// - /// Kept inside the working area, which a scaled window can outgrow: 1100 by 700 at 150% is too - /// tall for a 1080p screen once the taskbar is taken off. - /// - /// - internal static Size InitialClientSize(Size logical, int dpi, Size workingArea) - { - var width = logical.Width * dpi / 96; - var height = logical.Height * dpi / 96; - // Room for the frame and title bar, which are outside the client area - var maxWidth = workingArea.Width * 9 / 10; - var maxHeight = workingArea.Height * 9 / 10; - return new(Math.Min(width, maxWidth), Math.Min(height, maxHeight)); - } - - /// - /// Where a window of sits to be in the middle of - /// . One too large for it starts at the area's top left, so its title - /// bar is still in reach. - /// - internal static Point Centred(Rectangle area, Size size) => - new( - Math.Max(area.X, area.X + (area.Width - size.Width) / 2), - Math.Max(area.Y, area.Y + (area.Height - size.Height) / 2)); - protected override void OnDpiChanged(DpiChangedEventArgs e) { base.OnDpiChanged(e); @@ -433,80 +217,34 @@ protected override void OnDpiChanged(DpiChangedEventArgs e) } /// - /// One frame of the loop, run on while user32 holds the thread in a - /// modal loop of its own. See . - /// - [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] - public Func? Frame { get; set; } - - /// - /// See . + /// One frame of the loop, for to run while user32 holds the + /// thread in a modal loop of its own. See . /// [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] - public Action? SessionEnding { get; set; } - - /// - /// A WinForms timer because its ticks are window messages, and a modal loop - the scroll bar's - /// while the thumb is dragged, the frame's while the window is moved or sized - still - /// dispatches those, where it never returns to the loop that called DoEvents. - /// - readonly System.Windows.Forms.Timer modalFrames = new() - { - Interval = 16 - }; - - void EnterModal() + public Func? Frame { - if (Frame is not null && - !modalFrames.Enabled) + get; + set { - modalFrames.Start(); + field = value; + ModalFrame = value is null ? null : ModalStep; } } - void ExitModal() => - modalFrames.Stop(); - - /// - /// The loop is presenting a frame of its own, so nothing is holding the thread: whatever modal - /// loop was entered has returned. Its exit is normally what says so. A scroll sent to the bar - /// by something other than a press - an accessibility tool, say - need not be followed by an - /// EndScroll, and frames would then come from the timer as well as the loop from there on. - /// - public void LoopReturned() => - ExitModal(); - - const int enterSizeMove = 0x0231; - const int exitSizeMove = 0x0232; - - protected override void WndProc(ref Message message) + void ModalStep() { - if (message.Msg == enterSizeMove) - { - EnterModal(); - } - else if (message.Msg == exitSizeMove) + if (Frame is { } frame) { - ExitModal(); + Apply(frame()); + Animate(); } - - base.WndProc(ref message); } /// - /// Persisted here, synchronously, when the session is ending. WinForms closes the form inside - /// WM_ENDSESSION, and Windows may end the process as soon as that returns - before the loop has - /// even noticed the form is gone, let alone got through its own shutdown to the staging. + /// A spinner turns only as the canvas repaints, so while one is up the loop idles no longer + /// than a frame. /// - protected override void OnFormClosed(FormClosedEventArgs e) - { - if (EndsTheSession(e.CloseReason)) - { - SessionEnding?.Invoke(); - } - - base.OnFormClosed(e); - } + protected override bool Animating => canvas.Spinners.Count > 0; void ScaleChrome() { @@ -640,30 +378,6 @@ int Reluctance(Size line) => /// int shownButtons; - /// - /// Brings the window up, for a snapshot that has just arrived and wants reading. - /// - /// The restore is the part that was missing. BringToFront and Activate leave a minimised - /// window minimised - the taskbar button flashes and nothing else happens - so a viewer that - /// had been minimised never showed the snapshot it was being asked to show, and the queue - /// filled up out of sight. - /// - /// - public void Raise() - { - Visible = true; - if (WindowState == FormWindowState.Minimized) - { - // Back to what it was minimised from, as the taskbar would put it. Always to normal, - // a window the reader had maximised came back at its restored size, and was then - // remembered as one they had not maximised. - WindowState = maximized ? FormWindowState.Maximized : FormWindowState.Normal; - } - - BringToFront(); - Activate(); - } - public void Apply(Screen screen) { // The loop hands over the screen it handed over last frame for as long as nothing has @@ -838,7 +552,7 @@ public ViewerInput Drain() ClickedButton: next.Button, ClickedQueueItem: next.QueueItem, ScrollDelta: scrollDelta, - CloseRequested: closeRequested, + CloseRequested: TakeCloseRequest(), Columns: Grid.Columns, Rows: Grid.Rows, RightClickedQueueItem: next.RightClickedQueueItem, @@ -861,43 +575,9 @@ public ViewerInput Drain() scrollTo = -1; scrollDelta = 0; zoomDelta = 0; - closeRequested = false; return input; } - public void CloseForReal() - { - closingForReal = true; - Close(); - } - - protected override void OnFormClosing(FormClosingEventArgs e) - { - // Cancelled for a close the user asked for, because whether that means hide or exit is - // ViewerProgram's rule and it needs a tray check to decide. CloseForReal is how the answer - // comes back. - // - // Never for a close the session is ending: WinForms answers WM_QUERYENDSESSION with - // !e.Cancel, so refusing made Windows report the viewer as preventing shutdown, and with a - // tray running the loop only hid the window - leaving the process blocking until the user - // chose "Shut down anyway". Letting it through is safe because the loop watches for a - // disposed form and returns, which runs the same shutdown it would have run anyway. - if (!closingForReal && !EndsTheSession(e.CloseReason)) - { - closeRequested = true; - e.Cancel = true; - } - - base.OnFormClosing(e); - } - - /// - /// The process is going away whatever this form says. Task Manager's End Task is here with - /// shutdown because refusing it buys the same nothing: the user has already decided. - /// - internal static bool EndsTheSession(CloseReason reason) => - reason is CloseReason.WindowsShutDown or CloseReason.TaskManagerClosing; - /// /// ProcessCmdKey rather than OnKeyDown, because Tab and Escape are consumed by focus /// navigation and the default button before a key handler would ever see them. @@ -1036,7 +716,6 @@ protected override void Dispose(bool disposing) if (disposing) { contextMenu.Dispose(); - modalFrames.Dispose(); } base.Dispose(disposing); diff --git a/src/DiffEngineViewer/Ipc/OwnerLink.cs b/src/DiffEngineViewer/Ipc/OwnerLink.cs index 8a0d6c746..fcc8339f9 100644 --- a/src/DiffEngineViewer/Ipc/OwnerLink.cs +++ b/src/DiffEngineViewer/Ipc/OwnerLink.cs @@ -21,6 +21,12 @@ sealed class OwnerLink(SessionHost host, int port, DocumentPlugin? documents = n /// public ConcurrentQueue Windows { get; } = new(); + /// + /// Told when a command is queued, so a loop idling on its wake draws it at once: a command + /// changes no state, so nothing else would end the idle before it ran out. + /// + public Action? Commanded { get; set; } + /// /// Fast enough that someone accepting from the tray sees the window follow, slow enough that /// an idle pair is not a busy loop over loopback. @@ -268,6 +274,7 @@ held is not null && } Windows.Enqueue(response.Window.Value); + Commanded?.Invoke(); } return true; diff --git a/src/DiffEngineViewer/ViewerProgram.cs b/src/DiffEngineViewer/ViewerProgram.cs index 7bd6fc4b9..b7dfdf784 100644 --- a/src/DiffEngineViewer/ViewerProgram.cs +++ b/src/DiffEngineViewer/ViewerProgram.cs @@ -321,12 +321,26 @@ internal static int Run( // Whichever of the two produces them; a process either owns the queue or displays one. var windowCommands = link?.Windows ?? new(); + // What an idle loop waits on: set by every change of state, and by every window command, + // which changes none. Set off the loop's thread, so an arrival or a focus from the tray is + // drawn at once rather than at the end of the idle. + using var wake = new LoopWake(host); + link?.Commanded = wake.Set; using var cancel = new CancelSource(); // Only for a queue this process owns. A displayed one's accept-all runs in its owner, and // file mode's is the one comparison it shows. var runner = server is null ? null : new AcceptAllRunner(host, ViewerActions.Real); var listening = server?.Listen( - new MessageHandler(host, ViewerActions.Real, windowCommands.Enqueue, runner, documents).Handle, + new MessageHandler( + host, + ViewerActions.Real, + _ => + { + windowCommands.Enqueue(_); + wake.Set(); + }, + runner, + documents).Handle, cancel.Token); var polling = link is null ? null @@ -350,7 +364,7 @@ internal static int Run( { using (window) { - Loop(host, window, link, reader, watch, windowCommands, runner, preferences, screens); + Loop(host, window, link, reader, watch, windowCommands, runner, preferences, screens, wake.Handle); // While there is still a window to ask. Not from the finally: a loop that threw // has a window in no state worth opening the next one from. Remember(window, preferences); @@ -466,8 +480,13 @@ internal static void Loop( ConcurrentQueue windowCommands, AcceptAllRunner? runner, ViewerPreferences preferences, - ScreenCache screens) + ScreenCache screens, + WaitHandle? wake = null) { + // A loop handed no wake, as a test's, wakes on the state alone. + using var ownWake = wake is null ? new LoopWake(host) : null; + var waking = wake ?? ownWake!.Handle; + // Two reasons nobody is looking at the window, which come and go apart: it was hidden // from here, by a command or by closing it beside a tray, or its head says nothing of it // is on screen - minimised, or covered where a head can tell. Either slows what is kept @@ -525,19 +544,25 @@ void Slow() return; } - // The screen of the state last presented, for as long as that is still the state: see - // ScreenCache. A frame in which nothing happened builds nothing. if (host.State.Clipboard is { } copy) { ClipboardCopy.Offer(clipboard, host, window, copy); } + // The screen of the state last presented, for as long as that is still the state: see + // ScreenCache. A frame in which nothing happened builds nothing. var state = host.State; if (!window.Present(screens.Get(state))) { return; } + // Idles until something happens: input, a change of state, a window command, or a copy + // to offer the clipboard again on the next frame, which changes no state. The WinForms + // head woke sixty times a second with nothing to do. A native head waits a frame inside + // its present and answers at once, so it paces itself as it did. + window.Wait(Pacing.Idle(null, retrying: host.State.Clipboard is not null), waking); + var input = window.Poll(); // Said by every poll, and acted on when it changes. Apart from the state, which it is // no part of: a window going to the taskbar is not a frame with input in it. diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 1e396aaca..1af0b0ca7 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -21,6 +21,7 @@ + diff --git a/src/Packaging.Tests/Package.DiffEngine.verified.txt b/src/Packaging.Tests/Package.DiffEngine.verified.txt index f65807232..f61606ea9 100644 --- a/src/Packaging.Tests/Package.DiffEngine.verified.txt +++ b/src/Packaging.Tests/Package.DiffEngine.verified.txt @@ -55,9 +55,11 @@ tools/viewer/win-arm64/DiffEngineViewer.dll tools/viewer/win-arm64/DiffEngineViewer.exe tools/viewer/win-arm64/DiffEngineViewer.runtimeconfig.json tools/viewer/win-arm64/PlatformHeads.Core.dll +tools/viewer/win-arm64/PlatformHeads.WindowsForms.dll tools/viewer/win-x64/DiffEngineViewer.Core.dll tools/viewer/win-x64/DiffEngineViewer.deps.json tools/viewer/win-x64/DiffEngineViewer.dll tools/viewer/win-x64/DiffEngineViewer.exe tools/viewer/win-x64/DiffEngineViewer.runtimeconfig.json -tools/viewer/win-x64/PlatformHeads.Core.dll \ No newline at end of file +tools/viewer/win-x64/PlatformHeads.Core.dll +tools/viewer/win-x64/PlatformHeads.WindowsForms.dll \ No newline at end of file diff --git a/src/Packaging.Tests/Package.DiffEngineTray.verified.txt b/src/Packaging.Tests/Package.DiffEngineTray.verified.txt index d30936a3c..6118a801d 100644 --- a/src/Packaging.Tests/Package.DiffEngineTray.verified.txt +++ b/src/Packaging.Tests/Package.DiffEngineTray.verified.txt @@ -46,9 +46,11 @@ tools/net10.0/any/viewer/win-arm64/DiffEngineViewer.dll tools/net10.0/any/viewer/win-arm64/DiffEngineViewer.exe tools/net10.0/any/viewer/win-arm64/DiffEngineViewer.runtimeconfig.json tools/net10.0/any/viewer/win-arm64/PlatformHeads.Core.dll +tools/net10.0/any/viewer/win-arm64/PlatformHeads.WindowsForms.dll tools/net10.0/any/viewer/win-x64/DiffEngineViewer.Core.dll tools/net10.0/any/viewer/win-x64/DiffEngineViewer.deps.json tools/net10.0/any/viewer/win-x64/DiffEngineViewer.dll tools/net10.0/any/viewer/win-x64/DiffEngineViewer.exe tools/net10.0/any/viewer/win-x64/DiffEngineViewer.runtimeconfig.json -tools/net10.0/any/viewer/win-x64/PlatformHeads.Core.dll \ No newline at end of file +tools/net10.0/any/viewer/win-x64/PlatformHeads.Core.dll +tools/net10.0/any/viewer/win-x64/PlatformHeads.WindowsForms.dll \ No newline at end of file diff --git a/src/Packaging.Tests/Package.DiffEngineViewer.Windows.verified.txt b/src/Packaging.Tests/Package.DiffEngineViewer.Windows.verified.txt index 3ee219926..e2b2c9449 100644 --- a/src/Packaging.Tests/Package.DiffEngineViewer.Windows.verified.txt +++ b/src/Packaging.Tests/Package.DiffEngineViewer.Windows.verified.txt @@ -11,6 +11,7 @@ tools/net10.0/any/DiffEngineViewer.dll tools/net10.0/any/DiffEngineViewer.runtimeconfig.json tools/net10.0/any/DotnetToolSettings.xml tools/net10.0/any/PlatformHeads.Core.dll +tools/net10.0/any/PlatformHeads.WindowsForms.dll tools/net10.0/any/documents/AngleSharp.dll tools/net10.0/any/documents/DiffEngineViewer.Documents.deps.json tools/net10.0/any/documents/DiffEngineViewer.Documents.dll