diff --git a/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs b/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs
new file mode 100644
index 00000000..4cd0f803
--- /dev/null
+++ b/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs
@@ -0,0 +1,933 @@
+using System.Drawing.Text;
+
+///
+/// The WinForms head's own behaviour, through a real form and canvas: where glyphs land, how big the
+/// first window is, input order, the context menu, modal loops, mouse capture and logoff.
+///
+/// Input is posted as real window messages and pumped through the real form, one frame at a time
+/// in the order ViewerProgram.Loop runs them: Apply, DoEvents, Drain, then the model.
+///
+///
+[NotInParallel]
+[TUnit.Core.Executors.STAThreadExecutor]
+public class FormsHeadTests
+{
+ const int columns = 120;
+ const int rows = 37;
+
+ ///
+ /// What reports against where
+ /// actually puts glyphs, at several display scales.
+ ///
+ /// The cell is measured the way the canvas measures it: a screen Graphics with no hint set, so
+ /// whatever the system default is. The glyphs are drawn the way the canvas draws them:
+ /// and . Where each glyph landed is
+ /// read back from the pixels, as the centre of each of 100 drawn bars.
+ ///
+ ///
+ [Test]
+ public async Task TheAdvanceIsWhereGlyphsLand()
+ {
+ using var font = MonoFont.Create();
+ var report = new StringBuilder();
+ var worst = 0d;
+ foreach (var dpi in (int[]) [96, 120, 144, 168, 192])
+ {
+ var (cell, advance) = Measure(font, dpi);
+ var bars = DrawnBars(font, dpi, 100);
+ var at99 = bars[99] - bars[0] - 99 * advance;
+ worst = Math.Max(worst, Math.Abs(at99));
+ report.AppendLine($"dpi {dpi}: cell {cell}, advance {advance:F3}, drawn {(bars[99] - bars[0]) / 99:F3}, drift at column 99 {at99:F2}px");
+ }
+
+ Console.WriteLine(report);
+ await Assert.That(worst).IsLessThan(1d);
+ }
+
+ ///
+ /// through the real canvas at the test host's scale (96): a bar at column 66 of a pane
+ /// row, with columns 66 to 67 selected. The bar's ink has to sit inside the highlight.
+ ///
+ [Test]
+ public async Task HighlightAtColumn66CoversItsGlyph()
+ {
+ var line = new string(' ', 66) + "|";
+ var state = ViewerSession.Resize(
+ ViewerSession.Drag(Fixtures.File(line, line), PaneSide.Left, 0, 66, 0, 67),
+ columns,
+ rows);
+
+ // Wide enough that column 66 is on screen in a pane: at 1100 each pane holds about 52.
+ using var host = new CanvasHost(2000, 700);
+ var bitmap = host.Draw(ScreenBuilder.Build(state));
+ var highlight = Bounds(bitmap, _ => _.ToArgb() == Palette.Selection.ToArgb());
+ await Assert.That(highlight).IsNotNull();
+
+ // The bar is the only bright ink in the left pane's row: the gutter's line number is drawn
+ // Dim, which is darker than the threshold, and the right pane's copy is far to the right.
+ var band = highlight!.Value;
+ var cell = host.Canvas.CellSize();
+ var ink = new List();
+ for (var x = 0; x < band.Right + cell.Width * 10; x++)
+ {
+ if (bitmap.GetPixel(x, band.Top + band.Height / 2).GetBrightness() > 0.6f)
+ {
+ ink.Add(x);
+ }
+ }
+
+ // Which column the hit test gives for a press on the bar itself.
+ var hit = host.Canvas.PaneCellAt(new((ink.Min() + ink.Max()) / 2, band.Top + band.Height / 2));
+
+ Console.WriteLine(
+ $"cell {cell}, highlight x {band.Left}..{band.Right - 1}, bar ink x {ink.Min()}..{ink.Max()}, " +
+ $"hit test on the bar gives column {hit?.Column}");
+ await Assert.That(ink).IsNotEmpty();
+ await Assert.That(ink.Min()).IsGreaterThanOrEqualTo(band.Left);
+ await Assert.That(ink.Max()).IsLessThan(band.Right);
+ }
+
+ ///
+ /// Ten image pairs drawn one after another, each accepted (received moved over
+ /// verified) before the next, and then a screen with no picture on it. Nothing needs more than
+ /// the two on screen.
+ ///
+ [Test]
+ public async Task EveryPictureEverDrawnStaysDecoded()
+ {
+ var directory = TempDirectory("deview-review-cache");
+ using var host = new CanvasHost();
+ for (var index = 0; index < 10; index++)
+ {
+ var received = Path.Combine(directory, $"Test{index}.received.png");
+ var verified = Path.Combine(directory, $"Test{index}.verified.png");
+ File.WriteAllBytes(received, SamplePng.Build(400, 300, 200, 40, 40));
+ File.WriteAllBytes(verified, SamplePng.Build(400, 300, 40, 40, 200));
+ var entry = QueueEntry.ForFiles(received, verified, FileSide.Read(received), FileSide.Read(verified));
+ var state = ViewerSession.Resize(
+ ViewerSession.EnqueueFile(SessionState.Start(ViewerMode.File, columns, rows), entry),
+ columns,
+ rows);
+ host.Draw(ScreenBuilder.Build(state));
+ File.Move(received, verified, true);
+ }
+
+ host.Draw(ScreenBuilder.Build(ViewerSession.Resize(Fixtures.File(), columns, rows)));
+ var (count, bytes) = host.Canvas.CachedImages();
+ Directory.Delete(directory, true);
+ Console.WriteLine($"{count} decoded pictures held, {bytes / 1024} KB of pixels, on a screen showing none");
+ await Assert.That(count).IsLessThanOrEqualTo(2);
+ }
+
+ ///
+ /// The default 1100 by 700 window at the common scales, through the canvas's own layout
+ /// code with the cell MonoFont measures at that scale and the chrome the form takes there: the
+ /// scrollbar's system width and the footer's LogicalToDeviceUnits(40). The window itself stays
+ /// 1100 by 700 device pixels, which is what ViewerForm's constructor asks for.
+ ///
+ [Test]
+ public async Task TheFirstWindowIsSizedForTheDisplay()
+ {
+ await Assert.That(ViewerForm.InitialClientSize(new(1100, 700), 96, new(1920, 1040))).IsEqualTo(new Size(1100, 700));
+ await Assert.That(ViewerForm.InitialClientSize(new(1100, 700), 192, new(3840, 2100))).IsEqualTo(new Size(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 Size(1650, 936));
+ }
+
+ ///
+ /// The first window at the common scales, through the canvas's own layout code with the cell
+ /// MonoFont measures at that scale and the chrome the form takes there. At 1100 by 700 device
+ /// pixels a pane held 4 characters at 200%.
+ ///
+ [Test]
+ public async Task WhatTheFirstWindowHoldsAtEachScale()
+ {
+ using var font = MonoFont.Create();
+ using var host = new CanvasHost();
+ var screen = ScreenBuilder.Build(ViewerSession.Resize(Fixtures.Inline(Fixtures.Patch()), columns, rows));
+ var report = new StringBuilder();
+ var fewest = int.MaxValue;
+ foreach (var dpi in (int[]) [96, 120, 144, 192])
+ {
+ using var bitmap = new Bitmap(1, 1);
+ bitmap.SetResolution(dpi, dpi);
+ using var graphics = Graphics.FromImage(bitmap);
+ var cell = MonoFont.Cell(graphics, font);
+ // 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));
+ host.Resize(window.Width - bar, window.Height - footer);
+ typeof(ViewerCanvas).GetField("cell", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(host.Canvas, cell);
+ host.Canvas.Draw(screen);
+ var (_, half, _) = host.Canvas.Panes();
+ var characters = (half - 8 * cell.Width) / cell.Width;
+ fewest = Math.Min(fewest, characters);
+ report.AppendLine($"dpi {dpi}: cell {cell}, window {window}, {Layout(host.Canvas)}");
+ }
+
+ Console.WriteLine(report);
+ await Assert.That(fewest).IsGreaterThanOrEqualTo(30);
+ }
+
+ static string Layout(ViewerCanvas canvas)
+ {
+ var cell = canvas.CellSize();
+ var (left, half, width) = canvas.Panes();
+ var text = half - 8 * cell.Width;
+ return $"canvas {canvas.Size}, cell {cell}, queue column {left} px, pane {half} px, text {text} px = {text / cell.Width} characters a pane, {canvas.BodyCapacity} body rows";
+ }
+
+ ///
+ /// Two presses of Down before the pump runs: both are real key messages, and both reach the
+ /// model, a frame each. One slot per kind of input kept only the last.
+ ///
+ [Test]
+ public async Task TwoKeysInOnePumpAreTwoCommands()
+ {
+ using var host = new FormHost(Fixtures.File(Lines(300, 3), Lines(300)));
+ host.Settle();
+ var before = ScreenBuilder.Build(host.State).Left.ScrollTop;
+
+ host.PostKey(Keys.Down);
+ host.PostKey(Keys.Down);
+ var first = host.Frame();
+ // The second is waiting for the next frame, which does not wait for more input first
+ await Assert.That(host.Form.Pending).IsTrue();
+ var second = host.Frame();
+
+ var after = ScreenBuilder.Build(host.State).Left.ScrollTop;
+ Console.WriteLine($"drained {first.Key} then {second.Key}; scroll top {before} -> {after}");
+ await Assert.That(after - before).IsEqualTo(2);
+ }
+
+ ///
+ /// d pressed while one entry is on screen, then a click on another row, both before the
+ /// pump runs. In the order they happened, the first entry is discarded and the second selected.
+ ///
+ [Test]
+ public async Task AKeyThenAClickInOnePumpAreAppliedInTheirOrder()
+ {
+ using var host = new FormHost(
+ Fixtures.Inline(
+ Fixtures.Patch("ATests.cs", 10, content: "one"),
+ Fixtures.Patch("BTests.cs", 20, content: "two"),
+ Fixtures.Patch("CTests.cs", 30, content: "three")));
+ host.Settle();
+ var onScreen = host.State.Current!.Key;
+ var clicked = host.State.Queue.Last(_ => _.Key != onScreen).Key;
+
+ host.PostKey(Keys.D);
+ host.PostClick(host.QueueRowOf(clicked), right: false);
+ var input = host.Frame();
+
+ var left = host.State.Queue.Select(_ => _.Key).ToList();
+ Console.WriteLine(
+ $"drained Key {input.Key}, ClickedQueueItem {input.ClickedQueueItem}; on screen at the key: {onScreen}; " +
+ $"clicked: {clicked}; still queued: {string.Join(" | ", left)}");
+ await Assert.That(left).DoesNotContain(onScreen);
+ await Assert.That(left).Contains(clicked);
+ }
+
+ ///
+ /// Right click a row, which opens its menu, then right click the same row again.
+ ///
+ [Test]
+ public async Task RightClickingTheRowWhoseMenuIsOpen()
+ {
+ using var host = new FormHost(
+ Fixtures.Inline(
+ Fixtures.Patch("ATests.cs", 10, content: "one"),
+ Fixtures.Patch("BTests.cs", 20, content: "two")));
+ host.Settle();
+ var row = host.QueueRowOf(host.State.Current!.Key);
+ var popup = Field(host.Form, "contextMenu");
+
+ host.PostClick(row, right: true);
+ host.Frame();
+ host.Frame();
+ var opened = $"first: model menu {host.State.Menu is not null}, popup {popup.Visible}";
+
+ host.PostClick(row, right: true);
+ host.Settle();
+ var reopened = $"second: model menu {host.State.Menu is not null}, popup {popup.Visible}";
+
+ host.PostClick(row, right: true);
+ host.Settle();
+ Console.WriteLine($"{opened}\n{reopened}\nthird: model menu {host.State.Menu is not null}, popup {popup.Visible}");
+ await Assert.That(popup.Visible).IsEqualTo(host.State.Menu is not null);
+ }
+
+ ///
+ /// Press on the scrollbar thumb, drag, hold, drag again, release half a second later: real mouse
+ /// messages, posted to the bar. The one DoEvents that dispatches the press does not return until
+ /// the release, because the bar tracks the thumb in a modal loop of its own - so the frames the
+ /// panes follow the thumb by have to come from inside it.
+ ///
+ /// A posted press alone is not enough: the bar looks at the button state and, finding it up,
+ /// ends the track at once. So this thread's own key state says the left button is down for the
+ /// length of the drag, which is what it says during a real one. Nothing outside this thread
+ /// sees that, and it is put back after.
+ ///
+ ///
+ [Test]
+ public async Task DraggingTheThumbStillRunsFrames()
+ {
+ var keys = new byte[256];
+ GetKeyboardState(keys);
+ var saved = (byte[]) keys.Clone();
+ keys[1] = 0x80;
+ SetKeyboardState(keys);
+ try
+ {
+ await DragTheThumb();
+ }
+ finally
+ {
+ SetKeyboardState(saved);
+ }
+ }
+
+ static async Task DragTheThumb()
+ {
+ using var host = new FormHost(Fixtures.File(Lines(400, 3), Lines(400)));
+ host.Settle();
+ var bar = Field(host.Form, "scrollBar");
+ var info = new ScrollBarInfo
+ {
+ Size = Marshal.SizeOf()
+ };
+ GetScrollBarInfo(bar.Handle, objectClient, ref info);
+ var x = bar.Width / 2;
+ var y = (info.ThumbTop + info.ThumbBottom) / 2;
+
+ var clock = Stopwatch.StartNew();
+ var scrolls = new List();
+ bar.Scroll += (_, e) => scrolls.Add($"{e.Type} {e.NewValue} at {clock.ElapsedMilliseconds}ms");
+ var filtered = 0;
+ HookProc filter = (code, wParam, lParam) =>
+ {
+ if (code == messageFilterScrollBar)
+ {
+ filtered++;
+ }
+
+ return CallNextHookEx(IntPtr.Zero, code, wParam, lParam);
+ };
+ var hook = SetWindowsHookEx(messageFilterHook, filter, IntPtr.Zero, GetCurrentThreadId());
+ var tops = new List();
+ host.Form.Frame = () =>
+ {
+ host.Apply(host.Form.Drain());
+ var screen = ScreenBuilder.Build(host.State);
+ tops.Add(screen.Left.ScrollTop);
+ return screen;
+ };
+ var handle = bar.Handle;
+ var done = false;
+ var release = new Thread(() =>
+ {
+ Thread.Sleep(500);
+ PostMessage(handle, mouseMove, leftButtonFlag, Point(x, y + 80));
+ Thread.Sleep(50);
+ PostMessage(handle, leftButtonUp, IntPtr.Zero, Point(x, y + 80));
+ // Only if the bar never let go, so a failure here cannot hang the run.
+ for (var wait = 0; wait < 60 && !Volatile.Read(ref done); wait++)
+ {
+ Thread.Sleep(50);
+ }
+
+ if (!Volatile.Read(ref done))
+ {
+ PostMessage(handle, leftButtonUp, IntPtr.Zero, Point(x, y + 80));
+ PostMessage(handle, cancelMode, IntPtr.Zero, IntPtr.Zero);
+ }
+ })
+ {
+ IsBackground = true
+ };
+
+ long pumped;
+ try
+ {
+ PostMessage(handle, leftButtonDown, leftButtonFlag, Point(x, y));
+ PostMessage(handle, mouseMove, leftButtonFlag, Point(x, y + 40));
+ release.Start();
+ var pump = Stopwatch.StartNew();
+ Application.DoEvents();
+ pumped = pump.ElapsedMilliseconds;
+ }
+ finally
+ {
+ Volatile.Write(ref done, true);
+ release.Join();
+ UnhookWindowsHookEx(hook);
+ GC.KeepAlive(filter);
+ }
+
+ Console.WriteLine(
+ $"one DoEvents took {pumped}ms; scroll bar's own loop filtered {filtered} messages; " +
+ $"Scroll events: {string.Join(", ", scrolls)}; frames during it: {tops.Count}, scroll tops {string.Join(" ", tops.Distinct())}");
+ await Assert.That(tops.Count).IsGreaterThan(5);
+ await Assert.That(tops.Max()).IsGreaterThan(0);
+ }
+
+ ///
+ /// A press in a pane, then the capture goes elsewhere, as it does when another window
+ /// takes the mouse, and the button comes up somewhere the canvas never hears about. Then the
+ /// pointer comes back over the pane with no button down.
+ ///
+ [Test]
+ public async Task LosingCaptureMidDragEndsTheDrag()
+ {
+ using var host = new CanvasHost();
+ host.Draw(ScreenBuilder.Build(ViewerSession.Resize(Fixtures.File(Lines(60, 3), Lines(60)), columns, rows)));
+ var canvas = host.Canvas;
+ var cell = canvas.CellSize();
+ var textLeft = canvas.Panes().Left + 8 * cell.Width;
+ var press = new Point(textLeft + 2 * cell.Width, canvas.BodyTop() + cell.Height / 2);
+ var later = new Point(textLeft + 20 * cell.Width, canvas.BodyTop() + 5 * cell.Height + cell.Height / 2);
+
+ SendMessage(canvas.Handle, leftButtonDown, leftButtonFlag, Point(press.X, press.Y));
+ var captured = canvas.Capture;
+
+ using var other = new Form
+ {
+ StartPosition = FormStartPosition.Manual,
+ Location = new(-4000, -2000),
+ ShowInTaskbar = false
+ };
+ other.Show();
+ other.Capture = true;
+ other.Capture = false;
+ var capturedAfter = canvas.Capture;
+
+ SendMessage(canvas.Handle, mouseMove, IntPtr.Zero, Point(later.X, later.Y));
+ var first = canvas.TakeDrag();
+ var second = canvas.TakeDrag();
+ Console.WriteLine(
+ $"captured on press {captured}, after the loss {capturedAfter}; selecting still {Field(canvas, "selecting")}; " +
+ $"a move with no button reported {first}; the next frame reported {second}");
+ await Assert.That(second).IsNull();
+ }
+
+ ///
+ /// the splitter: the same loss while dragging the rule between the queue and the panes.
+ ///
+ [Test]
+ public async Task LosingCaptureMidSplitterDragEndsTheDrag()
+ {
+ using var host = new CanvasHost();
+ host.Draw(ScreenBuilder.Build(ViewerSession.Resize(Fixtures.Inline(Fixtures.Patch()), columns, rows)));
+ var canvas = host.Canvas;
+ var splitter = canvas.Panes().Left - 2;
+ var y = canvas.BodyTop() + 40;
+
+ SendMessage(canvas.Handle, leftButtonDown, leftButtonFlag, Point(splitter, y));
+ using var other = new Form
+ {
+ StartPosition = FormStartPosition.Manual,
+ Location = new(-4000, -2000),
+ ShowInTaskbar = false
+ };
+ other.Show();
+ other.Capture = true;
+ other.Capture = false;
+
+ var before = canvas.Panes().Left;
+ SendMessage(canvas.Handle, mouseMove, IntPtr.Zero, Point(splitter + 200, y));
+ var after = canvas.Panes().Left;
+ Console.WriteLine($"dragging still {Field(canvas, "dragging")}; queue column edge {before} -> {after} on a move with no button");
+ await Assert.That(after).IsEqualTo(before);
+ }
+
+ ///
+ /// The real loop, ViewerProgram.Run, over a real hidden window, sent the two
+ /// messages a logoff sends, from another thread as the system sends them. What matters is what
+ /// had happened by the time WM_ENDSESSION returned, since the session may end from then on.
+ /// is the first thing Run's finally sets, before it persists.
+ ///
+ [Test]
+ public async Task EndSessionReturnsBeforeTheLoopHasStartedToPersist()
+ {
+ var host = new SessionHost(Fixtures.File());
+ var handle = IntPtr.Zero;
+ ViewerForm? form = null;
+ OpenWindow open = (string title, int width, int height, bool hidden, out string? error) =>
+ {
+ var window = FormsViewerWindow.Open(title, width, height, true, out error);
+ form = Field(window!, "form");
+ handle = form.Handle;
+ return window;
+ };
+
+ var clock = Stopwatch.StartNew();
+ var query = IntPtr.Zero;
+ var end = IntPtr.Zero;
+ long endReturnedAt = 0;
+ var disposedAtReturn = false;
+ var closingAtReturn = true;
+ var sender = new Thread(() =>
+ {
+ while (handle == IntPtr.Zero)
+ {
+ Thread.Sleep(10);
+ }
+
+ Thread.Sleep(200);
+ query = SendMessage(handle, queryEndSession, IntPtr.Zero, endSessionLogoff);
+ end = SendMessage(handle, endSession, new(1), endSessionLogoff);
+ endReturnedAt = clock.ElapsedMilliseconds;
+ closingAtReturn = host.State.Closing;
+ disposedAtReturn = form!.IsDisposed;
+ })
+ {
+ IsBackground = true
+ };
+ sender.Start();
+
+ var code = ViewerProgram.Run(host, null, null, open);
+ var runReturnedAt = clock.ElapsedMilliseconds;
+ sender.Join();
+
+ Console.WriteLine(
+ $"WM_QUERYENDSESSION answered {query}; WM_ENDSESSION returned {end} at {endReturnedAt}ms with the form disposed " +
+ $"{disposedAtReturn} and Run's finally started {closingAtReturn}; Run returned {code} at {runReturnedAt}ms");
+ await Assert.That(query).IsEqualTo(new IntPtr(1));
+ await Assert.That(closingAtReturn).IsTrue();
+ }
+
+ static string Lines(int count, int changedAt = -1)
+ {
+ var builder = new StringBuilder();
+ for (var index = 1; index <= count; index++)
+ {
+ if (index > 1)
+ {
+ builder.Append('\n');
+ }
+
+ builder.Append($"line {index}");
+ if (index == changedAt)
+ {
+ builder.Append(" changed");
+ }
+ }
+
+ return builder.ToString();
+ }
+
+ static string TempDirectory(string name)
+ {
+ var path = Path.Combine(Path.GetTempPath(), name);
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ static T Field(object target, string name) =>
+ (T) target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(target)!;
+
+ static (int Cell, float Advance) Measure(Font font, int dpi)
+ {
+ if (dpi == 96)
+ {
+ // The host is DPI unaware, so a screen Graphics here is exactly 96.
+ using var screen = Graphics.FromHwnd(IntPtr.Zero);
+ return Measure(screen, font);
+ }
+
+ // Per monitor on this thread only, for long enough to get a screen Graphics at the real
+ // system DPI. Other scales than the machine's come from a bitmap at that resolution.
+ var previous = SetThreadDpiAwarenessContext(perMonitorV2);
+ try
+ {
+ using var screen = Graphics.FromHwnd(IntPtr.Zero);
+ if ((int) screen.DpiX == dpi)
+ {
+ return Measure(screen, font);
+ }
+ }
+ finally
+ {
+ SetThreadDpiAwarenessContext(previous);
+ }
+
+ using var bitmap = new Bitmap(1, 1);
+ bitmap.SetResolution(dpi, dpi);
+ using var graphics = Graphics.FromImage(bitmap);
+ return Measure(graphics, font);
+ }
+
+ static (int Cell, float Advance) Measure(Graphics graphics, Font font) =>
+ (MonoFont.Cell(graphics, font).Width, MonoFont.Advance(graphics, font));
+
+ ///
+ /// The horizontal centre of each of bars drawn as one string.
+ ///
+ static double[] DrawnBars(Font font, int dpi, int count)
+ {
+ using var bitmap = new Bitmap(2600, 80);
+ bitmap.SetResolution(dpi, dpi);
+ using (var graphics = Graphics.FromImage(bitmap))
+ {
+ graphics.Clear(Color.White);
+ Painter.Prepare(graphics);
+ Painter.Draw(graphics, new('|', count), font, Color.Black, new(10, 10, 2580, 60));
+ }
+
+ var profile = new double[bitmap.Width];
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ for (var y = 0; y < bitmap.Height; y++)
+ {
+ profile[x] += 255 - bitmap.GetPixel(x, y).R;
+ }
+ }
+
+ var centres = new List();
+ var start = -1;
+ for (var x = 0; x <= profile.Length; x++)
+ {
+ var inked = x < profile.Length && profile[x] > 0;
+ if (inked && start < 0)
+ {
+ start = x;
+ }
+ else if (!inked && start >= 0)
+ {
+ double weight = 0;
+ double moment = 0;
+ for (var column = start; column < x; column++)
+ {
+ weight += profile[column];
+ moment += profile[column] * column;
+ }
+
+ centres.Add(moment / weight);
+ start = -1;
+ }
+ }
+
+ if (centres.Count != count)
+ {
+ throw new($"Expected {count} bars at {dpi} dpi, found {centres.Count}");
+ }
+
+ return centres.ToArray();
+ }
+
+ static Rectangle? Bounds(Bitmap bitmap, Func match)
+ {
+ int left = int.MaxValue, top = int.MaxValue, right = -1, bottom = -1;
+ for (var y = 0; y < bitmap.Height; y++)
+ {
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ if (!match(bitmap.GetPixel(x, y)))
+ {
+ continue;
+ }
+
+ left = Math.Min(left, x);
+ top = Math.Min(top, y);
+ right = Math.Max(right, x);
+ bottom = Math.Max(bottom, y);
+ }
+ }
+
+ if (right < 0)
+ {
+ return null;
+ }
+
+ return Rectangle.FromLTRB(left, top, right + 1, bottom + 1);
+ }
+
+ static IntPtr Point(int x, int y) =>
+ new((y << 16) | (x & 0xFFFF));
+
+ static readonly IntPtr perMonitorV2 = new(-4);
+ const int keyDown = 0x0100;
+ const int keyUp = 0x0101;
+ const int mouseMove = 0x0200;
+ const int leftButtonDown = 0x0201;
+ const int leftButtonUp = 0x0202;
+ const int rightButtonDown = 0x0204;
+ const int rightButtonUp = 0x0205;
+ const int cancelMode = 0x001F;
+ const int queryEndSession = 0x0011;
+ const int endSession = 0x0016;
+ static readonly IntPtr endSessionLogoff = new(unchecked((int) 0x80000000));
+ static readonly IntPtr leftButtonFlag = new(1);
+ static readonly IntPtr rightButtonFlag = new(2);
+ const int messageFilterHook = -1;
+ const int messageFilterScrollBar = 5;
+ const int objectClient = unchecked((int) 0xFFFFFFFC);
+
+ delegate IntPtr HookProc(int code, IntPtr wParam, IntPtr lParam);
+
+ [StructLayout(LayoutKind.Sequential)]
+ struct ScrollBarInfo
+ {
+ public int Size;
+ public int BarLeft;
+ public int BarTop;
+ public int BarRight;
+ public int BarBottom;
+ public int LineButton;
+ public int ThumbTop;
+ public int ThumbBottom;
+ public int Reserved;
+ public int State0;
+ public int State1;
+ public int State2;
+ public int State3;
+ public int State4;
+ public int State5;
+ }
+
+ [DllImport("user32.dll")]
+ static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
+
+ const int verticalScrollBarWidth = 2;
+
+ [DllImport("user32.dll")]
+ static extern int GetSystemMetricsForDpi(int index, uint dpi);
+
+ [DllImport("user32.dll")]
+ static extern bool GetKeyboardState(byte[] state);
+
+ [DllImport("user32.dll")]
+ static extern bool SetKeyboardState(byte[] state);
+
+ [DllImport("user32.dll")]
+ static extern bool PostMessage(IntPtr window, int message, IntPtr wParam, IntPtr lParam);
+
+ [DllImport("user32.dll")]
+ static extern IntPtr SendMessage(IntPtr window, int message, IntPtr wParam, IntPtr lParam);
+
+ [DllImport("user32.dll")]
+ static extern IntPtr SetWindowsHookEx(int hook, HookProc procedure, IntPtr module, uint thread);
+
+ [DllImport("user32.dll")]
+ static extern bool UnhookWindowsHookEx(IntPtr hook);
+
+ [DllImport("user32.dll")]
+ static extern IntPtr CallNextHookEx(IntPtr hook, int code, IntPtr wParam, IntPtr lParam);
+
+ [DllImport("kernel32.dll")]
+ static extern uint GetCurrentThreadId();
+
+ [DllImport("user32.dll")]
+ static extern bool GetScrollBarInfo(IntPtr window, int objectId, ref ScrollBarInfo info);
+
+ ///
+ /// A canvas in a real window, parked off screen and out of the taskbar, as PaneHitTests hosts
+ /// one.
+ ///
+ sealed class CanvasHost : IDisposable
+ {
+ readonly Form form = new()
+ {
+ StartPosition = FormStartPosition.Manual,
+ Location = new(-4000, -2000),
+ ShowInTaskbar = false
+ };
+
+ readonly List bitmaps = [];
+
+ public ViewerCanvas Canvas { get; } = new()
+ {
+ Dock = DockStyle.Fill
+ };
+
+ public CanvasHost(int width = 1100, int height = 700)
+ {
+ form.ClientSize = new(width, height);
+ form.Controls.Add(Canvas);
+ form.Show();
+ }
+
+ public void Resize(int width, int height) =>
+ form.ClientSize = new(width, height);
+
+ public Bitmap Draw(Screen screen)
+ {
+ Canvas.Draw(screen);
+ Canvas.Refresh();
+ var bitmap = new Bitmap(Canvas.Width, Canvas.Height);
+ Canvas.DrawToBitmap(bitmap, new(0, 0, Canvas.Width, Canvas.Height));
+ bitmaps.Add(bitmap);
+ return bitmap;
+ }
+
+ public void Dispose()
+ {
+ foreach (var bitmap in bitmaps)
+ {
+ bitmap.Dispose();
+ }
+
+ form.Dispose();
+ }
+ }
+
+ ///
+ /// A real driven one frame at a time the way ViewerProgram.Loop drives
+ /// it, minus the session lock: Apply the screen, DoEvents, Drain, and hand the input to the
+ /// same ViewerProgram.Apply the loop calls.
+ ///
+ sealed class FormHost : IDisposable
+ {
+ readonly IViewerWindow window = new NoWindow();
+
+ public FormHost(SessionState state)
+ {
+ State = state;
+ Form = new("DiffEngineViewer", 1100, 700)
+ {
+ StartPosition = FormStartPosition.Manual,
+ Location = new(-4000, -2000),
+ ShowInTaskbar = false
+ };
+ Form.Show();
+ Canvas = Field(Form, "canvas");
+ }
+
+ public ViewerForm Form { get; }
+
+ public ViewerCanvas Canvas { get; }
+
+ public SessionState State { get; private set; }
+
+ public ViewerInput Frame()
+ {
+ Form.Apply(ScreenBuilder.Build(State));
+ Application.DoEvents();
+ var input = Form.Drain();
+ Apply(input);
+ return input;
+ }
+
+ public void Apply(ViewerInput input)
+ {
+ if (!ViewerProgram.IsIdle(input, State))
+ {
+ State = ViewerProgram.Apply(State, input, null, window);
+ }
+ }
+
+ ///
+ /// Until the model holds the grid the canvas reports.
+ ///
+ public void Settle()
+ {
+ for (var index = 0; index < 3; index++)
+ {
+ Frame();
+ }
+ }
+
+ public int QueueRowOf(string key)
+ {
+ var entry = -1;
+ for (var index = 0; index < State.Queue.Count; index++)
+ {
+ if (State.Queue[index].Key == key)
+ {
+ entry = index;
+ }
+ }
+
+ var items = ScreenBuilder.Build(State).Queue;
+ for (var row = 0; row < items.Count; row++)
+ {
+ if (items[row].EntryIndex == entry)
+ {
+ return row;
+ }
+ }
+
+ throw new($"No queue row for {key}");
+ }
+
+ public void PostKey(Keys key)
+ {
+ PostMessage(Canvas.Handle, keyDown, new((int) key), new(1));
+ PostMessage(Canvas.Handle, keyUp, new((int) key), new(unchecked((int) 0xC0000001)));
+ }
+
+ public void PostClick(int queueRow, bool right)
+ {
+ var cell = Canvas.CellSize();
+ var at = Point(6 + cell.Width * 2, Canvas.BodyTop() + queueRow * cell.Height + cell.Height / 2);
+ if (right)
+ {
+ PostMessage(Canvas.Handle, rightButtonDown, rightButtonFlag, at);
+ PostMessage(Canvas.Handle, rightButtonUp, IntPtr.Zero, at);
+ return;
+ }
+
+ PostMessage(Canvas.Handle, leftButtonDown, leftButtonFlag, at);
+ PostMessage(Canvas.Handle, leftButtonUp, IntPtr.Zero, at);
+ }
+
+ public void Dispose() =>
+ Form.Dispose();
+ }
+
+ sealed class NoWindow : IViewerWindow
+ {
+ public bool Present(Screen screen) => true;
+
+ public ViewerInput Poll() => default;
+
+ public void SetHidden(bool hidden)
+ {
+ }
+
+ public void SetClipboard(string text)
+ {
+ }
+
+ public void Focus()
+ {
+ }
+
+ public bool Capture(Screen screen, int width, int height, string pngPath) => false;
+
+ public void Dispose()
+ {
+ }
+ }
+}
+
+static class CanvasReflection
+{
+ const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic;
+
+ public static Size CellSize(this ViewerCanvas canvas) =>
+ (Size) typeof(ViewerCanvas).GetProperty("Cell", flags)!.GetValue(canvas)!;
+
+ public static int BodyTop(this ViewerCanvas canvas) =>
+ (int) typeof(ViewerCanvas).GetProperty("BodyTop", flags)!.GetValue(canvas)!;
+
+ public static (int Left, int Half, int Width) Panes(this ViewerCanvas canvas) =>
+ ((int, int, int)) typeof(ViewerCanvas).GetMethod("Panes", flags)!.Invoke(canvas, null)!;
+
+ public static (int Count, long Bytes) CachedImages(this ViewerCanvas canvas)
+ {
+ var cache = typeof(ViewerCanvas).GetField("images", flags)!.GetValue(canvas)!;
+ var entries = (IDictionary) typeof(ImageCache).GetField("entries", flags)!.GetValue(cache)!;
+ long bytes = 0;
+ foreach (var entry in entries.Values)
+ {
+ if (entry.GetType().GetProperty("Image")!.GetValue(entry) is Image image)
+ {
+ bytes += (long) image.Width * image.Height * Image.GetPixelFormatSize(image.PixelFormat) / 8;
+ }
+ }
+
+ return (entries.Count, bytes);
+ }
+}
diff --git a/src/DiffEngineViewer.Windows.Tests/ImageCacheTests.cs b/src/DiffEngineViewer.Windows.Tests/ImageCacheTests.cs
index 3368735a..843177f6 100644
--- a/src/DiffEngineViewer.Windows.Tests/ImageCacheTests.cs
+++ b/src/DiffEngineViewer.Windows.Tests/ImageCacheTests.cs
@@ -14,11 +14,11 @@ public async Task DecodesOnceAndKeepsIt()
var path = Write("decoded.png", SamplePng.Build(8, 6, 200, 40, 40));
using var cache = new ImageCache();
- var first = cache.Get(path);
+ var first = cache.Get(path, null);
await Assert.That(first).IsNotNull();
await Assert.That(first!.Width).IsEqualTo(8);
await Assert.That(first.Height).IsEqualTo(6);
- await Assert.That(ReferenceEquals(cache.Get(path), first)).IsTrue();
+ await Assert.That(ReferenceEquals(cache.Get(path, null), first)).IsTrue();
}
///
@@ -31,7 +31,7 @@ public async Task LeavesNoHandleOnTheFile()
{
var path = Write("copied-over.png", SamplePng.Build(8, 6, 200, 40, 40));
using var cache = new ImageCache();
- await Assert.That(cache.Get(path)).IsNotNull();
+ await Assert.That(cache.Get(path, null)).IsNotNull();
var replacement = Write("replacement.png", SamplePng.Build(4, 4, 40, 200, 40));
File.Copy(replacement, path, true);
@@ -46,12 +46,12 @@ public async Task RedecodesWhenTheFileChanges()
{
var path = Write("rewritten.png", SamplePng.Build(8, 6, 200, 40, 40));
using var cache = new ImageCache();
- await Assert.That(cache.Get(path)!.Width).IsEqualTo(8);
+ await Assert.That(cache.Get(path, null)!.Width).IsEqualTo(8);
// A different size, so the change is visible whatever the file system's timestamp
// resolution turns out to be.
await File.WriteAllBytesAsync(path, SamplePng.Build(4, 4, 40, 200, 40));
- await Assert.That(cache.Get(path)!.Width).IsEqualTo(4);
+ await Assert.That(cache.Get(path, null)!.Width).IsEqualTo(4);
}
///
@@ -64,15 +64,15 @@ public async Task RemembersAFailure()
var path = Write("notreally.png", "the quick brown fox"u8.ToArray());
using var cache = new ImageCache();
- await Assert.That(cache.Get(path)).IsNull();
- await Assert.That(cache.Get(path)).IsNull();
+ await Assert.That(cache.Get(path, null)).IsNull();
+ await Assert.That(cache.Get(path, null)).IsNull();
}
[Test]
public async Task MissingFile()
{
using var cache = new ImageCache();
- await Assert.That(cache.Get(Path.Combine(Directory(), "gone.png"))).IsNull();
+ await Assert.That(cache.Get(Path.Combine(Directory(), "gone.png"), null)).IsNull();
}
static string Write(string name, byte[] content)
@@ -88,4 +88,41 @@ static string Directory()
System.IO.Directory.CreateDirectory(path);
return path;
}
+
+ ///
+ /// A picture rewritten with different pixels at the same length and
+ /// the same write time, which is what a rewrite inside the file system's timestamp granularity
+ /// looks like to a stat. The model's hash sees it.
+ ///
+ [Test]
+ public async Task ARewriteWithTheSameStampKeepsTheOldPicture()
+ {
+ var path = Path.Combine(Directory(), "Same.received.png");
+ File.WriteAllBytes(path, SamplePng.Build(8, 6, 200, 40, 40));
+ var stamp = File.GetLastWriteTimeUtc(path);
+ using var cache = new ImageCache();
+ var before = ((Bitmap) cache.Get(path, FileSide.Read(path).Image!.Value.Hash)!).GetPixel(0, 0);
+ var hashBefore = FileSide.Read(path).Image!.Value.Hash;
+
+ File.WriteAllBytes(path, SamplePng.Build(8, 6, 40, 200, 40));
+ File.SetLastWriteTimeUtc(path, stamp);
+ var hashAfter = FileSide.Read(path).Image!.Value.Hash;
+ var after = ((Bitmap) cache.Get(path, hashAfter)!).GetPixel(0, 0);
+
+ // How often two writes in a row land on one stamp here, for how reachable that is.
+ var ticks = new List();
+ for (var index = 0; index < 200; index++)
+ {
+ File.WriteAllBytes(path, [(byte) index]);
+ ticks.Add(File.GetLastWriteTimeUtc(path).Ticks);
+ }
+
+ var repeats = ticks.Zip(ticks.Skip(1)).Count(_ => _.First == _.Second);
+ var smallest = ticks.Zip(ticks.Skip(1)).Select(_ => _.Second - _.First).Where(_ => _ > 0).DefaultIfEmpty(0).Min();
+ File.Delete(path);
+ Console.WriteLine(
+ $"hash changed {hashBefore != hashAfter}; pixel before {before}, after {after}; " +
+ $"back to back writes on this volume: {repeats} of 199 kept the stamp, smallest step {smallest / 10}us");
+ await Assert.That(after.ToArgb()).IsNotEqualTo(before.ToArgb());
+ }
}
diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.FileDiff.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.FileDiff.verified.png
index b6eb6961..f04d02b8 100644
Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.FileDiff.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.FileDiff.verified.png differ
diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.GroupedConflictedQueue.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.GroupedConflictedQueue.verified.png
index 7b33bff7..c7719a24 100644
Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.GroupedConflictedQueue.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.GroupedConflictedQueue.verified.png differ
diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Images.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Images.verified.png
index 6b9372bd..8519d055 100644
Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Images.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Images.verified.png differ
diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineAccepted.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineAccepted.verified.png
index d3368755..488850f9 100644
Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineAccepted.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineAccepted.verified.png differ
diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineQueue.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineQueue.verified.png
index 15809042..a9aaff11 100644
Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineQueue.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineQueue.verified.png differ
diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineSingle.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineSingle.verified.png
index 6d36ebc7..8bf1cdac 100644
Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineSingle.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineSingle.verified.png differ
diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.LongPane.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.LongPane.verified.png
index a6d8e8e9..beb44bd1 100644
Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.LongPane.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.LongPane.verified.png differ
diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.LongQueueLabel.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.LongQueueLabel.verified.png
index 5d650047..a2fdd29b 100644
Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.LongQueueLabel.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.LongQueueLabel.verified.png differ
diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Minimal.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Minimal.verified.png
index 5c6ee08c..1f4642f2 100644
Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Minimal.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Minimal.verified.png differ
diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Selection.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Selection.verified.png
index 10fe34ac..b7417f40 100644
Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Selection.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Selection.verified.png differ
diff --git a/src/DiffEngineViewer.Windows/FormsViewerWindow.cs b/src/DiffEngineViewer.Windows/FormsViewerWindow.cs
index cb32a5c0..cfefab9b 100644
--- a/src/DiffEngineViewer.Windows/FormsViewerWindow.cs
+++ b/src/DiffEngineViewer.Windows/FormsViewerWindow.cs
@@ -5,11 +5,15 @@
/// Pumped rather than inverted onto Application.Run. ViewerProgram owns the loop for all
/// three heads, and keeping it that way means the scroll amplification, the button lookup and the
/// close-means-hide rule stay in one place. DoEvents is usually a smell, but the conditions
-/// that make it one are absent here: no modal dialogs, no nested message loops, and session state
-/// already behind its own lock.
+/// that make it one are absent here: no modal dialogs, and session state already behind its own
+/// lock. user32's own modal loops - a scroll bar thumb being dragged, the window being moved or
+/// sized - do hold the thread inside DoEvents, and is how frames keep
+/// coming while they do.
///
///
-sealed class FormsViewerWindow : IViewerWindow
+sealed class FormsViewerWindow :
+ IViewerWindow,
+ ILoopHooks
{
///
/// Roughly sixty frames a second, which is what the shim's SetTargetFPS gives the other heads.
@@ -75,6 +79,13 @@ public bool Present(Screen screen)
///
void Wait()
{
+ // 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)
+ {
+ return;
+ }
+
var timeout = form.Visible ? frameMilliseconds - 1 : hiddenMilliseconds;
MsgWaitForMultipleObjectsEx(0, IntPtr.Zero, (uint) timeout, allInput, inputAvailable);
}
@@ -89,6 +100,16 @@ void Wait()
public ViewerInput Poll() =>
form.IsDisposed ? default : form.Drain();
+ public Func? Frame
+ {
+ set => form.Frame = value;
+ }
+
+ public Action? SessionEnding
+ {
+ set => form.SessionEnding = value;
+ }
+
///
/// Visibility only. Assigning ShowInTaskbar recreates the window handle, and doing that under
/// a loop that is pumping with DoEvents means tearing the handle out from under an in flight
diff --git a/src/DiffEngineViewer.Windows/ImageCache.cs b/src/DiffEngineViewer.Windows/ImageCache.cs
index 62533991..7fd2324b 100644
--- a/src/DiffEngineViewer.Windows/ImageCache.cs
+++ b/src/DiffEngineViewer.Windows/ImageCache.cs
@@ -1,7 +1,14 @@
///
/// Decoded pictures for the panes, keyed by the path the screen model handed over and invalidated
/// by the file's write time and length — the same freshness test the queue poller uses, so a re-run
-/// that rewrites a received image refreshes the pane rather than leaving the old one up.
+/// that rewrites a received image refreshes the pane rather than leaving the old one up — and by
+/// the content hash the model carries, since a same-length rewrite inside the file system's
+/// timestamp granularity looks unchanged to a stat.
+///
+/// Only what the current screen shows is kept (). Every picture ever drawn stayed
+/// decoded otherwise, for the life of a process the tray can keep hidden for days: ten accepted
+/// 400 by 300 pairs held 9 MB of unmanaged memory the collector does not see.
+///
///
/// A cache and not a convenience: OnPaint runs on every wheel notch and every resize, and
/// decoding a picture per frame is what turns a window that is merely showing something into one
@@ -16,9 +23,20 @@ sealed class ImageCache : IDisposable
/// A null is a remembered failure. Kept rather than dropped, so a file
/// this machine cannot decode is attempted once instead of once per frame.
///
- record Entry(long WriteTicksUtc, long Length, Image? Image);
+ record Entry(long WriteTicksUtc, long Length, string? Hash, Image? Image);
+
+ ///
+ /// Drops every picture not at one of , which is what is on screen.
+ ///
+ public void Keep(IReadOnlyCollection paths)
+ {
+ foreach (var path in entries.Keys.Where(_ => !paths.Contains(_, StringComparer.OrdinalIgnoreCase)).ToList())
+ {
+ Forget(path);
+ }
+ }
- public Image? Get(string path)
+ public Image? Get(string path, string? hash)
{
long ticks;
long length;
@@ -45,7 +63,8 @@ record Entry(long WriteTicksUtc, long Length, Image? Image);
if (entries.TryGetValue(path, out var entry))
{
if (entry.WriteTicksUtc == ticks &&
- entry.Length == length)
+ entry.Length == length &&
+ entry.Hash == hash)
{
return entry.Image;
}
@@ -54,7 +73,7 @@ record Entry(long WriteTicksUtc, long Length, Image? Image);
}
var image = Load(path);
- entries.Add(path, new(ticks, length, image));
+ entries.Add(path, new(ticks, length, hash, image));
return image;
}
@@ -65,7 +84,7 @@ record Entry(long WriteTicksUtc, long Length, Image? Image);
// Decoded from a copy of the bytes and then copied again. GDI+ holds on to the stream
// it was handed for as long as the image lives, and a viewer keeping a handle on the
// received file is one that blocks the accept it exists to perform.
- using var stream = new MemoryStream(File.ReadAllBytes(path));
+ using var stream = new MemoryStream(FileSide.ReadBytes(path));
using var decoded = new Bitmap(stream);
return new Bitmap(decoded);
}
diff --git a/src/DiffEngineViewer.Windows/MonoFont.cs b/src/DiffEngineViewer.Windows/MonoFont.cs
index 736d9692..654788bd 100644
--- a/src/DiffEngineViewer.Windows/MonoFont.cs
+++ b/src/DiffEngineViewer.Windows/MonoFont.cs
@@ -29,6 +29,17 @@ public static Size Cell(Graphics graphics, Font font)
Math.Max(1, (int) Math.Ceiling(font.GetHeight(graphics))));
}
+ ///
+ /// Where one glyph starts after the last, unrounded. is whole pixels, which
+ /// is what the grid is laid out in, but Graphics.DrawString places glyphs at this advance. At
+ /// 96 DPI that is 8.8 against a cell of 9, so anything positioned by the cell - the selection
+ /// highlight, and the column a click lands in - was a character off from about column 22 and
+ /// two by column 66, and at 175% more than three. Measured over a run rather than one glyph,
+ /// for the precision a long line needs.
+ ///
+ public static float Advance(Graphics graphics, Font font) =>
+ graphics.MeasureString(new('M', 100), font, PointF.Empty, Painter.Format).Width / 100;
+
static FontFamily Register()
{
var bytes = EmbeddedFont.Bytes();
diff --git a/src/DiffEngineViewer.Windows/ViewerCanvas.cs b/src/DiffEngineViewer.Windows/ViewerCanvas.cs
index 25582c5a..e4a4d307 100644
--- a/src/DiffEngineViewer.Windows/ViewerCanvas.cs
+++ b/src/DiffEngineViewer.Windows/ViewerCanvas.cs
@@ -146,11 +146,28 @@ public ViewerCanvas()
public void Draw(Screen value)
{
screen = value;
+ images.Keep(PicturesOn(value));
// A new screen renumbers the rows, so a kept index would describe a different entry.
tips.Forget(this);
Invalidate();
}
+ static List PicturesOn(Screen screen)
+ {
+ var paths = new List(2);
+ if (screen.Left.Image is { } left)
+ {
+ paths.Add(left.Path);
+ }
+
+ if (screen.Right.Image is { } right)
+ {
+ paths.Add(right.Path);
+ }
+
+ return paths;
+ }
+
Size Cell
{
get
@@ -159,12 +176,34 @@ Size Cell
{
using var graphics = CreateGraphics();
cell = MonoFont.Cell(graphics, font);
+ advance = MonoFont.Advance(graphics, font);
}
return cell;
}
}
+ ///
+ /// Where glyphs actually land within a line, for anything placed under or against them. The
+ /// cell stays whole pixels for laying out the grid. See .
+ ///
+ float Advance
+ {
+ get
+ {
+ _ = Cell;
+ return advance;
+ }
+ }
+
+ float advance;
+
+ ///
+ /// The pixel offset of a column into a line of text.
+ ///
+ int Offset(int column) =>
+ (int) Math.Round(column * Advance);
+
///
/// Everything drawn here is laid out in character cells, and a cell is measured in pixels from
/// a Graphics, which is per display. Dragging the window to a display with different scaling
@@ -271,7 +310,7 @@ int ScrollTop(PaneSide side) =>
/// pointing at is the one they mean.
///
int ColumnAt(int x, PaneSide side) =>
- Math.Max(0, (x - TextLeft(side) + Cell.Width / 2) / Cell.Width);
+ Math.Max(0, (int) Math.Floor((x - TextLeft(side)) / Advance + 0.5f));
///
/// The body row a point is on, clamped into the body. Used while dragging, where a pointer
@@ -348,7 +387,7 @@ void DrawImage(Graphics graphics, Pane pane, int left, int width, int bodyTop, i
return;
}
- var picture = images.Get(image.Path);
+ var picture = images.Get(image.Path, image.Hash);
if (picture is null)
{
return;
@@ -427,7 +466,7 @@ void DrawTitle(Graphics graphics, int lineHeight)
return;
}
- var width = screen.Subtitle.Length * Cell.Width;
+ var width = Offset(screen.Subtitle.Length);
Painter.Draw(graphics, screen.Subtitle, font, Palette.Dim, Cellular(Width - padding - width, padding, width, lineHeight));
}
@@ -487,9 +526,9 @@ void DrawRow(Graphics graphics, Pane pane, int index, Rectangle bounds)
Painter.Brush(Palette.Selection),
Rectangle.Intersect(
new(
- bounds.X + gutter + row.Selection.Start * Cell.Width,
+ bounds.X + gutter + Offset(row.Selection.Start),
bounds.Y,
- row.Selection.Length * Cell.Width,
+ Offset(row.Selection.Start + row.Selection.Length) - Offset(row.Selection.Start),
bounds.Height),
bounds));
}
@@ -595,6 +634,14 @@ int QueueRowAt(Point point)
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
+ // A move with the button up is a drag whose release went somewhere else - the button let go
+ // over another window after an Alt+Tab, say - and not one still going.
+ if ((selecting || dragging) &&
+ (e.Button & MouseButtons.Left) == 0)
+ {
+ EndDrag();
+ }
+
if (selecting)
{
// Against the side the press landed in, whatever the pointer has wandered over since:
@@ -649,6 +696,26 @@ void ApplyTooltip(Point point)
protected override void OnMouseUp(MouseEventArgs e)
{
base.OnMouseUp(e);
+ EndDrag();
+ }
+
+ ///
+ /// The mouse was taken away mid drag: Alt+Tab, the Windows key, a UAC prompt, another window
+ /// grabbing it. Only the window holding capture hears the button come up, so without this the
+ /// selection followed the pointer with no button held, and the splitter dragged the queue
+ /// column along, until the next click happened to land here.
+ ///
+ protected override void OnMouseCaptureChanged(EventArgs e)
+ {
+ base.OnMouseCaptureChanged(e);
+ if (!Capture)
+ {
+ EndDrag();
+ }
+ }
+
+ void EndDrag()
+ {
if (dragging)
{
dragging = false;
diff --git a/src/DiffEngineViewer.Windows/ViewerForm.cs b/src/DiffEngineViewer.Windows/ViewerForm.cs
index 998ac9e7..75343a01 100644
--- a/src/DiffEngineViewer.Windows/ViewerForm.cs
+++ b/src/DiffEngineViewer.Windows/ViewerForm.cs
@@ -91,12 +91,35 @@ sealed class ViewerForm : Form
Point? menuPoint;
Screen? last;
- CommandKind key;
- int clickedButton = -1;
- int clickedQueueItem = -1;
- int rightClickedQueueItem = -1;
- int clickedMenuItem = -1;
- bool menuClosed;
+
+ ///
+ /// Keys, clicks and menu events, in the order they happened, one handed over per frame.
+ ///
+ /// A slot per kind used to hold them: two presses of Down between frames scrolled once, and a
+ /// key then a click were applied click first. That is not an edge case when a frame is slow - the
+ /// loop waiting behind an accept on InlineApplier's mutex - and d pressed on the entry being
+ /// read and a click on another row then discarded the clicked one, which the reader had never
+ /// looked at. With a, it would have been accepted into source.
+ ///
+ ///
+ readonly Queue discrete = new();
+
+ readonly record struct Discrete(
+ CommandKind Key = CommandKind.None,
+ int Button = -1,
+ int QueueItem = -1,
+ int RightClickedQueueItem = -1,
+ int MenuItem = -1,
+ bool MenuClosed = false);
+
+ static readonly Discrete nothing = new(Key: CommandKind.None);
+
+ ///
+ /// 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;
+
int scrollTo = -1;
int scrollDelta;
bool closeRequested;
@@ -131,12 +154,34 @@ public ViewerForm(string title, int width, int height)
// Scroll rather than ValueChanged, which also fires for this class's own model driven
// assignment and would turn every wheel notch into a round trip fighting the clamp.
- scrollBar.Scroll += (_, e) => scrollTo = e.NewValue;
+ scrollBar.Scroll += (_, e) =>
+ {
+ scrollTo = e.NewValue;
+ // The thumb is tracked in the scroll bar's own modal loop, from the first ThumbTrack
+ // until the release
+ if (e.Type == ScrollEventType.ThumbTrack)
+ {
+ EnterModal();
+ return;
+ }
- canvas.QueueItemClicked += _ => clickedQueueItem = _;
+ if (e.Type is ScrollEventType.ThumbPosition or ScrollEventType.EndScroll)
+ {
+ ExitModal();
+ }
+ };
+ modalFrames.Tick += (_, _) =>
+ {
+ if (Frame is { } frame)
+ {
+ Apply(frame());
+ }
+ };
+
+ canvas.QueueItemClicked += _ => discrete.Enqueue(new(QueueItem: _));
canvas.QueueItemRightClicked += (row, point) =>
{
- rightClickedQueueItem = row;
+ discrete.Enqueue(new(RightClickedQueueItem: row));
menuPoint = point;
};
canvas.Scrolled += _ => scrollDelta += _;
@@ -151,7 +196,7 @@ public ViewerForm(string title, int width, int height)
// this is the only thing that brings them back.
if (e.CloseReason != ToolStripDropDownCloseReason.ItemClicked)
{
- menuClosed = true;
+ discrete.Enqueue(new(MenuClosed: true));
}
};
}
@@ -163,15 +208,110 @@ public ViewerForm(string title, int width, int height)
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
+ if (!sized)
+ {
+ sized = true;
+ ClientSize = InitialClientSize(ClientSize, DeviceDpi, System.Windows.Forms.Screen.FromControl(this).WorkingArea.Size);
+ }
+
ScaleChrome();
}
+ bool sized;
+
+ ///
+ /// 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. 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));
+ }
+
protected override void OnDpiChanged(DpiChangedEventArgs e)
{
base.OnDpiChanged(e);
ScaleChrome();
}
+ ///
+ /// 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 .
+ ///
+ [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()
+ {
+ if (Frame is not null &&
+ !modalFrames.Enabled)
+ {
+ modalFrames.Start();
+ }
+ }
+
+ void ExitModal() =>
+ modalFrames.Stop();
+
+ const int enterSizeMove = 0x0231;
+ const int exitSizeMove = 0x0232;
+
+ protected override void WndProc(ref Message message)
+ {
+ if (message.Msg == enterSizeMove)
+ {
+ EnterModal();
+ }
+ else if (message.Msg == exitSizeMove)
+ {
+ ExitModal();
+ }
+
+ 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.
+ ///
+ protected override void OnFormClosed(FormClosedEventArgs e)
+ {
+ if (EndsTheSession(e.CloseReason))
+ {
+ SessionEnding?.Invoke();
+ }
+
+ base.OnFormClosed(e);
+ }
+
void ScaleChrome()
{
footer.Height = LogicalToDeviceUnits(40);
@@ -262,7 +402,7 @@ void ApplyMenu(Screen screen)
}
shownMenu = menu;
- ViewerMenu.Fill(contextMenu, menu, _ => clickedMenuItem = _);
+ ViewerMenu.Fill(contextMenu, menu, _ => discrete.Enqueue(new(MenuItem: _)));
contextMenu.Show(canvas, point);
}
@@ -280,7 +420,7 @@ void ApplyButtons(Screen screen)
// the OS build's theme renderer does with a Win32 button.
FlatStyle = FlatStyle.Standard
};
- button.Click += (_, _) => clickedButton = index;
+ button.Click += (_, _) => discrete.Enqueue(new(Button: index));
pool.Add(button);
buttonRow.Controls.Add(button);
}
@@ -304,19 +444,25 @@ void ApplyButtons(Screen screen)
public ViewerInput Drain()
{
var drag = canvas.TakeDrag();
+ // Not default: that zeroes every index, and zero is the first button and the first row
+ if (!discrete.TryDequeue(out var next))
+ {
+ next = nothing;
+ }
+
var input = new ViewerInput(
- Key: key,
- ClickedButton: clickedButton,
- ClickedQueueItem: clickedQueueItem,
+ Key: next.Key,
+ ClickedButton: next.Button,
+ ClickedQueueItem: next.QueueItem,
ScrollDelta: scrollDelta,
CloseRequested: closeRequested,
Columns: canvas.ColumnCapacity,
// ScreenBuilder subtracts Chrome to get the body, so adding it back asks for exactly
// the rows the canvas can draw rather than a guess from a fixed cell height.
Rows: canvas.BodyCapacity + ScreenBuilder.Chrome,
- RightClickedQueueItem: rightClickedQueueItem,
- ClickedMenuItem: clickedMenuItem,
- MenuClosed: menuClosed,
+ RightClickedQueueItem: next.RightClickedQueueItem,
+ ClickedMenuItem: next.MenuItem,
+ MenuClosed: next.MenuClosed,
ScrollTo: scrollTo,
DragSide: drag is null ? -1 : (int) drag.Value.Side,
DragAnchorRow: drag?.AnchorRow ?? 0,
@@ -324,12 +470,6 @@ public ViewerInput Drain()
DragFocusRow: drag?.FocusRow ?? 0,
DragFocusColumn: drag?.FocusColumn ?? 0);
- key = CommandKind.None;
- clickedButton = -1;
- clickedQueueItem = -1;
- rightClickedQueueItem = -1;
- clickedMenuItem = -1;
- menuClosed = false;
scrollTo = -1;
scrollDelta = 0;
closeRequested = false;
@@ -389,7 +529,7 @@ protected override bool ProcessCmdKey(ref Message message, Keys keyData)
return base.ProcessCmdKey(ref message, keyData);
}
- key = command;
+ discrete.Enqueue(new(Key: command));
return true;
}
@@ -457,6 +597,7 @@ protected override void Dispose(bool disposing)
if (disposing)
{
contextMenu.Dispose();
+ modalFrames.Dispose();
}
base.Dispose(disposing);
diff --git a/src/DiffEngineViewer/FileSide.cs b/src/DiffEngineViewer/FileSide.cs
index 61719abc..d78fd966 100644
--- a/src/DiffEngineViewer/FileSide.cs
+++ b/src/DiffEngineViewer/FileSide.cs
@@ -33,10 +33,10 @@ public static FileSide Read(string path)
var stamp = new FileStamp(info.LastWriteTimeUtc.Ticks, info.Length);
if (!ImageExtensions.Is(path))
{
- return new(File.ReadAllText(path), stamp, null, null);
+ return new(ReadText(path), stamp, null, null);
}
- return new("", stamp, null, ImageFile.Read(path, File.ReadAllBytes(path)));
+ return new("", stamp, null, ImageFile.Read(path, ReadBytes(path)));
}
catch (Exception exception)
{
@@ -51,6 +51,32 @@ public static FileSide Read(string path)
static ImageFile? Unread(string path) =>
ImageExtensions.Is(path) ? ImageFile.Unread(path) : null;
+ ///
+ /// Every read of a pending file shares it with writers and deleters. A test process rewrites
+ /// or deletes its received file on every run, and an accept moves it: opened with read sharing
+ /// alone, a read that happened to overlap either failed it with "being used by another
+ /// process", and the accept-all worker does not retry.
+ ///
+ static FileStream OpenShared(string path) =>
+ new(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
+
+ public static byte[] ReadBytes(string path)
+ {
+ using var stream = OpenShared(path);
+ using var memory = new MemoryStream();
+ stream.CopyTo(memory);
+ return memory.ToArray();
+ }
+
+ ///
+ /// As File.ReadAllText reads, byte order mark included in the detection, over a shared stream.
+ ///
+ static string ReadText(string path)
+ {
+ using var reader = new StreamReader(OpenShared(path), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
+ return reader.ReadToEnd();
+ }
+
public static FileStamp? StampOf(string path)
{
try
diff --git a/src/DiffEngineViewer/ILoopHooks.cs b/src/DiffEngineViewer/ILoopHooks.cs
new file mode 100644
index 00000000..9620d0f2
--- /dev/null
+++ b/src/DiffEngineViewer/ILoopHooks.cs
@@ -0,0 +1,28 @@
+///
+/// For a head whose toolkit can hold the loop's thread where the loop cannot see it. The loop in
+/// runs one frame per , and on
+/// Windows a present pumps messages - which is where user32 runs its own modal loops, for dragging
+/// a scrollbar thumb and for moving or sizing the window, and where a logoff ends the session. The
+/// loop's next step never comes in either case, so the head is handed those steps to run itself.
+///
+/// Optional, and only WinForms takes it: the native heads pump their own events and have no modal
+/// loops to be caught in.
+///
+///
+interface ILoopHooks
+{
+ ///
+ /// One frame of the loop, for a head to run on a timer from inside a modal loop: polls the
+ /// window, applies what it reports, and returns the screen that results for the head to draw.
+ /// Without it the panes froze until the thumb was let go, and a resize redrew the old screen
+ /// into the new size until the mouse came up.
+ ///
+ Func? Frame { set; }
+
+ ///
+ /// The session is ending: stop taking arrivals and stage what the queue holds, before
+ /// returning. Windows may end the process as soon as a window has answered the message that
+ /// says so, which is before the loop would get as far as its own shutdown.
+ ///
+ Action? SessionEnding { set; }
+}
diff --git a/src/DiffEngineViewer/ViewerProgram.cs b/src/DiffEngineViewer/ViewerProgram.cs
index bc14a3f3..d2850211 100644
--- a/src/DiffEngineViewer/ViewerProgram.cs
+++ b/src/DiffEngineViewer/ViewerProgram.cs
@@ -226,6 +226,16 @@ internal static int Run(SessionHost host, ViewerServer? server, OwnerLink? link,
return 4;
}
+ if (window is ILoopHooks hooks)
+ {
+ hooks.Frame = () => ModalFrame(host, window, link);
+ hooks.SessionEnding = () =>
+ {
+ host.Mutate(_ => _ with { Closing = true });
+ PersistOwned(host.State, link);
+ };
+ }
+
// Whichever of the two produces them; a process either owns the queue or displays one.
var windowCommands = link?.Windows ?? new();
using var cancel = new CancelSource();
@@ -283,6 +293,22 @@ internal static int Run(SessionHost host, ViewerServer? server, OwnerLink? link,
return 0;
}
+ ///
+ /// A frame from inside a head's modal loop (): what the loop does
+ /// with input, without the present, which the head is already inside of.
+ ///
+ static Screen ModalFrame(SessionHost host, IViewerWindow window, OwnerLink? link)
+ {
+ var state = host.State;
+ var input = window.Poll();
+ if (!IsIdle(input, state))
+ {
+ state = host.Mutate(_ => Apply(_, input, link, window));
+ }
+
+ return ScreenBuilder.Build(state);
+ }
+
///
/// An owning viewer's queue lives in this process's memory, so exiting with entries still
/// pending used to discard them silently. Staged instead, so accept tooling still finds them
diff --git a/todo.md b/todo.md
index 75416f5b..12c52097 100644
--- a/todo.md
+++ b/todo.md
@@ -18,52 +18,6 @@ Viewer model
- Fix: have each head report string positions from its own layout rather than cells, or put every code point on the grid.
-Windows head (measured in the test host at 96 DPI; this machine is 120)
-
-- [ ] **Selection highlight and hit-test drift from the glyphs** (repro at 100%, 150%, 175% and 200%; not at 125%)
- - `Graphics.DrawString` with `GenericTypographic` places glyphs at the unhinted advance (8.798 px at 96 DPI), while `MonoFont.Cell` rounds it (9, `src/DiffEngineViewer.Windows/MonoFont.cs:26-28`), and the highlight (`ViewerCanvas.cs:486-494`) and `ColumnAt` (`:273-274`) multiply by the rounded width. Drift at column 66: −13.2 px at 96 DPI, +13.2 at 144, +26.2 at 168, −26.8 at 192.
- - Tests: `CellAgainstTheDrawnAdvance`, `HighlightAtColumn66CoversItsGlyph` (the bar at column 66 hit-tests as 65).
- - Fix as suggested, the subtitle width at `:430` included. The integer cell can stay for layout.
-
-- [ ] **`ImageCache` never evicts** (repro)
- - Keyed by path and only replaced for the path asked about (`src/DiffEngineViewer.Windows/ImageCache.cs:45-58`); `ViewerCanvas.Draw` never prunes it. The process lives while the queue has anything in it, hidden or not.
- - Test: `EveryPictureEverDrawnStaysDecoded` (ten 400×300 pairs, each accepted: 20 pictures and 9.4 MB of unmanaged GDI+ memory held, on a screen showing none).
- - Fix: keep only the paths on the current screen.
-
-- [ ] **The initial window is 1100×700 device pixels under PerMonitorV2** (repro)
- - `ViewerForm.cs:115`, with `AutoScaleMode` left at Inherit, which scales nothing on a top-level form, while the 11 pt font grows with DPI. Characters a pane with a queue showing: 34 at 96 DPI, 23 at 120, 15 at 144, 4 at 192.
- - Test: `WhatTheDefaultWindowHoldsAtEachScale`.
- - Fix: `LogicalToDeviceUnits`, clamped to the working area, since 1100×700 at 150% is too tall for 1080p. Of the pixel constants only `grab = 4` matters.
-
-- [ ] **Discrete input overwrites itself within one `DoEvents` and is applied in a fixed order** (repro)
- - One slot per kind of input (`ViewerForm.cs:94-101`), one `ViewerInput` per `Drain` (`:304-337`), applied as the click chain, then buttons, then the key (`ViewerProgram.cs:440-524`). It needs a slow frame, for example the loop waiting in `host.Mutate` behind an accept on the mutex.
- - Tests: `TwoKeysInOnePumpAreTwoCommands` (two Downs scroll once), `AKeyThenAClickInOnePumpAreAppliedInTheirOrder`: d pressed on one entry, then a click on another, discards the clicked one, which the reader never looked at. With a, it would be accepted into source.
- - Fix: queue them and emit one per `Drain`, without waiting while more are queued. That also fixes the next item.
-
-- [ ] **Right-clicking the row whose menu is open leaves the popup and the model out of step** (repro)
- - The menu filter closes the popup (`ViewerForm.cs:144-156`), the model keeps an equal menu (`ViewerProgram.cs:496-500`), and `Apply` returns before `ApplyMenu` (`ViewerForm.cs:206-209`), so right-clicking that row does nothing until something else changes the screen.
- - Test: `RightClickingTheRowWhoseMenuIsOpen`. Fix: call `ApplyMenu` before the early return.
-
-- [ ] **Dragging the scrollbar thumb, or moving or resizing the window, freezes the panes** (repro for the thumb)
- - `Present` returns only after `DoEvents` (`src/DiffEngineViewer.Windows/FormsViewerWindow.cs:58-60`), and the thumb is tracked in the scroll bar's own modal loop. Move and resize rest on `WM_ENTERSIZEMOVE`'s documentation. The class comment's "no nested message loops" (`:7-9`) is wrong.
- - Test: `DraggingTheThumbHoldsThePump` (one `DoEvents` took 560 ms; the panes jumped on release).
- - A fix needs a way to run a frame from inside the modal loop, which `IViewerWindow` has no hook for.
-
-- [ ] **Losing mouse capture mid drag leaves `selecting`/`dragging` set** (repro)
- - Only `OnMouseUp` clears them (`ViewerCanvas.cs:649-665`), and a window without capture never hears the button come up.
- - Tests: `LosingCaptureMidDragEndsTheDrag` (the selection follows the pointer with no button held), `LosingCaptureMidSplitterDragEndsTheDrag`.
- - Fix: `OnMouseCaptureChanged` acting when `!Capture`, and ending the drag when a move arrives without the left button.
-
-- [ ] **At logoff the session can end before `PersistOwned` runs** (verified)
- - WinForms closes and disposes the form inside `WM_ENDSESSION`. The loop notices only after `DoEvents` returns, and `Run`'s finally then joins the listener and the watcher, up to 2 s each, before persisting (`ViewerProgram.cs:256-281`). The documentation says the session may end once every application has returned from that message.
- - Test: `EndSessionReturnsBeforeTheLoopHasStartedToPersist` (`WM_ENDSESSION` returned at 330 ms, before `Run`'s finally had started). Only a real logoff shows whether Windows ends the process in that gap.
- - The suggested fix is incomplete: the form has no route to the session. It must set `Closing` before persisting, so late arrivals are refused and staged by their senders, and must not wait on the runner or the joins.
-
-- [ ] **`ImageCache` ignores `ImagePane.Hash`, and reads with `FileShare.Read`** (repro)
- - The key is the path, checked by write time and length (`ImageCache.cs:27-51`), and `DrawImage` never passes the hash (`ViewerCanvas.cs:351`). In the app, that takes a pair re-sent within the timestamp granularity.
- - Test: `ARewriteWithTheSameStampKeepsTheOldPicture` (180 of 199 back-to-back writes here kept the stamp). Fix: pass the hash into `Get`.
- - The file is held only for the `ReadAllBytes`: 0.1 ms at 100 KB, 2.8 ms at 10 MB (`HowLongADecodeHoldsTheFile`). The viewer's accept-all worker can overlap it and does not retry; the tray's move retries, its discard does not. `FileSide.Read` opens the same files the same way (`FileSide.cs:36, 39`), so widen the sharing there too.
-
Tray
- [ ] **A failed bind on 3492 leaves the tray running without its listener** (verified)