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;
}