Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-native.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion native/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 20 additions & 6 deletions src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/// <summary>
Expand All @@ -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);
Expand Down Expand Up @@ -1279,8 +1279,22 @@ static string Lines(int count, int changedAt = -1)
return builder.ToString();
}

static T Field<T>(object target, string name) =>
(T) target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(target)!;
/// <summary>
/// A private field of <paramref name="target"/> 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.
/// </summary>
static T Field<T>(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)
{
Expand Down
74 changes: 0 additions & 74 deletions src/DiffEngineViewer.Windows.Tests/FrameWaitTests.cs

This file was deleted.

24 changes: 12 additions & 12 deletions src/DiffEngineViewer.Windows.Tests/WindowPlacementTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand All @@ -25,15 +25,15 @@ 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));
}

[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));
}
Expand All @@ -45,15 +45,15 @@ 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();
}

[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));
}
Expand All @@ -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));
}
Expand All @@ -76,15 +76,15 @@ 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));
}

[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();
}
Expand All @@ -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));
}

/// <summary>
Expand All @@ -123,7 +123,7 @@ public async Task CentredIsTheMiddleOfTheDisplayItIsOn()
/// </summary>
[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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
<!-- And an ImageFormat, so the GDI+ one needs a name for the same reason. -->
<Using Include="System.Drawing.Imaging.ImageFormat" Alias="DrawingImageFormat" />
<ProjectReference Include="..\DiffEngineViewer\DiffEngineViewer.csproj" />
<PackageReference Include="PlatformHeads.WindowsForms" />
<PackageReference Include="ProjectDefaults" PrivateAssets="all" />
<None Include="..\DiffEngineViewer\Assets\JetBrainsMono-OFL.txt" Pack="true" PackagePath="\" />
<None Remove="viewer.ico" />
Expand Down
56 changes: 12 additions & 44 deletions src/DiffEngineViewer.Windows/FormsViewerWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,6 @@ sealed class FormsViewerWindow :
IViewerWindow,
ILoopHooks
{
/// <summary>
/// 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.
/// </summary>
const int frameMilliseconds = 16;

readonly ViewerForm form;
bool disposed;

Expand Down Expand Up @@ -70,48 +64,25 @@ public bool Present(Screen screen)
return false;
}

Wait();
return true;
}

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// 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.
/// </summary>
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();

Expand Down Expand Up @@ -158,16 +129,13 @@ public void Focus()
/// </summary>
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)
Expand Down
Loading
Loading