diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index b1f3dab9..f3198a68 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 3d3b4353..b57d0bb3 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 b1847e7a..ea972f13 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 4a1b3062..2c6c031b 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 c0eb2bb9..00000000 --- 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 b2ae0b92..5ba2692d 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 993da231..0216e932 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 cdcc663b..743d1906 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 3430ae82..a29ebcc2 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 fc5eda77..d58dbf67 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 8a0d6c74..fcc8339f 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 7bd6fc4b..b7dfdf78 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 1e396aac..1af0b0ca 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 f6580723..f61606ea 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 d30936a3..6118a801 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 3ee21992..e2b2c944 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