diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 00fc07d..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(dotnet build:*)" - ] - } -} diff --git a/.gitignore b/.gitignore index 81a0dd1..6108f2e 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ scripts/ # Local agent instructions AGENTS.md agents.md +CLAUDE.md +.Claude/ +.claude/settings.local.json # Local diagnostics notes diagnostics-instrumentation-guide.md diff --git a/InkkSlinger.Tests/ControlDemoDataGridSampleTests.cs b/InkkSlinger.Tests/ControlDemoDataGridSampleTests.cs index 4b57142..0e69004 100644 --- a/InkkSlinger.Tests/ControlDemoDataGridSampleTests.cs +++ b/InkkSlinger.Tests/ControlDemoDataGridSampleTests.cs @@ -370,6 +370,45 @@ public void DataGridView_WheelScrollingToBottom_ShouldNotLeaveBlankSpaceAfterLas $"Expected last row to align with viewport bottom after wheel scroll. LastRowBottom={lastRowBottom}, ViewportBottom={viewportBottom}, Offset={scrollViewer.VerticalOffset}, Extent={scrollViewer.ExtentHeight}, Viewport={scrollViewer.ViewportHeight}."); } + [Fact] + public void DataGridView_WheelScroll_WhenAllRowsAreRealized_DoesNotInvalidateLayout() + { + var view = new DataGridView + { + Width = 1200f, + Height = 520f + }; + + var host = new Canvas + { + Width = 1200f, + Height = 520f + }; + host.AddChild(view); + + var uiRoot = new UiRoot(host); + RunLayout(uiRoot, 1200, 520); + + var dataGrid = FindFirstVisualChild(view); + Assert.NotNull(dataGrid); + Assert.Equal(12, dataGrid!.RealizedRowCountForTesting); + + var scrollViewer = dataGrid.ScrollViewerForTesting; + var pointer = new Vector2( + scrollViewer.LayoutSlot.X + (scrollViewer.LayoutSlot.Width * 0.5f), + scrollViewer.LayoutSlot.Y + (scrollViewer.LayoutSlot.Height * 0.5f)); + var measureInvalidationsBefore = uiRoot.MeasureInvalidationCount; + var arrangeInvalidationsBefore = uiRoot.ArrangeInvalidationCount; + + uiRoot.ResetDirtyStateForTests(); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(pointer, pointerMoved: true, wheelDelta: -120)); + RunLayout(uiRoot, 1200, 520); + + Assert.True(scrollViewer.VerticalOffset > 0f); + Assert.Equal(measureInvalidationsBefore, uiRoot.MeasureInvalidationCount); + Assert.Equal(arrangeInvalidationsBefore, uiRoot.ArrangeInvalidationCount); + } + [Fact] public void DataGridView_DraggingVerticalScrollBarToBottom_ShouldNotLeaveBlankSpaceAfterLastRow() { @@ -461,12 +500,14 @@ public void DataGridView_DraggingHorizontalScrollBar_KeepsFrozenLanesAligned() thumbCenter.Y >= horizontalBar.LayoutSlot.Y && thumbCenter.Y <= horizontalBar.LayoutSlot.Y + horizontalBar.LayoutSlot.Height, $"Expected horizontal thumb center to stay within the horizontal scrollbar bounds. Thumb={thumb.LayoutSlot}, CachedThumb={horizontalBar.GetThumbRectForInput()}, ScrollBar={horizontalBar.LayoutSlot}."); + Assert.Same(thumb, VisualTreeHelper.HitTest(host, thumbCenter)); var rightPointer = new Vector2(horizontalBar.LayoutSlot.X + horizontalBar.LayoutSlot.Width - 2f, thumbCenter.Y); - Assert.True(thumb.HandlePointerDownFromInput(thumbCenter)); - Assert.True(thumb.HandlePointerMoveFromInput(rightPointer)); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(thumbCenter, pointerMoved: true)); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(thumbCenter, leftPressed: true)); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(rightPointer, pointerMoved: true)); RunLayout(uiRoot, 780, 520); - Assert.True(thumb.HandlePointerUpFromInput()); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(rightPointer, leftReleased: true)); RunLayout(uiRoot, 780, 520); Assert.True(scrollViewer.HorizontalOffset > 0f); @@ -476,6 +517,52 @@ public void DataGridView_DraggingHorizontalScrollBar_KeepsFrozenLanesAligned() Assert.Equal(dataGrid.ColumnHeadersForTesting[1].LayoutSlot.X, row.Cells[1].LayoutSlot.X); } + [Fact] + public void DataGridView_DraggingHorizontalScrollBar_DoesNotInvalidateWorkbenchLayout() + { + var view = new DataGridView + { + Width = 780f, + Height = 520f + }; + + var host = new Canvas + { + Width = 780f, + Height = 520f + }; + host.AddChild(view); + + var uiRoot = new UiRoot(host); + RunLayout(uiRoot, 780, 520); + + var dataGrid = FindFirstVisualChild(view); + Assert.NotNull(dataGrid); + + var scrollViewer = dataGrid!.ScrollViewerForTesting; + var horizontalBar = GetPrivateScrollBar(scrollViewer, "_horizontalBar"); + var thumb = FindFirstVisualChild(horizontalBar); + Assert.NotNull(thumb); + + var thumbCenter = GetCenter(thumb!.LayoutSlot); + Assert.Same(thumb, VisualTreeHelper.HitTest(host, thumbCenter)); + var rightPointer = new Vector2(horizontalBar.LayoutSlot.X + horizontalBar.LayoutSlot.Width - 2f, thumbCenter.Y); + var measureInvalidationsBefore = uiRoot.MeasureInvalidationCount; + var arrangeInvalidationsBefore = uiRoot.ArrangeInvalidationCount; + + uiRoot.ResetDirtyStateForTests(); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(thumbCenter, pointerMoved: true)); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(thumbCenter, leftPressed: true)); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(rightPointer, pointerMoved: true)); + RunLayout(uiRoot, 780, 520); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(rightPointer, leftReleased: true)); + RunLayout(uiRoot, 780, 520); + + Assert.True(scrollViewer.HorizontalOffset > 0f); + Assert.Equal(measureInvalidationsBefore, uiRoot.MeasureInvalidationCount); + Assert.Equal(arrangeInvalidationsBefore, uiRoot.ArrangeInvalidationCount); + } + private static TElement? FindFirstVisualChild(UIElement root) where TElement : UIElement { @@ -566,4 +653,5 @@ private static ScrollBar GetPrivateScrollBar(ScrollViewer viewer, string fieldNa Assert.NotNull(field); return Assert.IsType(field!.GetValue(viewer)); } + } diff --git a/InkkSlinger.Tests/ControlsCatalogHoverRegressionTests.cs b/InkkSlinger.Tests/ControlsCatalogHoverRegressionTests.cs index f13b4c0..dd6b567 100644 --- a/InkkSlinger.Tests/ControlsCatalogHoverRegressionTests.cs +++ b/InkkSlinger.Tests/ControlsCatalogHoverRegressionTests.cs @@ -5,6 +5,7 @@ using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Xunit; +using InkkSlinger.Tests.TestDoubles; namespace InkkSlinger.Tests; @@ -31,14 +32,48 @@ public void HoveringFromViewerGutterIntoButton_ShouldActivateButtonHover() var gutterPoint = new Vector2( verticalBar!.LayoutSlot.X - 0.25f, button.LayoutSlot.Y + (button.LayoutSlot.Height * 0.5f)); - MovePointer(uiRoot, gutterPoint); - Assert.False(button.IsMouseOver); + // Capture instrumentation during the hover sequence + using var capture = new InstrumentationCapture(); + MovePointer(uiRoot, gutterPoint); + RunLayout(uiRoot, 1280, 820, 16); var buttonPoint = new Vector2( button.LayoutSlot.X + (button.LayoutSlot.Width * 0.5f), button.LayoutSlot.Y + (button.LayoutSlot.Height * 0.5f)); MovePointer(uiRoot, buttonPoint); + RunLayout(uiRoot, 1280, 820, 32); + + var lines = capture.GetInstrumentLines(); + + // Parse instrumentation + var timings = lines.Select(l => InstrumentationCapture.TryParseTiming(l)).Where(t => t.HasValue).Select(t => t!.Value).ToList(); + var counters = lines.Select(l => InstrumentationCapture.TryParseCounter(l)).Where(c => c.HasValue).Select(c => c!.Value).ToList(); + + Console.WriteLine($"[METRICS] Raw instrument lines captured: {lines.Count}"); + + // Aggregate by method type and show top slow and hot + foreach (var timing in timings.OrderByDescending(t => t.microseconds).Take(10)) + { + Console.WriteLine($"[METRICS] Slow: {timing.method} = {timing.microseconds}us"); + } + + var groupedCounters = counters.GroupBy(c => c.method).Select(g => (method: g.Key, totalCalls: g.Sum(x => x.count))).OrderByDescending(x => x.totalCalls).ToList(); + foreach (var counter in groupedCounters.Take(10)) + { + Console.WriteLine($"[METRICS] Hot: {counter.method} = {counter.totalCalls} calls"); + } + + // Check for MarkFullFrameDirty calls + var fullFrameDirtyLines = lines.Where(l => l.Contains("MarkFullFrameDirty")).ToList(); + if (fullFrameDirtyLines.Count > 0) + { + Console.WriteLine($"[METRICS] MarkFullFrameDirty calls: {fullFrameDirtyLines.Count}"); + foreach (var line in fullFrameDirtyLines.Take(5)) + { + Console.WriteLine($" {line}"); + } + } Assert.True( button.IsMouseOver, diff --git a/InkkSlinger.Tests/ControlsCatalogScrollPersistenceTests.cs b/InkkSlinger.Tests/ControlsCatalogScrollPersistenceTests.cs index 9969e64..93b6edf 100644 --- a/InkkSlinger.Tests/ControlsCatalogScrollPersistenceTests.cs +++ b/InkkSlinger.Tests/ControlsCatalogScrollPersistenceTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; @@ -65,6 +66,29 @@ public void SelectingPreviewViaButtonInvoke_ShouldPreserveLeftCatalogScrollOffse $"Expected left catalog scroll offset to persist after direct invoke. before={beforeInvokeOffset:0.###}, after={afterInvokeOffset:0.###}"); } + [Fact] + public void SidebarWheelScroll_ShouldMoveCatalogScrollViewer() + { + var view = new ControlsCatalogView(); + var uiRoot = new UiRoot(view); + RunLayout(uiRoot, 1280, 820, 16); + + var viewer = FindFirstVisualChild(view); + Assert.NotNull(viewer); + var verticalBar = GetPrivateScrollBar(viewer!, "_verticalBar"); + var beforeThumb = verticalBar.GetThumbRectForInput(); + + var pointer = new Vector2(viewer!.LayoutSlot.X + 24f, viewer.LayoutSlot.Y + 48f); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(pointer, pointerMoved: true)); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(pointer, wheelDelta: -120)); + RunLayout(uiRoot, 1280, 820, 32); + + var afterThumb = verticalBar.GetThumbRectForInput(); + + Assert.True(viewer.VerticalOffset > 0f, $"Expected catalog sidebar wheel scroll to change offset, got {viewer.VerticalOffset:0.###}."); + Assert.True(afterThumb.Y > beforeThumb.Y + 0.01f, $"Expected catalog sidebar thumb to move after wheel scroll. before={beforeThumb}, after={afterThumb}"); + } + private static Vector2 GetVisibleCenter(LayoutRect slot, float verticalOffset) { var visibleY = slot.Y - verticalOffset; @@ -80,6 +104,7 @@ private static void Click(UiRoot uiRoot, Vector2 pointer) private static InputDelta CreatePointerDelta( Vector2 pointer, bool pointerMoved = false, + int wheelDelta = 0, bool leftPressed = false, bool leftReleased = false) { @@ -91,7 +116,7 @@ private static InputDelta CreatePointerDelta( ReleasedKeys = new List(), TextInput = new List(), PointerMoved = pointerMoved || leftPressed || leftReleased, - WheelDelta = 0, + WheelDelta = wheelDelta, LeftPressed = leftPressed, LeftReleased = leftReleased, RightPressed = false, @@ -121,6 +146,13 @@ private static InputDelta CreatePointerDelta( return null; } + private static ScrollBar GetPrivateScrollBar(ScrollViewer viewer, string fieldName) + { + var field = typeof(ScrollViewer).GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + return Assert.IsType(field!.GetValue(viewer)); + } + private static void RunLayout(UiRoot uiRoot, int width, int height, int elapsedMs) { uiRoot.Update( diff --git a/InkkSlinger.Tests/DataGridParityChecklistTests.cs b/InkkSlinger.Tests/DataGridParityChecklistTests.cs index 5dabbb5..ca9b067 100644 --- a/InkkSlinger.Tests/DataGridParityChecklistTests.cs +++ b/InkkSlinger.Tests/DataGridParityChecklistTests.cs @@ -828,9 +828,9 @@ public void HorizontalScroll_RepositionsHeadersWithoutGridLayoutInvalidation() Assert.Equal(frozenHeaderX, grid.ColumnHeadersForTesting[0].LayoutSlot.X); Assert.True(grid.ColumnHeadersForTesting[1].LayoutSlot.X < scrollingHeaderX); Assert.Equal(gridMeasureInvalidationsBefore, grid.MeasureInvalidationCount); - Assert.True(grid.ArrangeInvalidationCount > gridArrangeInvalidationsBefore); + Assert.Equal(gridArrangeInvalidationsBefore, grid.ArrangeInvalidationCount); Assert.Equal(rootMeasureInvalidationsBefore, uiRoot.MeasureInvalidationCount); - Assert.True(uiRoot.ArrangeInvalidationCount > rootArrangeInvalidationsBefore); + Assert.Equal(rootArrangeInvalidationsBefore, uiRoot.ArrangeInvalidationCount); } [Fact] diff --git a/InkkSlinger.Tests/DirtyBoundsEdgeRegressionTests.cs b/InkkSlinger.Tests/DirtyBoundsEdgeRegressionTests.cs index 70ae436..d6ec806 100644 --- a/InkkSlinger.Tests/DirtyBoundsEdgeRegressionTests.cs +++ b/InkkSlinger.Tests/DirtyBoundsEdgeRegressionTests.cs @@ -6,7 +6,7 @@ namespace InkkSlinger.Tests; public sealed class DirtyBoundsEdgeRegressionTests { [Fact] - public void RenderInvalidation_WithNullSource_EscalatesToFullFrameDirty() + public void RenderInvalidation_WithNullSource_DoesNotEscalateToFullFrameDirty() { var uiRoot = new UiRoot(new Panel()); uiRoot.SetDirtyRegionViewportForTests(new LayoutRect(0f, 0f, 200f, 200f)); @@ -14,7 +14,7 @@ public void RenderInvalidation_WithNullSource_EscalatesToFullFrameDirty() uiRoot.NotifyInvalidation(UiInvalidationType.Render, null); - Assert.True(uiRoot.IsFullDirtyForTests()); + Assert.False(uiRoot.IsFullDirtyForTests()); } [Fact] diff --git a/InkkSlinger.Tests/Instrumentation.cs b/InkkSlinger.Tests/Instrumentation.cs new file mode 100644 index 0000000..97d0d62 --- /dev/null +++ b/InkkSlinger.Tests/Instrumentation.cs @@ -0,0 +1,38 @@ +using System.Diagnostics; + +namespace InkkSlinger.Tests; + +public static class Instrumentation +{ + /// + /// Writes a line to the test output that can be captured by InstrumentationCapture + /// and also appears in the test console output. + /// + [DebuggerStepThrough] + public static void Trace(string message) + { + var line = $"[INSTRUMENT] {message}"; + System.Diagnostics.Trace.WriteLine(line); + Console.Out.WriteLine(line); + } + + /// + /// Writes a timing entry in the standard format parsed by InstrumentationCapture.TryParseTiming. + /// Example: "MyMethod took 1234us" + /// + [DebuggerStepThrough] + public static void TraceTiming(string method, long microseconds) + { + Trace($"{method} took {microseconds}us"); + } + + /// + /// Writes a counter entry in the standard format parsed by InstrumentationCapture.TryParseCounter. + /// Example: "MyMethod #42" + /// + [DebuggerStepThrough] + public static void TraceCounter(string method, int count) + { + Trace($"{method} #{count}"); + } +} diff --git a/InkkSlinger.Tests/InstrumentationTests.cs b/InkkSlinger.Tests/InstrumentationTests.cs new file mode 100644 index 0000000..bd40320 --- /dev/null +++ b/InkkSlinger.Tests/InstrumentationTests.cs @@ -0,0 +1,32 @@ +using Xunit; + +namespace InkkSlinger.Tests; + +public sealed class InstrumentationTests +{ + [Fact] + public void Trace_WritesLineToConsole() + { + Instrumentation.Trace("Test message from InstrumentationTests"); + } + + [Fact] + public void TraceTiming_WritesTimingLine() + { + Instrumentation.TraceTiming("FakeMethod", 12345); + } + + [Fact] + public void TraceCounter_WritesCounterLine() + { + Instrumentation.TraceCounter("FakeMethod", 42); + } + + [Fact] + public void Trace_MultipleLines_AreOrdered() + { + Instrumentation.Trace("Line 1"); + Instrumentation.Trace("Line 2"); + Instrumentation.Trace("Line 3"); + } +} diff --git a/InkkSlinger.Tests/ScrollViewerViewerOwnedScrollingTests.cs b/InkkSlinger.Tests/ScrollViewerViewerOwnedScrollingTests.cs index dc9480d..84dcf30 100644 --- a/InkkSlinger.Tests/ScrollViewerViewerOwnedScrollingTests.cs +++ b/InkkSlinger.Tests/ScrollViewerViewerOwnedScrollingTests.cs @@ -215,7 +215,50 @@ public void VirtualizingStackPanel_RearrangesChildren_WhenViewerHorizontalOrigin Assert.True(firstChild.LayoutSlot.X < childXBefore); Assert.False(viewer.NeedsMeasure); Assert.Equal(0, uiRoot.MeasureInvalidationCount); - Assert.True(uiRoot.ArrangeInvalidationCount > 0); + Assert.Equal(0, uiRoot.ArrangeInvalidationCount); + } + + [Fact] + public void VirtualizingStackPanel_AllRealized_WheelScroll_StaysOffLayoutInvalidationPath() + { + var root = new Panel(); + var virtualizingPanel = new VirtualizingStackPanel + { + Orientation = Orientation.Vertical, + IsVirtualizing = true, + CacheLength = 0.5f, + CacheLengthUnit = VirtualizationCacheLengthUnit.Page + }; + + for (var i = 0; i < 12; i++) + { + virtualizingPanel.AddChild(new Border { Height = 24f }); + } + + var viewer = new ScrollViewer + { + LineScrollAmount = 24f, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Content = virtualizingPanel + }; + root.AddChild(viewer); + + var uiRoot = new UiRoot(root); + RunLayout(uiRoot, 320, 260, 16); + + Assert.Equal(12, virtualizingPanel.RealizedChildrenCount); + + uiRoot.ResetDirtyStateForTests(); + var handled = viewer.HandleMouseWheelFromInput(-120); + RunLayout(uiRoot, 320, 260, 32); + + Assert.True(handled); + Assert.True(viewer.VerticalOffset > 0f); + Assert.False(viewer.NeedsMeasure); + Assert.False(viewer.NeedsArrange); + Assert.Equal(0, uiRoot.MeasureInvalidationCount); + Assert.Equal(0, uiRoot.ArrangeInvalidationCount); } [Fact] diff --git a/InkkSlinger.Tests/ScrollViewerWheelHoverRegressionTests.cs b/InkkSlinger.Tests/ScrollViewerWheelHoverRegressionTests.cs index c49b0a6..c806bc4 100644 --- a/InkkSlinger.Tests/ScrollViewerWheelHoverRegressionTests.cs +++ b/InkkSlinger.Tests/ScrollViewerWheelHoverRegressionTests.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; +using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Xunit; +using InkkSlinger.Tests.TestDoubles; namespace InkkSlinger.Tests; @@ -23,7 +25,35 @@ public void WheelScroll_WithStationaryPointer_UpdatesHoverToElementNowUnderCurso Assert.NotNull(beforeButton); Assert.True(beforeButton!.IsMouseOver); + // Exercise the scroll+hover scenario with instrumentation capture + using var capture = new InstrumentationCapture(); Wheel(uiRoot, pointer, delta: -120); + RunLayout(uiRoot, 280, 220, 16); + var lines = capture.GetInstrumentLines(); + + // Parse instrumentation: collect timing and counter data + var timings = lines.Select(l => InstrumentationCapture.TryParseTiming(l)).Where(t => t.HasValue).Select(t => t!.Value).ToList(); + var counters = lines.Select(l => InstrumentationCapture.TryParseCounter(l)).Where(c => c.HasValue).Select(c => c!.Value).ToList(); + + // Also emit to console for immediate visibility + Console.WriteLine($"[METRICS] Raw instrument lines captured: {lines.Count}"); + foreach (var line in lines) + { + Console.WriteLine(line); + } + + // Report slow methods + foreach (var timing in timings.OrderByDescending(t => t.microseconds).Take(5)) + { + Console.WriteLine($"[METRICS] Slow: {timing.method} = {timing.microseconds}us"); + } + + // Report hot methods (high call counts) + var groupedCounters = counters.GroupBy(c => c.method).Select(g => (method: g.Key, totalCalls: g.Sum(x => x.count))).OrderByDescending(x => x.totalCalls).ToList(); + foreach (var counter in groupedCounters.Take(5)) + { + Console.WriteLine($"[METRICS] Hot: {counter.method} = {counter.totalCalls} calls"); + } var afterButton = FindAncestor