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