From b6b0eb7530674d1ade757dd067adce29d1bddc83 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:42:22 -0400 Subject: [PATCH 1/5] Add wait stats benefit scoring (Stage 2 of #215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calculate MaxBenefitPercent for each wait type in plan wait stats. Serial plans use a simple ratio (waitMs/elapsedMs). Parallel plans use Joe's proportional allocation formula, mapping wait types to relevant operators (I/O waits to operators with physical reads, CPU waits to operators with CPU work, etc.). Parallelism waits (CXPACKET/CXCONSUMER/CXSYNC) use the efficiency gap formula instead of raw wait time — threads waiting for other threads is a symptom, not directly addressable time. Benefits shown in CLI text, HTML export, JSON API (wait_benefits array), and desktop app wait stats ribbon. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controls/PlanViewerControl.axaml.cs | 31 ++- src/PlanViewer.Core/Models/PlanModels.cs | 8 + src/PlanViewer.Core/Output/AnalysisResult.cs | 16 ++ src/PlanViewer.Core/Output/HtmlExporter.cs | 10 +- src/PlanViewer.Core/Output/ResultMapper.cs | 11 + src/PlanViewer.Core/Output/TextFormatter.cs | 12 +- src/PlanViewer.Core/Services/BenefitScorer.cs | 215 ++++++++++++++++++ 7 files changed, 296 insertions(+), 7 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index fc8f3a9..a1ebeca 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -377,7 +377,7 @@ private void RenderStatement(PlanStatement statement) // Update banners ShowMissingIndexes(statement.MissingIndexes); ShowParameters(statement); - ShowWaitStats(statement.WaitStats, statement.QueryTimeStats != null); + ShowWaitStats(statement.WaitStats, statement.WaitBenefits, statement.QueryTimeStats != null); ShowRuntimeSummary(statement); UpdateInsightsHeader(); @@ -2635,7 +2635,7 @@ private static long GetChildElapsedMsSum(PlanNode node) return sum; } - private void ShowWaitStats(List waits, bool isActualPlan) + private void ShowWaitStats(List waits, List benefits, bool isActualPlan) { WaitStatsContent.Children.Clear(); @@ -2651,6 +2651,11 @@ private void ShowWaitStats(List waits, bool isActualPlan) WaitStatsEmpty.IsVisible = false; + // Build benefit lookup + var benefitLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var wb in benefits) + benefitLookup[wb.WaitType] = wb.MaxBenefitPercent; + var sorted = waits.OrderByDescending(w => w.WaitTimeMs).ToList(); var maxWait = sorted[0].WaitTimeMs; var totalWait = sorted.Sum(w => w.WaitTimeMs); @@ -2659,10 +2664,10 @@ private void ShowWaitStats(List waits, bool isActualPlan) WaitStatsHeader.Text = $" Wait Stats \u2014 {totalWait:N0}ms total"; // Build a single Grid for all rows so columns align - // Name and duration auto-size; bar fills remaining space + // Name, bar, duration, and benefit columns var grid = new Grid { - ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto") + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto") }; for (int i = 0; i < sorted.Count; i++) grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); @@ -2709,11 +2714,27 @@ private void ShowWaitStats(List waits, bool isActualPlan) FontSize = 12, Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 0, 2) + Margin = new Thickness(0, 2, 8, 2) }; Grid.SetRow(durationText, i); Grid.SetColumn(durationText, 2); grid.Children.Add(durationText); + + // Benefit % (if available) + if (benefitLookup.TryGetValue(w.WaitType, out var benefitPct) && benefitPct > 0) + { + var benefitText = new TextBlock + { + Text = $"up to {benefitPct:N0}%", + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse("#8b949e")), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 0, 2) + }; + Grid.SetRow(benefitText, i); + Grid.SetColumn(benefitText, 3); + grid.Children.Add(benefitText); + } } WaitStatsContent.Children.Add(grid); diff --git a/src/PlanViewer.Core/Models/PlanModels.cs b/src/PlanViewer.Core/Models/PlanModels.cs index 3950ba6..c839e35 100644 --- a/src/PlanViewer.Core/Models/PlanModels.cs +++ b/src/PlanViewer.Core/Models/PlanModels.cs @@ -62,6 +62,7 @@ public class PlanStatement public SetOptionsInfo? SetOptions { get; set; } public List Parameters { get; set; } = new(); public List WaitStats { get; set; } = new(); + public List WaitBenefits { get; set; } = new(); public QueryTimeInfo? QueryTimeStats { get; set; } // MaxQueryMemory + QueryPlan-level warnings @@ -447,6 +448,13 @@ public class PlanParameter public string? RuntimeValue { get; set; } } +public class WaitBenefit +{ + public string WaitType { get; set; } = ""; + public double MaxBenefitPercent { get; set; } + public string Category { get; set; } = ""; +} + public class WaitStatInfo { public string WaitType { get; set; } = ""; diff --git a/src/PlanViewer.Core/Output/AnalysisResult.cs b/src/PlanViewer.Core/Output/AnalysisResult.cs index 60168a1..21da4fc 100644 --- a/src/PlanViewer.Core/Output/AnalysisResult.cs +++ b/src/PlanViewer.Core/Output/AnalysisResult.cs @@ -139,6 +139,10 @@ public class StatementResult [JsonPropertyName("wait_stats")] public List WaitStats { get; set; } = new(); + // Wait stats benefit analysis + [JsonPropertyName("wait_benefits")] + public List WaitBenefits { get; set; } = new(); + // Cursor metadata [JsonPropertyName("cursor")] public CursorResult? Cursor { get; set; } @@ -353,6 +357,18 @@ public class WaitStatResult public long WaitCount { get; set; } } +public class WaitBenefitResult +{ + [JsonPropertyName("wait_type")] + public string WaitType { get; set; } = ""; + + [JsonPropertyName("max_benefit_percent")] + public double MaxBenefitPercent { get; set; } + + [JsonPropertyName("category")] + public string Category { get; set; } = ""; +} + public class CursorResult { [JsonPropertyName("name")] diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index 4757268..a2fe5c0 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -391,14 +391,22 @@ private static void WriteWaitStatsCard(StringBuilder sb, StatementResult stmt, b sb.AppendLine("
"); if (stmt.WaitStats.Count > 0) { + // Build benefit lookup + var benefitLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var wb in stmt.WaitBenefits) + benefitLookup[wb.WaitType] = wb.MaxBenefitPercent; + var maxWait = stmt.WaitStats.Max(w => w.WaitTimeMs); foreach (var w in stmt.WaitStats.OrderByDescending(w => w.WaitTimeMs)) { var barPct = maxWait > 0 ? (double)w.WaitTimeMs / maxWait * 100 : 0; + var benefitTag = benefitLookup.TryGetValue(w.WaitType, out var pct) + ? $" up to {pct:N0}%" + : ""; sb.AppendLine("
"); sb.AppendLine($"{Encode(w.WaitType)}"); sb.AppendLine($"
"); - sb.AppendLine($"{w.WaitTimeMs:N0} ms"); + sb.AppendLine($"{w.WaitTimeMs:N0} ms{benefitTag}"); sb.AppendLine("
"); } } diff --git a/src/PlanViewer.Core/Output/ResultMapper.cs b/src/PlanViewer.Core/Output/ResultMapper.cs index d61d44e..abfab98 100644 --- a/src/PlanViewer.Core/Output/ResultMapper.cs +++ b/src/PlanViewer.Core/Output/ResultMapper.cs @@ -129,6 +129,17 @@ private static StatementResult MapStatement(PlanStatement stmt) }); } + // Wait stat benefits + foreach (var wb in stmt.WaitBenefits) + { + result.WaitBenefits.Add(new WaitBenefitResult + { + WaitType = wb.WaitType, + MaxBenefitPercent = wb.MaxBenefitPercent, + Category = wb.Category + }); + } + // Parameters — flag potential sniffing issues foreach (var p in stmt.Parameters) { diff --git a/src/PlanViewer.Core/Output/TextFormatter.cs b/src/PlanViewer.Core/Output/TextFormatter.cs index 02f77a5..da655ad 100644 --- a/src/PlanViewer.Core/Output/TextFormatter.cs +++ b/src/PlanViewer.Core/Output/TextFormatter.cs @@ -131,8 +131,18 @@ public static void WriteText(AnalysisResult result, TextWriter writer) if (stmt.WaitStats.Count > 0) { writer.WriteLine("Wait stats:"); + // Build a lookup from wait type to benefit % + var benefitLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var wb in stmt.WaitBenefits) + benefitLookup[wb.WaitType] = wb.MaxBenefitPercent; + foreach (var w in stmt.WaitStats.OrderByDescending(w => w.WaitTimeMs)) - writer.WriteLine($" {w.WaitType}: {w.WaitTimeMs:N0}ms"); + { + var benefitTag = benefitLookup.TryGetValue(w.WaitType, out var pct) + ? $" (up to {pct:N0}% benefit)" + : ""; + writer.WriteLine($" {w.WaitType}: {w.WaitTimeMs:N0}ms{benefitTag}"); + } } if (stmt.Parameters.Count > 0) diff --git a/src/PlanViewer.Core/Services/BenefitScorer.cs b/src/PlanViewer.Core/Services/BenefitScorer.cs index 7508294..7fa24a0 100644 --- a/src/PlanViewer.Core/Services/BenefitScorer.cs +++ b/src/PlanViewer.Core/Services/BenefitScorer.cs @@ -35,6 +35,9 @@ public static void Score(ParsedPlan plan) if (stmt.RootNode != null) ScoreNodeTree(stmt.RootNode, stmt); + + if (stmt.WaitStats.Count > 0 && stmt.QueryTimeStats != null) + ScoreWaitStats(stmt); } } } @@ -376,4 +379,216 @@ private static long GetParallelismOperatorElapsedMs(PlanNode node) var maxChildElapsed = node.Children.Max(c => c.ActualElapsedMs); return Math.Max(0, node.ActualElapsedMs - maxChildElapsed); } + + // --------------------------------------------------------------- + // Stage 2: Wait Stats Benefit + // --------------------------------------------------------------- + + /// + /// Calculates MaxBenefitPercent for each wait type in the statement's wait stats. + /// Serial plans: simple ratio of wait time to elapsed time. + /// Parallel plans: proportional allocation across relevant operators (Joe's formula). + /// + private static void ScoreWaitStats(PlanStatement stmt) + { + var elapsedMs = stmt.QueryTimeStats!.ElapsedTimeMs; + if (elapsedMs <= 0) return; + + var isParallel = stmt.DegreeOfParallelism > 1 && stmt.RootNode != null; + + // Collect all operators with per-thread stats for parallel benefit calculation + List? operatorProfiles = null; + if (isParallel) + { + operatorProfiles = new List(); + CollectOperatorWaitProfiles(stmt.RootNode!, operatorProfiles); + } + + foreach (var wait in stmt.WaitStats) + { + if (wait.WaitTimeMs <= 0) continue; + + var category = ClassifyWaitType(wait.WaitType); + double benefitPct; + + if (category == "Parallelism" && isParallel) + { + // CXPACKET/CXCONSUMER/CXSYNC: benefit is the parallelism efficiency gap, + // not the raw wait time. Threads waiting for other threads is a symptom + // of imperfect parallelism, not directly addressable time. + var cpu = stmt.QueryTimeStats!.CpuTimeMs; + var dop = stmt.DegreeOfParallelism; + if (cpu > 0 && dop > 1) + { + var idealElapsed = (double)cpu / dop; + benefitPct = Math.Max(0, (elapsedMs - idealElapsed) / elapsedMs * 100); + } + else + { + benefitPct = (double)wait.WaitTimeMs / elapsedMs * 100; + } + } + else if (!isParallel || operatorProfiles == null || operatorProfiles.Count == 0) + { + // Serial plan or no operator data: simple ratio + benefitPct = (double)wait.WaitTimeMs / elapsedMs * 100; + } + else + { + // Parallel plan: proportional allocation across relevant operators + benefitPct = CalculateParallelWaitBenefit(wait, category, operatorProfiles, elapsedMs); + } + + stmt.WaitBenefits.Add(new WaitBenefit + { + WaitType = wait.WaitType, + MaxBenefitPercent = Math.Round(Math.Min(100, Math.Max(0, benefitPct)), 1), + Category = category + }); + } + } + + /// + /// Parallel wait benefit using Joe's formula: + /// benefit = (SUM relevant operator max waits) * (total_wait_for_type) / (SUM relevant operator total waits) + /// Then convert to % of statement elapsed time. + /// + private static double CalculateParallelWaitBenefit( + WaitStatInfo wait, string category, + List profiles, long stmtElapsedMs) + { + // Filter to operators relevant for this wait category + var relevant = new List(); + foreach (var p in profiles) + { + if (IsOperatorRelevantForCategory(p, category)) + relevant.Add(p); + } + + // If no operators match, fall back to simple ratio + if (relevant.Count == 0) + return (double)wait.WaitTimeMs / stmtElapsedMs * 100; + + // Joe's formula: + // sum_max = SUM of each relevant operator's max per-thread wait time + // sum_total = SUM of each relevant operator's total wait time across all threads + // benefit_ms = sum_max * wait.WaitTimeMs / sum_total + double sumMax = 0; + double sumTotal = 0; + foreach (var p in relevant) + { + sumMax += p.MaxThreadWaitMs; + sumTotal += p.TotalWaitMs; + } + + if (sumTotal <= 0) + return (double)wait.WaitTimeMs / stmtElapsedMs * 100; + + var benefitMs = sumMax * wait.WaitTimeMs / sumTotal; + return benefitMs / stmtElapsedMs * 100; + } + + /// + /// Determines if an operator is relevant for a given wait category. + /// + private static bool IsOperatorRelevantForCategory(OperatorWaitProfile profile, string category) + { + return category switch + { + "I/O" => profile.HasPhysicalReads, + "CPU" => profile.HasCpuWork, + "Parallelism" => profile.IsExchange, + "Hash" => profile.IsHashOperator, + "Sort" => profile.IsSortOperator, + "Latch" => profile.HasTempDbActivity, + "Lock" => true, // any operator can be blocked by locks + "Network" => false, // ASYNC_NETWORK_IO is client-side, not attributable to operators + "Memory" => false, // memory waits are statement-level + _ => true, // unknown category: include all operators + }; + } + + /// + /// Walks the operator tree and collects wait time profiles for each operator. + /// Wait time per thread = max(0, elapsed - cpu) for that thread. + /// + private static void CollectOperatorWaitProfiles(PlanNode node, List profiles) + { + if (node.HasActualStats && node.PerThreadStats.Count > 0) + { + long maxThreadWait = 0; + long totalWait = 0; + + foreach (var ts in node.PerThreadStats) + { + var threadWait = Math.Max(0, ts.ActualElapsedMs - ts.ActualCPUMs); + totalWait += threadWait; + if (threadWait > maxThreadWait) + maxThreadWait = threadWait; + } + + if (totalWait > 0 || maxThreadWait > 0) + { + profiles.Add(new OperatorWaitProfile + { + Node = node, + MaxThreadWaitMs = maxThreadWait, + TotalWaitMs = totalWait, + HasPhysicalReads = node.ActualPhysicalReads > 0, + HasCpuWork = node.ActualCPUMs > 0, + IsExchange = node.PhysicalOp == "Parallelism", + IsHashOperator = node.PhysicalOp.StartsWith("Hash", StringComparison.OrdinalIgnoreCase), + IsSortOperator = node.PhysicalOp.StartsWith("Sort", StringComparison.OrdinalIgnoreCase), + HasTempDbActivity = node.Warnings.Any(w => w.SpillDetails != null) + || node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase) + }); + } + } + + foreach (var child in node.Children) + CollectOperatorWaitProfiles(child, profiles); + } + + /// + /// Classifies a wait type into a category for operator-to-wait mapping. + /// + internal static string ClassifyWaitType(string waitType) + { + var wt = waitType.ToUpperInvariant(); + return wt switch + { + _ when wt.StartsWith("PAGEIOLATCH") => "I/O", + _ when wt.Contains("IO_COMPLETION") => "I/O", + _ when wt.StartsWith("WRITELOG") => "I/O", + _ when wt == "SOS_SCHEDULER_YIELD" => "CPU", + _ when wt.StartsWith("CXPACKET") || wt.StartsWith("CXCONSUMER") => "Parallelism", + _ when wt.StartsWith("CXSYNC") => "Parallelism", + _ when wt.StartsWith("HT") => "Hash", + _ when wt == "BPSORT" => "Sort", + _ when wt == "BMPBUILD" => "Hash", + _ when wt.StartsWith("PAGELATCH") => "Latch", + _ when wt.StartsWith("LATCH_") => "Latch", + _ when wt.StartsWith("LCK_") => "Lock", + _ when wt == "ASYNC_NETWORK_IO" => "Network", + _ when wt.Contains("MEMORY_ALLOCATION") => "Memory", + _ when wt == "SOS_PHYS_PAGE_CACHE" => "Memory", + _ => "Other" + }; + } + + /// + /// Per-operator wait time profile used for parallel benefit allocation. + /// + private sealed class OperatorWaitProfile + { + public PlanNode Node { get; init; } = null!; + public long MaxThreadWaitMs { get; init; } + public long TotalWaitMs { get; init; } + public bool HasPhysicalReads { get; init; } + public bool HasCpuWork { get; init; } + public bool IsExchange { get; init; } + public bool IsHashOperator { get; init; } + public bool IsSortOperator { get; init; } + public bool HasTempDbActivity { get; init; } + } } From 97624f8605afcc7782bb3f98b35b44c0a67e907c Mon Sep 17 00:00:00 2001 From: ClaudioESSilva Date: Tue, 14 Apr 2026 18:21:30 +0100 Subject: [PATCH 2/5] implements #225 --- .../Controls/PlanViewerControl.axaml.cs | 1 + .../Controls/QueryStoreGridControl.axaml.cs | 1 + .../Dialogs/QueryStoreHistoryWindow.axaml.cs | 2 + .../Helpers/DataGridBehaviors.cs | 117 ++++++++++++++++++ src/PlanViewer.App/MainWindow.axaml.cs | 29 +++-- 5 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 src/PlanViewer.App/Helpers/DataGridBehaviors.cs diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index a1ebeca..9f092f0 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -141,6 +141,7 @@ public PlanViewerControl() var layoutTransform = this.FindControl("PlanLayoutTransform")!; _zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!; + Helpers.DataGridBehaviors.Attach(StatementsGrid); } /// diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs index 71c779a..c7261a9 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs @@ -63,6 +63,7 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi _slicerDaysBack = AppSettingsService.Load().QueryStoreSlicerDays; InitializeComponent(); ResultsGrid.ItemsSource = _filteredRows; + Helpers.DataGridBehaviors.Attach(ResultsGrid); EnsureFilterPopup(); SetupColumnHeaders(); PopulateDatabaseBox(databases, initialDatabase); diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs index 8742ae4..ca5d9b1 100644 --- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs +++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs @@ -104,6 +104,8 @@ public QueryStoreHistoryWindow(string connectionString, string queryHash, _maxHoursBack = slicerDaysBack * 24; InitializeComponent(); + Helpers.DataGridBehaviors.Attach(HistoryDataGrid); + QueryIdentifierText.Text = $"Query Store History: {queryHash} in [{database}]"; QueryTextBox.Text = queryText; diff --git a/src/PlanViewer.App/Helpers/DataGridBehaviors.cs b/src/PlanViewer.App/Helpers/DataGridBehaviors.cs new file mode 100644 index 0000000..1b2c180 --- /dev/null +++ b/src/PlanViewer.App/Helpers/DataGridBehaviors.cs @@ -0,0 +1,117 @@ +using System; +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace PlanViewer.App.Helpers; + +/// +/// Attaches middle-mouse-button pan behavior to a DataGrid. +/// +public static class DataGridBehaviors +{ + // Cached reflection accessors for DataGrid internal scroll methods. + // ProcessHorizontalScroll / ProcessVerticalScroll read the scrollbar's current Value + // and reposition the grid content — the same code path used during real scrollbar interaction. + private static readonly MethodInfo? _processHScroll = + typeof(DataGrid).GetMethod("ProcessHorizontalScroll", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly MethodInfo? _processVScroll = + typeof(DataGrid).GetMethod("ProcessVerticalScroll", BindingFlags.Instance | BindingFlags.NonPublic); + + /// Attach middle-click pan behavior to . + public static void Attach(DataGrid grid) + { + AttachMiddleClickPan(grid); + } + + // ───────────────────────────────────────────────────────────────────────── + // Middle-mouse-button drag → pan (scroll) the grid + // ───────────────────────────────────────────────────────────────────────── + + private static void AttachMiddleClickPan(DataGrid grid) + { + Point panStart = default; + double scrollStartH = 0, scrollStartV = 0; + bool panning = false; + ScrollBar? hBar = null, vBar = null; + bool barsResolved = false; + + // Avalonia's DataGrid has no ScrollViewer in its template — it manages scrolling + // itself via PART_HorizontalScrollbar and PART_VerticalScrollbar. Resolve them + // lazily (visual tree isn't populated until after TemplateApplied). + void ResolveScrollBars() + { + if (barsResolved) return; + barsResolved = true; + foreach (var d in grid.GetVisualDescendants()) + { + if (d is not ScrollBar sb) continue; + if (sb.Name == "PART_HorizontalScrollbar") hBar = sb; + else if (sb.Name == "PART_VerticalScrollbar") vBar = sb; + if (hBar != null && vBar != null) break; + } + } + + // Re-resolve scroll bars if the template is ever re-applied. + grid.TemplateApplied += (_, _) => { barsResolved = false; hBar = null; vBar = null; }; + + // RoutingStrategies.Direct|Bubble + handledEventsToo:true ensures the handler fires + // even though DataGrid rows/cells mark PointerPressed handled (for row selection). + grid.AddHandler(InputElement.PointerPressedEvent, (object? _, PointerPressedEventArgs e) => + { + if (e.GetCurrentPoint(grid).Properties.PointerUpdateKind != PointerUpdateKind.MiddleButtonPressed) return; + + ResolveScrollBars(); + + panning = true; + panStart = e.GetPosition(grid); + scrollStartH = hBar?.Value ?? 0; + scrollStartV = vBar?.Value ?? 0; + e.Pointer.Capture(grid); + grid.Cursor = new Cursor(StandardCursorType.SizeAll); + e.Handled = true; + }, RoutingStrategies.Direct | RoutingStrategies.Bubble, handledEventsToo: true); + + grid.AddHandler(InputElement.PointerMovedEvent, (object? _, PointerEventArgs e) => + { + if (!panning) return; + + // Release pan if the middle button was lifted outside a PointerReleased event. + if (!e.GetCurrentPoint(grid).Properties.IsMiddleButtonPressed) + { + panning = false; + e.Pointer.Capture(null); + grid.Cursor = null; + return; + } + + var delta = e.GetPosition(grid) - panStart; + + if (hBar is not null) + { + hBar.Value = Math.Clamp(scrollStartH + delta.X, hBar.Minimum, hBar.Maximum); + _processHScroll?.Invoke(grid, [ScrollEventType.ThumbTrack]); + } + if (vBar is not null) + { + vBar.Value = Math.Clamp(scrollStartV + delta.Y, vBar.Minimum, vBar.Maximum); + _processVScroll?.Invoke(grid, [ScrollEventType.ThumbTrack]); + } + + e.Handled = true; + }, RoutingStrategies.Direct | RoutingStrategies.Bubble, handledEventsToo: true); + + grid.AddHandler(InputElement.PointerReleasedEvent, (object? _, PointerReleasedEventArgs e) => + { + if (!panning) return; + panning = false; + e.Pointer.Capture(null); + grid.Cursor = null; + e.Handled = true; + }, RoutingStrategies.Direct | RoutingStrategies.Bubble, handledEventsToo: true); + } +} diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index 01315ae..f2d096d 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -96,20 +96,25 @@ public MainWindow() } }, RoutingStrategies.Tunnel); - // Accept command-line argument or restore previously open plans - var args = Environment.GetCommandLineArgs(); - if (args.Length > 1 && File.Exists(args[1])) + // Defer plan loading and MCP startup until the window is visible, + // so any ShowDialog calls have a valid visible owner. + Opened += (_, _) => { - LoadPlanFile(args[1]); - } - else - { - // Restore plans that were open in the previous session - RestoreOpenPlans(); - } + // Accept command-line argument or restore previously open plans + var args = Environment.GetCommandLineArgs(); + if (args.Length > 1 && File.Exists(args[1])) + { + LoadPlanFile(args[1]); + } + else + { + // Restore plans that were open in the previous session + RestoreOpenPlans(); + } - // Start MCP server if enabled in settings - StartMcpServer(); + // Start MCP server if enabled in settings + StartMcpServer(); + }; } private void StartPipeServer() From d1d340837f3381a145161b63fa5967ae20d305bb Mon Sep 17 00:00:00 2001 From: ClaudioESSilva Date: Tue, 14 Apr 2026 18:45:16 +0100 Subject: [PATCH 3/5] Undoing unrelated change --- src/PlanViewer.App/MainWindow.axaml.cs | 29 +++++++++++--------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index f2d096d..01315ae 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -96,25 +96,20 @@ public MainWindow() } }, RoutingStrategies.Tunnel); - // Defer plan loading and MCP startup until the window is visible, - // so any ShowDialog calls have a valid visible owner. - Opened += (_, _) => + // Accept command-line argument or restore previously open plans + var args = Environment.GetCommandLineArgs(); + if (args.Length > 1 && File.Exists(args[1])) { - // Accept command-line argument or restore previously open plans - var args = Environment.GetCommandLineArgs(); - if (args.Length > 1 && File.Exists(args[1])) - { - LoadPlanFile(args[1]); - } - else - { - // Restore plans that were open in the previous session - RestoreOpenPlans(); - } + LoadPlanFile(args[1]); + } + else + { + // Restore plans that were open in the previous session + RestoreOpenPlans(); + } - // Start MCP server if enabled in settings - StartMcpServer(); - }; + // Start MCP server if enabled in settings + StartMcpServer(); } private void StartPipeServer() From 016cc9af42e56153f33877ad2a13b10e685dee40 Mon Sep 17 00:00:00 2001 From: ClaudioESSilva Date: Tue, 14 Apr 2026 18:54:54 +0100 Subject: [PATCH 4/5] Don't use Reflection on internal methods --- src/PlanViewer.App/Helpers/DataGridBehaviors.cs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/PlanViewer.App/Helpers/DataGridBehaviors.cs b/src/PlanViewer.App/Helpers/DataGridBehaviors.cs index 1b2c180..165829f 100644 --- a/src/PlanViewer.App/Helpers/DataGridBehaviors.cs +++ b/src/PlanViewer.App/Helpers/DataGridBehaviors.cs @@ -1,5 +1,4 @@ using System; -using System.Reflection; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; @@ -14,14 +13,6 @@ namespace PlanViewer.App.Helpers; /// public static class DataGridBehaviors { - // Cached reflection accessors for DataGrid internal scroll methods. - // ProcessHorizontalScroll / ProcessVerticalScroll read the scrollbar's current Value - // and reposition the grid content — the same code path used during real scrollbar interaction. - private static readonly MethodInfo? _processHScroll = - typeof(DataGrid).GetMethod("ProcessHorizontalScroll", BindingFlags.Instance | BindingFlags.NonPublic); - private static readonly MethodInfo? _processVScroll = - typeof(DataGrid).GetMethod("ProcessVerticalScroll", BindingFlags.Instance | BindingFlags.NonPublic); - /// Attach middle-click pan behavior to . public static void Attach(DataGrid grid) { @@ -94,12 +85,15 @@ void ResolveScrollBars() if (hBar is not null) { hBar.Value = Math.Clamp(scrollStartH + delta.X, hBar.Minimum, hBar.Maximum); - _processHScroll?.Invoke(grid, [ScrollEventType.ThumbTrack]); + // Raise Thumb.DragDeltaEvent on the scrollbar — a public routed event whose + // ScrollBar class handler calls OnScroll → fires Scroll event → DataGrid + // processes the new Value without any reflection on private members. + hBar.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent }); } if (vBar is not null) { vBar.Value = Math.Clamp(scrollStartV + delta.Y, vBar.Minimum, vBar.Maximum); - _processVScroll?.Invoke(grid, [ScrollEventType.ThumbTrack]); + vBar.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent }); } e.Handled = true; From 3fafcd59cf55164ffcbea07cf7267e03da2c4d47 Mon Sep 17 00:00:00 2001 From: ClaudioESSilva Date: Tue, 14 Apr 2026 18:56:22 +0100 Subject: [PATCH 5/5] fix scroll direction --- src/PlanViewer.App/Helpers/DataGridBehaviors.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PlanViewer.App/Helpers/DataGridBehaviors.cs b/src/PlanViewer.App/Helpers/DataGridBehaviors.cs index 165829f..65c2bfa 100644 --- a/src/PlanViewer.App/Helpers/DataGridBehaviors.cs +++ b/src/PlanViewer.App/Helpers/DataGridBehaviors.cs @@ -84,7 +84,7 @@ void ResolveScrollBars() if (hBar is not null) { - hBar.Value = Math.Clamp(scrollStartH + delta.X, hBar.Minimum, hBar.Maximum); + hBar.Value = Math.Clamp(scrollStartH - delta.X, hBar.Minimum, hBar.Maximum); // Raise Thumb.DragDeltaEvent on the scrollbar — a public routed event whose // ScrollBar class handler calls OnScroll → fires Scroll event → DataGrid // processes the new Value without any reflection on private members. @@ -92,7 +92,7 @@ void ResolveScrollBars() } if (vBar is not null) { - vBar.Value = Math.Clamp(scrollStartV + delta.Y, vBar.Minimum, vBar.Maximum); + vBar.Value = Math.Clamp(scrollStartV - delta.Y, vBar.Minimum, vBar.Maximum); vBar.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent }); }