diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index 6851fdb..a475b2d 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;
@@ -2865,12 +2920,40 @@ private void SetZoom(double level)
ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%";
}
+ ///
+ /// 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)
+ {
+ var newZoom = Math.Max(MinZoom, Math.Min(MaxZoom, level));
+ if (Math.Abs(newZoom - _zoomLevel) < 0.001)
+ return;
+
+ // 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;
+
+ // Apply the new zoom
+ SetZoom(newZoom);
+
+ // 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);
+
+ 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;
- SetZoom(_zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep));
+ var newLevel = _zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep);
+ SetZoomAtPoint(newLevel, e.GetPosition(PlanScrollViewer));
}
}
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
index b277089..9b2eb1f 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,82 +703,9 @@ 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);
-
- 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 panel = new DockPanel { Margin = new Avalonia.Thickness(12) };
- DockPanel.SetDock(buttonPanel, Dock.Bottom);
- panel.Children.Add(buttonPanel);
- panel.Children.Add(scrollViewer);
-
- 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
- };
-
- 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 81706bf..0a055f9 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,82 +594,9 @@ 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);
-
- 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 panel = new DockPanel { Margin = new Avalonia.Thickness(12) };
- DockPanel.SetDock(buttonPanel, Dock.Bottom);
- panel.Children.Add(buttonPanel);
- panel.Children.Add(scrollViewer);
-
- 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
- };
-
- 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/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
index eeff7d6..60c971a 100644
--- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs
+++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
@@ -66,12 +66,23 @@ 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"));
+ private static readonly Avalonia.Input.Cursor HandCursor = new(Avalonia.Input.StandardCursorType.Hand);
+
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 +421,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
+ ? HandCursor
+ : 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:",
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);
+ }
+}