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)