From 638025c953b2363f14c9fc163b6911830089105c Mon Sep 17 00:00:00 2001 From: rferraton <16419423+rferraton@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:59:00 +0200 Subject: [PATCH 1/5] - Allow to zoom in/out in humanadvices using mousewheel - Make the zoom on planviewer focused/centered on the mouse pointer --- .../Controls/PlanViewerControl.axaml.cs | 29 ++++++++++++++++++- .../Controls/QuerySessionControl.axaml.cs | 22 +++++++++++++- src/PlanViewer.App/MainWindow.axaml.cs | 22 +++++++++++++- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index 6851fdb..6b12332 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -2870,7 +2870,34 @@ private void PlanScrollViewer_PointerWheelChanged(object? sender, PointerWheelEv if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) { e.Handled = true; - SetZoom(_zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep)); + + var newZoom = Math.Max(MinZoom, Math.Min(MaxZoom, + _zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep))); + + if (Math.Abs(newZoom - _zoomLevel) < 0.001) + return; + + // Mouse position relative to the ScrollViewer viewport + var mouseInView = e.GetPosition(PlanScrollViewer); + + // Content point under the mouse at the current zoom level + var contentX = (PlanScrollViewer.Offset.X + mouseInView.X) / _zoomLevel; + var contentY = (PlanScrollViewer.Offset.Y + mouseInView.Y) / _zoomLevel; + + // Apply the new zoom + _zoomLevel = newZoom; + _zoomTransform.ScaleX = _zoomLevel; + _zoomTransform.ScaleY = _zoomLevel; + ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%"; + + // Adjust offset so the same content point stays under the mouse + var newOffsetX = Math.Max(0, contentX * _zoomLevel - mouseInView.X); + var newOffsetY = Math.Max(0, contentY * _zoomLevel - mouseInView.Y); + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(newOffsetX, newOffsetY); + }); } } diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index b277089..b51549e 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -741,10 +741,17 @@ private void ShowAdviceWindow(string title, string content, AnalysisResult? anal buttonPanel.Children.Add(copyBtn); buttonPanel.Children.Add(closeBtn); + var scaleTransform = new ScaleTransform(1, 1); + var layoutTransform = new LayoutTransformControl + { + LayoutTransform = scaleTransform, + Child = scrollViewer + }; + var panel = new DockPanel { Margin = new Avalonia.Thickness(12) }; DockPanel.SetDock(buttonPanel, Dock.Bottom); panel.Children.Add(buttonPanel); - panel.Children.Add(scrollViewer); + panel.Children.Add(layoutTransform); var window = new Window { @@ -759,6 +766,19 @@ private void ShowAdviceWindow(string title, string content, AnalysisResult? anal Content = panel }; + double adviceZoom = 1.0; + window.AddHandler(Avalonia.Input.InputElement.PointerWheelChangedEvent, (_, args) => + { + if (args.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + args.Handled = true; + adviceZoom += args.Delta.Y > 0 ? 0.1 : -0.1; + adviceZoom = Math.Max(0.5, Math.Min(3.0, adviceZoom)); + scaleTransform.ScaleX = adviceZoom; + scaleTransform.ScaleY = adviceZoom; + } + }, Avalonia.Interactivity.RoutingStrategies.Tunnel); + copyBtn.Click += async (_, _) => { var clipboard = window.Clipboard; diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index 81706bf..40519ce 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -637,10 +637,17 @@ private void ShowAdviceWindow(string title, string content, AnalysisResult? anal buttonPanel.Children.Add(copyBtn); buttonPanel.Children.Add(closeBtn); + var scaleTransform = new ScaleTransform(1, 1); + var layoutTransform = new LayoutTransformControl + { + LayoutTransform = scaleTransform, + Child = scrollViewer + }; + var panel = new DockPanel { Margin = new Avalonia.Thickness(12) }; DockPanel.SetDock(buttonPanel, Dock.Bottom); panel.Children.Add(buttonPanel); - panel.Children.Add(scrollViewer); + panel.Children.Add(layoutTransform); var window = new Window { @@ -655,6 +662,19 @@ private void ShowAdviceWindow(string title, string content, AnalysisResult? anal Content = panel }; + double adviceZoom = 1.0; + window.AddHandler(InputElement.PointerWheelChangedEvent, (_, args) => + { + if (args.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + args.Handled = true; + adviceZoom += args.Delta.Y > 0 ? 0.1 : -0.1; + adviceZoom = Math.Max(0.5, Math.Min(3.0, adviceZoom)); + scaleTransform.ScaleX = adviceZoom; + scaleTransform.ScaleY = adviceZoom; + } + }, RoutingStrategies.Tunnel); + copyBtn.Click += async (_, _) => { var clipboard = window.Clipboard; From 1dada02b441a92215ce3705a60b1998590c5d2fe Mon Sep 17 00:00:00 2001 From: rferraton <16419423+rferraton@users.noreply.github.com> Date: Sat, 4 Apr 2026 23:36:16 +0200 Subject: [PATCH 2/5] make the node quoted in the human advices window clickable and navigate to the node. The plan become centered and focus (zoomed) on the selected node --- .../Controls/PlanViewerControl.axaml.cs | 55 ++++ .../Controls/QuerySessionControl.axaml.cs | 22 +- src/PlanViewer.App/MainWindow.axaml.cs | 9 +- .../Services/AdviceContentBuilder.cs | 263 +++++++++++++++++- 4 files changed, 338 insertions(+), 11 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index 6b12332..106b4b3 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -172,6 +172,61 @@ public ServerMetadata? Metadata public event EventHandler? RobotAdviceRequested; public event EventHandler? CopyReproRequested; + /// + /// Navigates to a specific plan node by ID: selects it, zooms to show it, + /// and scrolls to center it in the viewport. + /// + public void NavigateToNode(int nodeId) + { + // Find the Border for this node + Border? targetBorder = null; + PlanNode? targetNode = null; + foreach (var (border, node) in _nodeBorderMap) + { + if (node.NodeId == nodeId) + { + targetBorder = border; + targetNode = node; + break; + } + } + + if (targetBorder == null || targetNode == null) + return; + + // Activate the parent window so the plan viewer becomes visible + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel is Window parentWindow) + parentWindow.Activate(); + + // Select the node (highlights it and shows properties) + SelectNode(targetBorder, targetNode); + + // Ensure zoom level makes the node comfortably visible + var viewWidth = PlanScrollViewer.Bounds.Width; + var viewHeight = PlanScrollViewer.Bounds.Height; + if (viewWidth <= 0 || viewHeight <= 0) + return; + + // If the node is too small at the current zoom, zoom in so it's ~1/3 of the viewport + var nodeW = PlanLayoutEngine.NodeWidth; + var nodeH = PlanLayoutEngine.GetNodeHeight(targetNode); + var minVisibleZoom = Math.Min(viewWidth / (nodeW * 4), viewHeight / (nodeH * 4)); + if (_zoomLevel < minVisibleZoom) + SetZoom(Math.Min(minVisibleZoom, 1.0)); + + // Scroll to center the node in the viewport + var centerX = (targetNode.X + nodeW / 2) * _zoomLevel - viewWidth / 2; + var centerY = (targetNode.Y + nodeH / 2) * _zoomLevel - viewHeight / 2; + centerX = Math.Max(0, centerX); + centerY = Math.Max(0, centerY); + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(centerX, centerY); + }); + } + public void LoadPlan(string planXml, string label, string? queryText = null) { _label = label; diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index b51549e..6af2ad9 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -659,12 +659,17 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride } private AnalysisResult? GetCurrentAnalysis() + { + return GetCurrentAnalysisWithViewer().Analysis; + } + + private (AnalysisResult? Analysis, PlanViewerControl? Viewer) GetCurrentAnalysisWithViewer() { // Find the currently selected plan tab's PlanViewerControl if (SubTabControl.SelectedItem is TabItem tab && tab.Content is PlanViewerControl viewer && viewer.CurrentPlan != null) { - return ResultMapper.Map(viewer.CurrentPlan, "query editor", _serverMetadata); + return (ResultMapper.Map(viewer.CurrentPlan, "query editor", _serverMetadata), viewer); } // Fallback: find the most recent plan tab @@ -673,20 +678,20 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride if (SubTabControl.Items[i] is TabItem planTab && planTab.Content is PlanViewerControl v && v.CurrentPlan != null) { - return ResultMapper.Map(v.CurrentPlan, "query editor"); + return (ResultMapper.Map(v.CurrentPlan, "query editor"), v); } } - return null; + return (null, null); } private void HumanAdvice_Click(object? sender, RoutedEventArgs e) { - var analysis = GetCurrentAnalysis(); + var (analysis, viewer) = GetCurrentAnalysisWithViewer(); if (analysis == null) { SetStatus("No plan to analyze", autoClear: false); return; } var text = TextFormatter.Format(analysis); - ShowAdviceWindow("Advice for Humans", text, analysis); + ShowAdviceWindow("Advice for Humans", text, analysis, viewer); } private void RobotAdvice_Click(object? sender, RoutedEventArgs e) @@ -698,9 +703,12 @@ private void RobotAdvice_Click(object? sender, RoutedEventArgs e) ShowAdviceWindow("Advice for Robots", json); } - private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null) + private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null) { - var styledContent = AdviceContentBuilder.Build(content, analysis); + Action? onNodeClick = sourceViewer != null + ? nodeId => sourceViewer.NavigateToNode(nodeId) + : null; + var styledContent = AdviceContentBuilder.Build(content, analysis, onNodeClick); var scrollViewer = new ScrollViewer { diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index 40519ce..8ec91d5 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -458,7 +458,7 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer) { if (viewer.CurrentPlan == null) return; var analysis = ResultMapper.Map(viewer.CurrentPlan, "file", viewer.Metadata); - ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis), analysis); + ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis), analysis, viewer); }; Action showRobotAdvice = () => @@ -594,9 +594,12 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer) return panel; } - private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null) + private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null) { - var styledContent = AdviceContentBuilder.Build(content, analysis); + Action? onNodeClick = sourceViewer != null + ? nodeId => sourceViewer.NavigateToNode(nodeId) + : null; + var styledContent = AdviceContentBuilder.Build(content, analysis, onNodeClick); var scrollViewer = new ScrollViewer { diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs index eeff7d6..a1b6b9e 100644 --- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs +++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs @@ -66,12 +66,22 @@ internal static class AdviceContentBuilder private static readonly Regex CpuPercentRegex = new(@"(\d+)%\)", RegexOptions.Compiled); + // Matches "Node N" or "(Node N)" references in text + private static readonly Regex NodeRefRegex = new(@"(?<=\(?)\bNode\s+(\d+)\b(?=\)?)", RegexOptions.Compiled); + + private static readonly SolidColorBrush LinkBrush = new(Color.Parse("#4FC3F7")); + public static StackPanel Build(string content) { - return Build(content, null); + return Build(content, null, null); } public static StackPanel Build(string content, AnalysisResult? analysis) + { + return Build(content, analysis, null); + } + + public static StackPanel Build(string content, AnalysisResult? analysis, Action? onNodeClick) { var panel = new StackPanel { Margin = new Avalonia.Thickness(4, 0) }; var lines = content.Split('\n'); @@ -410,9 +420,260 @@ public static StackPanel Build(string content, AnalysisResult? analysis) }); } + // Post-process: make "Node N" references clickable + if (onNodeClick != null) + MakeNodeRefsClickable(panel, onNodeClick); + return panel; } + /// + /// Walks all children recursively and replaces "Node N" text with clickable inline links. + /// + private static void MakeNodeRefsClickable(Panel panel, Action onNodeClick) + { + for (int i = 0; i < panel.Children.Count; i++) + { + var child = panel.Children[i]; + + // Recurse into containers + if (child is Panel innerPanel) + { + MakeNodeRefsClickable(innerPanel, onNodeClick); + continue; + } + if (child is Border border) + { + if (border.Child is Panel borderPanel) + { + MakeNodeRefsClickable(borderPanel, onNodeClick); + continue; + } + if (border.Child is SelectableTextBlock borderStb) + { + if (borderStb.Inlines?.Count > 0) + ProcessInlines(borderStb, onNodeClick); + else if (!string.IsNullOrEmpty(borderStb.Text) && NodeRefRegex.IsMatch(borderStb.Text)) + { + var bText = borderStb.Text; + var bFg = borderStb.Foreground; + borderStb.Text = null; + AddRunsWithNodeLinks(borderStb.Inlines!, bText, bFg, onNodeClick); + WireNodeClickHandler(borderStb, onNodeClick); + } + continue; + } + } + if (child is Expander expander && expander.Content is Panel expanderPanel) + { + MakeNodeRefsClickable(expanderPanel, onNodeClick); + continue; + } + + // Process SelectableTextBlock with Inlines + if (child is SelectableTextBlock stb && stb.Inlines?.Count > 0) + { + ProcessInlines(stb, onNodeClick); + continue; + } + + // Process SelectableTextBlock with plain Text + if (child is SelectableTextBlock stbPlain && stbPlain.Inlines?.Count == 0 + && !string.IsNullOrEmpty(stbPlain.Text) && NodeRefRegex.IsMatch(stbPlain.Text)) + { + var text = stbPlain.Text; + var fg = stbPlain.Foreground; + stbPlain.Text = null; + AddRunsWithNodeLinks(stbPlain.Inlines!, text, fg, onNodeClick); + WireNodeClickHandler(stbPlain, onNodeClick); + } + } + } + + /// + /// Processes existing Inlines in a SelectableTextBlock, splitting any Run that + /// contains "Node N" into segments with clickable links. + /// + private static void ProcessInlines(SelectableTextBlock stb, Action onNodeClick) + { + var inlines = stb.Inlines!; + var snapshot = inlines.ToList(); + var changed = false; + + foreach (var inline in snapshot) + { + if (inline is Run run && !string.IsNullOrEmpty(run.Text) && NodeRefRegex.IsMatch(run.Text)) + { + changed = true; + break; + } + } + + if (!changed) return; + + // Rebuild inlines + var newInlines = new List(); + foreach (var inline in snapshot) + { + if (inline is Run run && !string.IsNullOrEmpty(run.Text) && NodeRefRegex.IsMatch(run.Text)) + { + var text = run.Text; + int pos = 0; + foreach (System.Text.RegularExpressions.Match m in NodeRefRegex.Matches(text)) + { + if (m.Index > pos) + newInlines.Add(new Run(text[pos..m.Index]) { Foreground = run.Foreground, FontWeight = run.FontWeight, FontSize = run.FontSize > 0 ? run.FontSize : double.NaN }); + + if (int.TryParse(m.Groups[1].Value, out var nodeId)) + { + var linkRun = new Run(m.Value) + { + Foreground = LinkBrush, + TextDecorations = Avalonia.Media.TextDecorations.Underline, + FontWeight = run.FontWeight, + FontSize = run.FontSize > 0 ? run.FontSize : double.NaN + }; + newInlines.Add(linkRun); + } + else + { + newInlines.Add(new Run(m.Value) { Foreground = run.Foreground, FontWeight = run.FontWeight }); + } + pos = m.Index + m.Length; + } + if (pos < text.Length) + newInlines.Add(new Run(text[pos..]) { Foreground = run.Foreground, FontWeight = run.FontWeight, FontSize = run.FontSize > 0 ? run.FontSize : double.NaN }); + } + else + { + newInlines.Add(inline); + } + } + + inlines.Clear(); + foreach (var ni in newInlines) + inlines.Add(ni); + + // Wire up PointerPressed on the TextBlock to detect clicks on link runs + WireNodeClickHandler(stb, onNodeClick); + } + + /// + /// Splits plain text into Runs, making "Node N" references clickable. + /// + private static void AddRunsWithNodeLinks(InlineCollection inlines, string text, IBrush? defaultFg, Action onNodeClick) + { + int pos = 0; + var stb = inlines.FirstOrDefault()?.Parent as SelectableTextBlock; + foreach (System.Text.RegularExpressions.Match m in NodeRefRegex.Matches(text)) + { + if (m.Index > pos) + inlines.Add(new Run(text[pos..m.Index]) { Foreground = defaultFg }); + + if (int.TryParse(m.Groups[1].Value, out _)) + { + inlines.Add(new Run(m.Value) + { + Foreground = LinkBrush, + TextDecorations = Avalonia.Media.TextDecorations.Underline + }); + } + else + { + inlines.Add(new Run(m.Value) { Foreground = defaultFg }); + } + pos = m.Index + m.Length; + } + if (pos < text.Length) + inlines.Add(new Run(text[pos..]) { Foreground = defaultFg }); + + // Find the parent SelectableTextBlock to attach click handler + // The inlines collection is owned by the SelectableTextBlock that called us + // We need to wire it up after — caller should call WireNodeClickHandler separately + } + + /// + /// Attaches a PointerPressed handler to a SelectableTextBlock that detects clicks + /// on underlined "Node N" text and invokes the callback. + /// Uses Tunnel routing so the handler fires before SelectableTextBlock's + /// built-in text selection consumes the event. + /// + private static void WireNodeClickHandler(SelectableTextBlock stb, Action onNodeClick) + { + stb.AddHandler(Avalonia.Input.InputElement.PointerPressedEvent, (_, e) => + { + var point = e.GetPosition(stb); + var hit = stb.TextLayout.HitTestPoint(point); + if (!hit.IsInside) return; + + var charIndex = hit.TextPosition; + + // Walk through inlines to find which Run the charIndex falls in + int runStart = 0; + foreach (var inline in stb.Inlines!) + { + if (inline is Run run && run.Text != null) + { + var runEnd = runStart + run.Text.Length; + if (charIndex >= runStart && charIndex < runEnd) + { + if (run.TextDecorations == Avalonia.Media.TextDecorations.Underline + && run.Foreground == LinkBrush) + { + var m = NodeRefRegex.Match(run.Text); + if (m.Success && int.TryParse(m.Groups[1].Value, out var nodeId)) + { + e.Handled = true; + + // Clear any text selection and release pointer capture + // to prevent SelectableTextBlock from starting a selection drag + stb.SelectionStart = 0; + stb.SelectionEnd = 0; + e.Pointer.Capture(null); + + onNodeClick(nodeId); + } + } + return; + } + runStart = runEnd; + } + } + }, Avalonia.Interactivity.RoutingStrategies.Tunnel); + + // Change cursor on hover over link runs + stb.PointerMoved += (_, e) => + { + var point = e.GetPosition(stb); + var hit = stb.TextLayout.HitTestPoint(point); + if (!hit.IsInside) + { + stb.Cursor = Avalonia.Input.Cursor.Default; + return; + } + + var charIndex = hit.TextPosition; + int runStart = 0; + foreach (var inline in stb.Inlines!) + { + if (inline is Run run && run.Text != null) + { + var runEnd = runStart + run.Text.Length; + if (charIndex >= runStart && charIndex < runEnd) + { + stb.Cursor = run.TextDecorations == Avalonia.Media.TextDecorations.Underline + && run.Foreground == LinkBrush + ? new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand) + : Avalonia.Input.Cursor.Default; + return; + } + runStart = runEnd; + } + } + stb.Cursor = Avalonia.Input.Cursor.Default; + }; + } + private static bool IsSubSectionLabel(string trimmed) { // "Warnings:", "Parameters:", "Wait stats:", "Operator warnings:", From 1a849ddb8217a0d14a082238046d6869c2bf7efc Mon Sep 17 00:00:00 2001 From: rferraton <16419423+rferraton@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:51:20 +0200 Subject: [PATCH 3/5] =?UTF-8?q?PlanScrollViewer=5FPointerWheelChanged=20?= =?UTF-8?q?=E2=80=94=20now=20a=204-line=20method=20that=20just=20calls=20S?= =?UTF-8?q?etZoomAtPoint=20with=20the=20mouse=20position.=20No=20duplicate?= =?UTF-8?q?d=20zoom=20logic.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controls/PlanViewerControl.axaml.cs | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index 106b4b3..a475b2d 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -2920,39 +2920,40 @@ private void SetZoom(double level) ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%"; } - private void PlanScrollViewer_PointerWheelChanged(object? sender, PointerWheelEventArgs e) + /// + /// Sets the zoom level and adjusts the scroll offset so that the content point + /// under stays fixed in the viewport. + /// + private void SetZoomAtPoint(double level, Point viewportAnchor) { - if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) - { - e.Handled = true; - - var newZoom = Math.Max(MinZoom, Math.Min(MaxZoom, - _zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep))); - - if (Math.Abs(newZoom - _zoomLevel) < 0.001) - return; + var newZoom = Math.Max(MinZoom, Math.Min(MaxZoom, level)); + if (Math.Abs(newZoom - _zoomLevel) < 0.001) + return; - // Mouse position relative to the ScrollViewer viewport - var mouseInView = e.GetPosition(PlanScrollViewer); + // Content point under the anchor at the current zoom level + var contentX = (PlanScrollViewer.Offset.X + viewportAnchor.X) / _zoomLevel; + var contentY = (PlanScrollViewer.Offset.Y + viewportAnchor.Y) / _zoomLevel; - // Content point under the mouse at the current zoom level - var contentX = (PlanScrollViewer.Offset.X + mouseInView.X) / _zoomLevel; - var contentY = (PlanScrollViewer.Offset.Y + mouseInView.Y) / _zoomLevel; + // Apply the new zoom + SetZoom(newZoom); - // Apply the new zoom - _zoomLevel = newZoom; - _zoomTransform.ScaleX = _zoomLevel; - _zoomTransform.ScaleY = _zoomLevel; - ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%"; + // Adjust offset so the same content point stays under the anchor + var newOffsetX = Math.Max(0, contentX * _zoomLevel - viewportAnchor.X); + var newOffsetY = Math.Max(0, contentY * _zoomLevel - viewportAnchor.Y); - // Adjust offset so the same content point stays under the mouse - var newOffsetX = Math.Max(0, contentX * _zoomLevel - mouseInView.X); - var newOffsetY = Math.Max(0, contentY * _zoomLevel - mouseInView.Y); + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + PlanScrollViewer.Offset = new Vector(newOffsetX, newOffsetY); + }); + } - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - PlanScrollViewer.Offset = new Vector(newOffsetX, newOffsetY); - }); + private void PlanScrollViewer_PointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + e.Handled = true; + var newLevel = _zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep); + SetZoomAtPoint(newLevel, e.GetPosition(PlanScrollViewer)); } } From f1e94dc02e410f33083f12f51f303fa87ca263b9 Mon Sep 17 00:00:00 2001 From: rferraton <16419423+rferraton@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:57:42 +0200 Subject: [PATCH 4/5] Future changes to the advice window (styling, behavior, new features) now only need to touch AdviceWindowHelper.cs. --- .../Controls/QuerySessionControl.axaml.cs | 98 +------------- src/PlanViewer.App/MainWindow.axaml.cs | 98 +------------- .../Services/AdviceWindowHelper.cs | 128 ++++++++++++++++++ 3 files changed, 130 insertions(+), 194 deletions(-) create mode 100644 src/PlanViewer.App/Services/AdviceWindowHelper.cs diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index 6af2ad9..9b2eb1f 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -705,103 +705,7 @@ private void RobotAdvice_Click(object? sender, RoutedEventArgs e) private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null) { - Action? onNodeClick = sourceViewer != null - ? nodeId => sourceViewer.NavigateToNode(nodeId) - : null; - var styledContent = AdviceContentBuilder.Build(content, analysis, onNodeClick); - - var scrollViewer = new ScrollViewer - { - Content = styledContent, - HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled, - VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto - }; - - var copyBtn = new Button - { - Content = "Copy to Clipboard", - Height = 32, - Padding = new Avalonia.Thickness(16, 0), - FontSize = 12, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center, - Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! - }; - - var closeBtn = new Button - { - Content = "Close", - Height = 32, - Padding = new Avalonia.Thickness(16, 0), - FontSize = 12, - Margin = new Avalonia.Thickness(8, 0, 0, 0), - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center, - Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! - }; - - var buttonPanel = new StackPanel - { - Orientation = Avalonia.Layout.Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Margin = new Avalonia.Thickness(0, 8, 0, 0) - }; - buttonPanel.Children.Add(copyBtn); - buttonPanel.Children.Add(closeBtn); - - var scaleTransform = new ScaleTransform(1, 1); - var layoutTransform = new LayoutTransformControl - { - LayoutTransform = scaleTransform, - Child = scrollViewer - }; - - var panel = new DockPanel { Margin = new Avalonia.Thickness(12) }; - DockPanel.SetDock(buttonPanel, Dock.Bottom); - panel.Children.Add(buttonPanel); - panel.Children.Add(layoutTransform); - - var window = new Window - { - Title = $"Performance Studio — {title}", - Width = 700, - Height = 600, - MinWidth = 400, - MinHeight = 300, - Icon = GetParentWindow().Icon, - Background = new SolidColorBrush(Color.Parse("#1A1D23")), - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - Content = panel - }; - - double adviceZoom = 1.0; - window.AddHandler(Avalonia.Input.InputElement.PointerWheelChangedEvent, (_, args) => - { - if (args.KeyModifiers.HasFlag(KeyModifiers.Control)) - { - args.Handled = true; - adviceZoom += args.Delta.Y > 0 ? 0.1 : -0.1; - adviceZoom = Math.Max(0.5, Math.Min(3.0, adviceZoom)); - scaleTransform.ScaleX = adviceZoom; - scaleTransform.ScaleY = adviceZoom; - } - }, Avalonia.Interactivity.RoutingStrategies.Tunnel); - - copyBtn.Click += async (_, _) => - { - var clipboard = window.Clipboard; - if (clipboard != null) - { - await clipboard.SetTextAsync(content); - copyBtn.Content = "Copied!"; - await Task.Delay(1500); - copyBtn.Content = "Copy to Clipboard"; - } - }; - - closeBtn.Click += (_, _) => window.Close(); - - window.Show(GetParentWindow()); + AdviceWindowHelper.Show(GetParentWindow(), title, content, analysis, sourceViewer); } private void AddPlanTab(string planXml, string queryText, bool estimated, string? labelOverride = null) diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index 8ec91d5..0a055f9 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -596,103 +596,7 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer) private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null) { - Action? onNodeClick = sourceViewer != null - ? nodeId => sourceViewer.NavigateToNode(nodeId) - : null; - var styledContent = AdviceContentBuilder.Build(content, analysis, onNodeClick); - - var scrollViewer = new ScrollViewer - { - Content = styledContent, - HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled, - VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto - }; - - var copyBtn = new Button - { - Content = "Copy to Clipboard", - Height = 32, - Padding = new Avalonia.Thickness(16, 0), - FontSize = 12, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center, - Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! - }; - - var closeBtn = new Button - { - Content = "Close", - Height = 32, - Padding = new Avalonia.Thickness(16, 0), - FontSize = 12, - Margin = new Avalonia.Thickness(8, 0, 0, 0), - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center, - Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! - }; - - var buttonPanel = new StackPanel - { - Orientation = Avalonia.Layout.Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Margin = new Avalonia.Thickness(0, 8, 0, 0) - }; - buttonPanel.Children.Add(copyBtn); - buttonPanel.Children.Add(closeBtn); - - var scaleTransform = new ScaleTransform(1, 1); - var layoutTransform = new LayoutTransformControl - { - LayoutTransform = scaleTransform, - Child = scrollViewer - }; - - var panel = new DockPanel { Margin = new Avalonia.Thickness(12) }; - DockPanel.SetDock(buttonPanel, Dock.Bottom); - panel.Children.Add(buttonPanel); - panel.Children.Add(layoutTransform); - - var window = new Window - { - Title = $"Performance Studio — {title}", - Width = 700, - Height = 600, - MinWidth = 400, - MinHeight = 300, - Icon = this.Icon, - Background = new SolidColorBrush(Color.Parse("#1A1D23")), - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - Content = panel - }; - - double adviceZoom = 1.0; - window.AddHandler(InputElement.PointerWheelChangedEvent, (_, args) => - { - if (args.KeyModifiers.HasFlag(KeyModifiers.Control)) - { - args.Handled = true; - adviceZoom += args.Delta.Y > 0 ? 0.1 : -0.1; - adviceZoom = Math.Max(0.5, Math.Min(3.0, adviceZoom)); - scaleTransform.ScaleX = adviceZoom; - scaleTransform.ScaleY = adviceZoom; - } - }, RoutingStrategies.Tunnel); - - copyBtn.Click += async (_, _) => - { - var clipboard = window.Clipboard; - if (clipboard != null) - { - await clipboard.SetTextAsync(content); - copyBtn.Content = "Copied!"; - await Task.Delay(1500); - copyBtn.Content = "Copy to Clipboard"; - } - }; - - closeBtn.Click += (_, _) => window.Close(); - - window.Show(this); + AdviceWindowHelper.Show(this, title, content, analysis, sourceViewer); } private List<(string label, PlanViewerControl viewer)> CollectAllPlanTabs() diff --git a/src/PlanViewer.App/Services/AdviceWindowHelper.cs b/src/PlanViewer.App/Services/AdviceWindowHelper.cs new file mode 100644 index 0000000..372fd15 --- /dev/null +++ b/src/PlanViewer.App/Services/AdviceWindowHelper.cs @@ -0,0 +1,128 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using PlanViewer.App.Controls; +using PlanViewer.Core.Output; + +namespace PlanViewer.App.Services; + +/// +/// Creates and shows the Advice for Humans / Robots popup window. +/// Shared between MainWindow (file mode) and QuerySessionControl (query mode). +/// +internal static class AdviceWindowHelper +{ + public static void Show( + Window owner, + string title, + string content, + AnalysisResult? analysis = null, + PlanViewerControl? sourceViewer = null) + { + Action? onNodeClick = sourceViewer != null + ? nodeId => sourceViewer.NavigateToNode(nodeId) + : null; + var styledContent = AdviceContentBuilder.Build(content, analysis, onNodeClick); + + var scrollViewer = new ScrollViewer + { + Content = styledContent, + HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto + }; + + var buttonTheme = (Avalonia.Styling.ControlTheme)owner.FindResource("AppButton")!; + + var copyBtn = new Button + { + Content = "Copy to Clipboard", + Height = 32, + Padding = new Avalonia.Thickness(16, 0), + FontSize = 12, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Theme = buttonTheme + }; + + var closeBtn = new Button + { + Content = "Close", + Height = 32, + Padding = new Avalonia.Thickness(16, 0), + FontSize = 12, + Margin = new Avalonia.Thickness(8, 0, 0, 0), + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Theme = buttonTheme + }; + + var buttonPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Avalonia.Thickness(0, 8, 0, 0) + }; + buttonPanel.Children.Add(copyBtn); + buttonPanel.Children.Add(closeBtn); + + // Wrap in LayoutTransformControl for Ctrl+Wheel font scaling + var scaleTransform = new ScaleTransform(1, 1); + var layoutTransform = new LayoutTransformControl + { + LayoutTransform = scaleTransform, + Child = scrollViewer + }; + + var panel = new DockPanel { Margin = new Avalonia.Thickness(12) }; + DockPanel.SetDock(buttonPanel, Dock.Bottom); + panel.Children.Add(buttonPanel); + panel.Children.Add(layoutTransform); + + var window = new Window + { + Title = $"Performance Studio \u2014 {title}", + Width = 700, + Height = 600, + MinWidth = 400, + MinHeight = 300, + Icon = owner.Icon, + Background = new SolidColorBrush(Color.Parse("#1A1D23")), + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + Content = panel + }; + + // Ctrl+MouseWheel to increase/decrease font size + double adviceZoom = 1.0; + window.AddHandler(InputElement.PointerWheelChangedEvent, (_, args) => + { + if (args.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + args.Handled = true; + adviceZoom += args.Delta.Y > 0 ? 0.1 : -0.1; + adviceZoom = Math.Max(0.5, Math.Min(3.0, adviceZoom)); + scaleTransform.ScaleX = adviceZoom; + scaleTransform.ScaleY = adviceZoom; + } + }, RoutingStrategies.Tunnel); + + copyBtn.Click += async (_, _) => + { + var clipboard = window.Clipboard; + if (clipboard != null) + { + await clipboard.SetTextAsync(content); + copyBtn.Content = "Copied!"; + await Task.Delay(1500); + copyBtn.Content = "Copy to Clipboard"; + } + }; + + closeBtn.Click += (_, _) => window.Close(); + + window.Show(owner); + } +} From 3c715e37bd37892933d25596d91176c42e4f1bde Mon Sep 17 00:00:00 2001 From: rferraton <16419423+rferraton@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:11:08 +0200 Subject: [PATCH 5/5] The Hand cursor is now a single cached static field (HandCursor) allocated once, used on every PointerMoved hover instead of creating a new Cursor object per frame. --- src/PlanViewer.App/Services/AdviceContentBuilder.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs index a1b6b9e..60c971a 100644 --- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs +++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs @@ -70,6 +70,7 @@ internal static class AdviceContentBuilder private static readonly Regex NodeRefRegex = new(@"(?<=\(?)\bNode\s+(\d+)\b(?=\)?)", RegexOptions.Compiled); private static readonly SolidColorBrush LinkBrush = new(Color.Parse("#4FC3F7")); + private static readonly Avalonia.Input.Cursor HandCursor = new(Avalonia.Input.StandardCursorType.Hand); public static StackPanel Build(string content) { @@ -663,7 +664,7 @@ private static void WireNodeClickHandler(SelectableTextBlock stb, Action on { stb.Cursor = run.TextDecorations == Avalonia.Media.TextDecorations.Underline && run.Foreground == LinkBrush - ? new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand) + ? HandCursor : Avalonia.Input.Cursor.Default; return; }