diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs
index e4865b3..962e9d5 100644
--- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs
+++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs
@@ -29,6 +29,7 @@ You should have received a copy of the GNU General Public License
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Layout;
+using Avalonia.Styling;
using Avalonia.VisualTree;
using XTMF2;
using XTMF2.GUI.ViewModels;
@@ -47,14 +48,16 @@ namespace XTMF2.GUI.Controls;
public sealed class ModelSystemCanvas : Control
{
// ── Brushes / pens (shared, immutable) ───────────────────────────────
- private static readonly IBrush CanvasBackground = new SolidColorBrush(Color.FromRgb(0x1A, 0x1A, 0x2E));
+ private static readonly IBrush CanvasBackground = new SolidColorBrush(Color.FromRgb(0x1A, 0x1A, 0x2E));
+ private static readonly IBrush CanvasBackgroundLight = new SolidColorBrush(Color.FromRgb(0xF0, 0xF4, 0xF8));
private static readonly IBrush NodeFill = new SolidColorBrush(Color.FromRgb(0x2C, 0x3E, 0x50));
private static readonly IBrush NodeBorderBrush = new SolidColorBrush(Color.FromRgb(0x77, 0x88, 0x99));
private static readonly IBrush NodeSelBrush = Brushes.DodgerBlue;
private static readonly IBrush NodeTextBrush = Brushes.White;
private static readonly IBrush StartFill = new SolidColorBrush(Color.FromRgb(0xE6, 0x7E, 0x22));
private static readonly IBrush StartSelFill = Brushes.DodgerBlue;
- private static readonly IBrush StartTextBrush = Brushes.White;
+ private static readonly IBrush StartTextBrush = Brushes.White;
+ private static readonly IBrush StartTextBrushLight = new SolidColorBrush(Color.FromRgb(0x1A, 0x1A, 0x2E));
private static readonly IBrush LinkBrush = new SolidColorBrush(Color.FromRgb(0x7F, 0x8C, 0x8D));
private static readonly IBrush LinkSelBrush = Brushes.OrangeRed;
private static readonly IBrush PendingLinkBrush = new SolidColorBrush(Color.FromRgb(0x2E, 0xCC, 0x71));
@@ -65,6 +68,14 @@ public sealed class ModelSystemCanvas : Control
private static readonly IBrush GhostNodeSelBrush = Brushes.DodgerBlue;
private static readonly DashStyle GhostNodeDash = new DashStyle([6, 4], 0);
+ // Scripted-parameter syntax-highlight token colours
+ private static readonly IBrush ScriptVarKnownBrush = new SolidColorBrush(Color.FromRgb(0x44, 0xDD, 0x88)); // known variable → green
+ private static readonly IBrush ScriptVarUnknownBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0x44, 0x44)); // unrecognised identifier → red
+ private static readonly IBrush ScriptOperatorBrush = new SolidColorBrush(Color.FromRgb(0xAA, 0xBB, 0xCC)); // operators / punctuation → steel-blue
+ private static readonly IBrush ScriptNumberBrush = new SolidColorBrush(Color.FromRgb(0xB8, 0xD7, 0xFF)); // numeric literals → light blue
+ private static readonly IBrush ScriptStringBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xB8, 0x60)); // string literals → orange
+ private static readonly IBrush ScriptKeywordBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xE0, 0x82)); // true / false → gold
+
// Parameter value row
private static readonly IBrush ParamValueTextBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xE0, 0x82));
private static readonly IBrush ParamValueBg = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF));
@@ -100,9 +111,14 @@ public sealed class ModelSystemCanvas : Control
private static readonly IBrush SelectionRectFill = new SolidColorBrush(Color.FromArgb(0x2E, 0x44, 0x88, 0xFF));
private static readonly DashStyle SelectionRectDash = new DashStyle([5, 4], 0);
// Graph-paper background grid
- private static readonly Pen GridPen = new Pen(new SolidColorBrush(Color.FromArgb(0x38, 0x55, 0x77, 0xAA)), 0.5);
+ private static readonly Pen GridPen = new Pen(new SolidColorBrush(Color.FromArgb(0x38, 0x55, 0x77, 0xAA)), 0.5);
+ private static readonly Pen GridPenLight = new Pen(new SolidColorBrush(Color.FromArgb(0x60, 0x88, 0xAA, 0xCC)), 0.5);
/// 1 cm expressed in Avalonia logical pixels (96 DPI basis).
private const double GridSpacingDip = 96.0 / 2.54;
+ // Drop-shadow layers (three passes, loosest → tightest, to simulate a soft blur)
+ private static readonly IBrush ShadowBrush1 = new SolidColorBrush(Color.FromArgb(0x18, 0, 0, 0));
+ private static readonly IBrush ShadowBrush2 = new SolidColorBrush(Color.FromArgb(0x22, 0, 0, 0));
+ private static readonly IBrush ShadowBrush3 = new SolidColorBrush(Color.FromArgb(0x30, 0, 0, 0));
// ── Drawing constants ─────────────────────────────────────────────────
private const double NodeCornerRadius = 4.0;
@@ -156,6 +172,51 @@ private readonly Dictionary>
private NodeViewModel? _editingParamNode;
/// Screen position and width of the inline editor overlay (set in ).
private double _editingParamEditorX, _editingParamEditorY, _editingParamEditorW;
+ ///
+ /// Transparent, non-interactive overlay that draws syntax-highlighted tokens
+ /// on top of the scripted-parameter TextBox. Added to VisualChildren after
+ /// so it renders last (on top).
+ ///
+ private readonly ScriptSyntaxOverlay _scriptOverlay;
+ ///
+ /// Syntax-highlighted tokens for the scripted-parameter editor.
+ /// Each entry is a (text segment, brush) pair; painted left-to-right inside the TextBox region.
+ /// Empty when not editing a ScriptedParameter.
+ ///
+ private (string text, IBrush brush)[] _scriptTokens = Array.Empty<(string, IBrush)>();
+
+ /// true while is executing, used to suppress re-entrant LostFocus commits.
+ private bool _commitParamEditInProgress;
+
+ // ── Scripted-parameter variable autocomplete dropdown ─────────────────
+ /// Overlay border that contains the variable-name suggestion list.
+ private readonly Border _varDropdownBorder;
+ /// Stack of rows inside the dropdown.
+ private readonly StackPanel _varDropdownStack;
+ /// true while the variable autocomplete dropdown is open.
+ private bool _varDropdownVisible;
+ /// Character offset in where the current token starts.
+ private int _varTokenStart;
+ /// Index of the currently highlighted row in the dropdown.
+ private int _varSelectedIndex;
+ /// Maximum number of suggestions shown at once.
+ private const int MaxVarDropdownItems = 8;
+
+ // ── Inline comment editor ─────────────────────────────────────────────
+ /// Overlay multi-line TextBox used for in-canvas comment block editing.
+ private readonly TextBox _commentEditor;
+ /// The comment block currently being edited, or null when idle.
+ private CommentBlockViewModel? _editingCommentBlock;
+ /// Model-space position and size of the comment editor overlay.
+ private double _editingCommentEditorX, _editingCommentEditorY, _editingCommentEditorW, _editingCommentEditorH;
+
+ // ── Inline name editor ───────────────────────────────────────────────────
+ /// Overlay single-line TextBox used for renaming nodes and starts.
+ private readonly TextBox _nameEditor;
+ /// The element currently being renamed, or null when idle.
+ private ICanvasElement? _editingNameElement;
+ /// Model-space position and size of the name editor overlay.
+ private double _nameEditorX, _nameEditorY, _nameEditorW, _nameEditorH;
// ── Inlined BasicParameter caches (rebuilt by BuildHookAnchorCache) ───
///
@@ -193,12 +254,72 @@ public ModelSystemCanvas()
VerticalContentAlignment = Avalonia.Layout.VerticalAlignment.Center,
IsVisible = false,
};
- _inlineEditor.KeyDown += OnInlineEditorKeyDown;
- _inlineEditor.LostFocus += OnInlineEditorLostFocus;
+ _inlineEditor.KeyDown += OnInlineEditorKeyDown;
+ _inlineEditor.LostFocus += OnInlineEditorLostFocus;
+ _inlineEditor.TextChanged += OnInlineEditorTextChanged;
LogicalChildren.Add(_inlineEditor);
VisualChildren.Add(_inlineEditor);
+ // Syntax-highlight overlay – must be added AFTER _inlineEditor so it
+ // renders on top of the TextBox, not underneath it.
+ _scriptOverlay = new ScriptSyntaxOverlay();
+ LogicalChildren.Add(_scriptOverlay);
+ VisualChildren.Add(_scriptOverlay);
+ _varDropdownStack = new StackPanel { Orientation = Orientation.Vertical };
+ _varDropdownBorder = new Border
+ {
+ Child = _varDropdownStack,
+ Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x2E, 0x3E)),
+ BorderBrush = new SolidColorBrush(Color.FromRgb(0x44, 0x88, 0xCC)),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(3),
+ IsVisible = false,
+ };
+ LogicalChildren.Add(_varDropdownBorder);
+ VisualChildren.Add(_varDropdownBorder);
+
+ // Build the multi-line comment editor; Enter inserts a newline, Ctrl+Enter commits.
+ _commentEditor = new TextBox
+ {
+ FontFamily = new Avalonia.Media.FontFamily("Segoe UI, Arial, sans-serif"),
+ FontSize = CommentFontSize,
+ Foreground = CommentTextBrush,
+ Background = new SolidColorBrush(Color.FromArgb(0xF2, 0xFF, 0xF0, 0x96)),
+ BorderThickness = new Thickness(1),
+ BorderBrush = CommentBorderBrush,
+ Padding = new Thickness(6, 4, 6, 4),
+ AcceptsReturn = true,
+ TextWrapping = TextWrapping.Wrap,
+ IsVisible = false,
+ };
+ _commentEditor.LostFocus += OnCommentEditorLostFocus;
+ // Use the tunneling phase so Ctrl+Enter is intercepted before AcceptsReturn
+ // consumes the Enter keystroke and marks the event Handled.
+ _commentEditor.AddHandler(InputElement.KeyDownEvent, OnCommentEditorKeyDown,
+ Avalonia.Interactivity.RoutingStrategies.Tunnel);
+ LogicalChildren.Add(_commentEditor);
+ VisualChildren.Add(_commentEditor);
+
+ // Build the single-line name editor; Enter commits, Escape cancels.
+ _nameEditor = new TextBox
+ {
+ FontFamily = new Avalonia.Media.FontFamily("Segoe UI, Arial, sans-serif"),
+ FontSize = NodeFontSize,
+ Foreground = NodeTextBrush,
+ Background = new SolidColorBrush(Color.FromRgb(0x1A, 0x2C, 0x40)),
+ BorderThickness = new Thickness(1),
+ BorderBrush = NodeSelBrush,
+ Padding = new Thickness(4, 0, 4, 0),
+ VerticalContentAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ IsVisible = false,
+ };
+ _nameEditor.AddHandler(InputElement.KeyDownEvent, OnNameEditorKeyDown,
+ Avalonia.Interactivity.RoutingStrategies.Tunnel);
+ _nameEditor.LostFocus += OnNameEditorLostFocus;
+ LogicalChildren.Add(_nameEditor);
+ VisualChildren.Add(_nameEditor);
+
// ── Zoom control (pinned to viewport bottom-right) ────────────────
_zoomTextBox = new TextBox
{
@@ -431,6 +552,25 @@ protected override Size MeasureOverride(Size availableSize)
: NodeRenderWidth(_editingParamNode) * _scale,
HookRowHeight * _scale));
}
+ // Measure the syntax-highlight overlay (same footprint as the TextBox).
+ if (_editingParamNode is { IsScriptedParameter: true })
+ _scriptOverlay.Measure(new Size(_editingParamEditorW * _scale, HookRowHeight * _scale));
+ // Measure the variable autocomplete dropdown.
+ if (_varDropdownVisible && _editingParamNode is not null)
+ {
+ double ddW = Math.Max(180.0, _editingParamEditorW) * _scale;
+ _varDropdownBorder.Measure(new Size(ddW, 200));
+ }
+ // Measure the comment editor.
+ if (_editingCommentBlock is not null)
+ {
+ _commentEditor.Measure(new Size(_editingCommentEditorW * _scale, _editingCommentEditorH * _scale));
+ }
+ // Measure the name editor.
+ if (_editingNameElement is not null)
+ {
+ _nameEditor.Measure(new Size(_nameEditorW * _scale, _nameEditorH * _scale));
+ }
// Measure the zoom bar so ArrangeOverride can use its desired size.
_zoomBar.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
return new Size(maxX * _scale, maxY * _scale);
@@ -448,6 +588,44 @@ protected override Size ArrangeOverride(Size finalSize)
_editingParamEditorW * _scale,
HookRowHeight * _scale));
}
+ // Position the syntax-highlight overlay exactly over the TextBox.
+ if (_editingParamNode is { IsScriptedParameter: true })
+ {
+ _scriptOverlay.FontSize = HookFontSize * _scale;
+ _scriptOverlay.Arrange(new Rect(
+ _editingParamEditorX * _scale,
+ _editingParamEditorY * _scale,
+ _editingParamEditorW * _scale,
+ HookRowHeight * _scale));
+ }
+ // Position the variable autocomplete dropdown just below the inline editor.
+ if (_varDropdownVisible && _editingParamNode is not null)
+ {
+ double ddW = Math.Max(180.0, _editingParamEditorW) * _scale;
+ double ddX = _editingParamEditorX * _scale;
+ double ddY = (_editingParamEditorY + HookRowHeight) * _scale;
+ _varDropdownBorder.Arrange(new Rect(ddX, ddY, ddW, _varDropdownBorder.DesiredSize.Height));
+ }
+ // Position the comment editor over the comment block being edited.
+ if (_editingCommentBlock is not null)
+ {
+ _commentEditor.FontSize = CommentFontSize * _scale;
+ _commentEditor.Arrange(new Rect(
+ _editingCommentEditorX * _scale,
+ _editingCommentEditorY * _scale,
+ _editingCommentEditorW * _scale,
+ _editingCommentEditorH * _scale));
+ }
+ // Position the name editor over the element header being renamed.
+ if (_editingNameElement is not null)
+ {
+ _nameEditor.FontSize = NodeFontSize * _scale;
+ _nameEditor.Arrange(new Rect(
+ _nameEditorX * _scale,
+ _nameEditorY * _scale,
+ _nameEditorW * _scale,
+ _nameEditorH * _scale));
+ }
// Pin the zoom control to the bottom-right of the visible viewport.
var sv = GetScrollViewer();
var zw = _zoomBar.DesiredSize.Width;
@@ -468,8 +646,9 @@ public override void Render(DrawingContext ctx)
{
BuildHookAnchorCache();
var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height);
- ctx.DrawRectangle(CanvasBackground, null, bounds);
- DrawGridBackground(ctx, bounds);
+ bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light;
+ ctx.DrawRectangle(isLight ? CanvasBackgroundLight : CanvasBackground, null, bounds);
+ DrawGridBackground(ctx, bounds, isLight);
if (_vm is null) return;
@@ -490,7 +669,7 @@ public override void Render(DrawingContext ctx)
/// logical pixels and shift with the offset so they remain anchored
/// to model space as the user pans.
///
- private void DrawGridBackground(DrawingContext ctx, Rect bounds)
+ private void DrawGridBackground(DrawingContext ctx, Rect bounds, bool isLight = false)
{
var sv = GetScrollViewer();
double scrollX = sv?.Offset.X ?? 0;
@@ -503,13 +682,15 @@ private void DrawGridBackground(DrawingContext ctx, Rect bounds)
double phaseX = scrollX % step;
double phaseY = scrollY % step;
+ var pen = isLight ? GridPenLight : GridPen;
+
// Vertical lines
for (double x = -phaseX; x < bounds.Width; x += step)
- ctx.DrawLine(GridPen, new Point(x, 0), new Point(x, bounds.Height));
+ ctx.DrawLine(pen, new Point(x, 0), new Point(x, bounds.Height));
// Horizontal lines
for (double y = -phaseY; y < bounds.Height; y += step)
- ctx.DrawLine(GridPen, new Point(0, y), new Point(bounds.Width, y));
+ ctx.DrawLine(pen, new Point(0, y), new Point(bounds.Width, y));
}
private void RenderCommentBlocks(DrawingContext ctx)
@@ -520,6 +701,7 @@ private void RenderCommentBlocks(DrawingContext ctx)
var fill = comment.IsSelected ? CommentSelFill : CommentFill;
var border = new Pen(comment.IsSelected ? CommentSelBorder : CommentBorderBrush, NodeBorderThickness, dashStyle: DashStyle.Dash);
+ DrawRectShadow(ctx, rect, NodeCornerRadius);
ctx.DrawRectangle(fill, border, rect, NodeCornerRadius, NodeCornerRadius);
// Render wrapped comment text inside the block with clipping
@@ -1096,6 +1278,7 @@ private void RenderNodes(DrawingContext ctx)
var border = new Pen(node.IsSelected ? NodeSelBrush : NodeBorderBrush, NodeBorderThickness);
// Node background + border
+ DrawRectShadow(ctx, rect, NodeCornerRadius);
ctx.DrawRectangle(NodeFill, border, rect, NodeCornerRadius, NodeCornerRadius);
// ── Header: node name centred in the header band ──────────────
@@ -1175,12 +1358,17 @@ private void RenderNodes(DrawingContext ctx)
new Rect(node.X + 1, node.Y + NodeHeaderHeight, rw - 2, HookRowHeight));
const double textPad = 6.0;
- var display = string.IsNullOrEmpty(paramValue) ? "(no value)" : paramValue;
- var paramFt = MakeText(display, HookFontSize, ParamValueTextBrush);
- double maxW = rw - textPad * 2;
- double paramTy = rowMidY - paramFt.Height / 2.0;
- using (ctx.PushClip(new Rect(node.X + textPad, paramTy, Math.Max(0, maxW), paramFt.Height + 1)))
- ctx.DrawText(paramFt, new Point(node.X + textPad, paramTy));
+ // Skip drawing the text while this exact node is being edited —
+ // the inline TextBox (and syntax overlay) already cover that row.
+ if (node != _editingParamNode)
+ {
+ var display = string.IsNullOrEmpty(paramValue) ? "(no value)" : paramValue;
+ var paramFt = MakeText(display, HookFontSize, ParamValueTextBrush);
+ double maxW = rw - textPad * 2;
+ double paramTy = rowMidY - paramFt.Height / 2.0;
+ using (ctx.PushClip(new Rect(node.X + textPad, paramTy, Math.Max(0, maxW), paramFt.Height + 1)))
+ ctx.DrawText(paramFt, new Point(node.X + textPad, paramTy));
+ }
rowOffset = 1;
@@ -1274,6 +1462,7 @@ private void RenderGhostNodes(DrawingContext ctx)
var borderBrush = ghost.IsSelected ? GhostNodeSelBrush : GhostNodeBorderBrush;
var border = new Pen(borderBrush, NodeBorderThickness, dashStyle: GhostNodeDash);
+ DrawRectShadow(ctx, rect, NodeCornerRadius);
ctx.DrawRectangle(GhostNodeFill, border, rect, NodeCornerRadius, NodeCornerRadius);
// Ghost icon prefix ("⊙ ") to distinguish from real nodes at a glance.
@@ -1311,16 +1500,34 @@ private void RenderStarts(DrawingContext ctx)
var r = StartViewModel.Radius;
var border = new Pen(start.IsSelected ? NodeSelBrush : NodeBorderBrush, NodeBorderThickness);
+ DrawEllipseShadow(ctx, center, r, r);
ctx.DrawEllipse(fill, border, center, r, r);
// Label below the circle
- var ft = MakeText(start.Name, StartFontSize, StartTextBrush);
+ bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light;
+ var ft = MakeText(start.Name, StartFontSize, isLight ? StartTextBrushLight : StartTextBrush);
var lx = start.X + (start.Diameter - ft.Width) / 2;
var ly = start.Y + start.Diameter + 3;
ctx.DrawText(ft, new Point(lx, ly));
}
}
+ /// Draws a three-layer simulated drop shadow for a rounded rectangle.
+ private static void DrawRectShadow(DrawingContext ctx, Rect rect, double cornerRadius)
+ {
+ ctx.DrawRectangle(ShadowBrush1, null, rect.Translate(new Vector(6, 6)), cornerRadius, cornerRadius);
+ ctx.DrawRectangle(ShadowBrush2, null, rect.Translate(new Vector(4, 4)), cornerRadius, cornerRadius);
+ ctx.DrawRectangle(ShadowBrush3, null, rect.Translate(new Vector(2, 2)), cornerRadius, cornerRadius);
+ }
+
+ /// Draws a three-layer simulated drop shadow for an ellipse.
+ private static void DrawEllipseShadow(DrawingContext ctx, Point center, double rx, double ry)
+ {
+ ctx.DrawEllipse(ShadowBrush1, null, center + new Vector(6, 6), rx, ry);
+ ctx.DrawEllipse(ShadowBrush2, null, center + new Vector(4, 4), rx, ry);
+ ctx.DrawEllipse(ShadowBrush3, null, center + new Vector(2, 2), rx, ry);
+ }
+
private static FormattedText MakeText(string text, double size, IBrush foreground) =>
new FormattedText(
text,
@@ -1360,6 +1567,20 @@ protected override void OnKeyDown(KeyEventArgs e)
ClearMultiSelection();
e.Handled = true;
}
+ else if (e.Key == Key.F2 && _vm?.SelectedElement is not null)
+ {
+ var sel = _vm.SelectedElement;
+ if (sel is NodeViewModel or StartViewModel)
+ {
+ BeginNameEdit(sel);
+ e.Handled = true;
+ }
+ else if (sel is CommentBlockViewModel cmt)
+ {
+ BeginCommentEdit(cmt);
+ e.Handled = true;
+ }
+ }
}
// ── Scaling helpers ───────────────────────────────────────────────────
@@ -1468,7 +1689,9 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
var resizeHit = HitTestResizeHandle(mpos);
if (resizeHit is not null)
{
- if (_editingParamNode is not null) CommitParamEdit();
+ if (_editingParamNode is not null) CommitParamEdit();
+ if (_editingCommentBlock is not null) CommitCommentEdit();
+ if (_editingNameElement is not null) CommitNameEdit();
ClearMultiSelection();
_resizing = resizeHit;
_resizeStartPos = mpos;
@@ -1488,7 +1711,9 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
var minimizeHit = HitTestMinimizeButton(mpos);
if (minimizeHit is not null)
{
- if (_editingParamNode is not null) CommitParamEdit();
+ if (_editingParamNode is not null) CommitParamEdit();
+ if (_editingCommentBlock is not null) CommitCommentEdit();
+ if (_editingNameElement is not null) CommitNameEdit();
minimizeHit.InlineBasicParameter();
InvalidateAndMeasure();
e.Handled = true;
@@ -1519,7 +1744,14 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
return;
}
// Clicking elsewhere commits any open edit.
- if (_editingParamNode is not null) CommitParamEdit();
+ // Guard: if the click was already handled by a child (e.g. the variable
+ // autocomplete dropdown's TextBlock items), do not commit the edit.
+ if (!e.Handled)
+ {
+ if (_editingParamNode is not null) CommitParamEdit();
+ if (_editingCommentBlock is not null) CommitCommentEdit();
+ if (_editingNameElement is not null) CommitNameEdit();
+ }
}
// ── Hook toggle icon click (left button, any click count) ─────────
@@ -1554,6 +1786,33 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
e.Handled = true;
return;
}
+ // ── Double-click on a regular node: begin inline rename ────────
+ if (nodeHit is not null)
+ {
+ _vm.SelectElementCommand.Execute(nodeHit);
+ BeginNameEdit(nodeHit);
+ e.Handled = true;
+ return;
+ }
+ // ── Double-click on a start: begin inline rename ──────────────
+ var startHit = HitTest(mpos, testComments: false) as StartViewModel;
+ if (startHit is not null)
+ {
+ _vm.SelectElementCommand.Execute(startHit);
+ BeginNameEdit(startHit);
+ e.Handled = true;
+ return;
+ }
+
+ // ── Double-click on a comment block: open the inline comment editor ──
+ var commentHit = HitTest(mpos, testComments: true) as CommentBlockViewModel;
+ if (commentHit is not null)
+ {
+ _vm.SelectElementCommand.Execute(commentHit);
+ BeginCommentEdit(commentHit);
+ e.Handled = true;
+ return;
+ }
}
// For right-click (link creation) we exclude comment blocks; for all other paths we include them.
@@ -1590,7 +1849,9 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
if (isCtrlLeft)
{
// Always commit any open inline edit first.
- if (_editingParamNode is not null) CommitParamEdit();
+ if (_editingParamNode is not null) CommitParamEdit();
+ if (_editingCommentBlock is not null) CommitCommentEdit();
+ if (_editingNameElement is not null) CommitNameEdit();
if (hit is NodeViewModel or CommentBlockViewModel or GhostNodeViewModel)
{
@@ -1702,11 +1963,11 @@ protected override void OnPointerMoved(PointerEventArgs e)
var dw = mpos.X - _resizeStartPos.X;
var dh = mpos.Y - _resizeStartPos.Y;
if (_resizing is NodeViewModel resizingNode)
- resizingNode.ResizeTo(_resizeStartW + dw, _resizeStartH + dh);
+ resizingNode.ResizeToPreview(_resizeStartW + dw, _resizeStartH + dh);
else if (_resizing is CommentBlockViewModel resizingComment)
- resizingComment.ResizeTo(_resizeStartW + dw, _resizeStartH + dh);
+ resizingComment.ResizeToPreview(_resizeStartW + dw, _resizeStartH + dh);
else if (_resizing is GhostNodeViewModel resizingGhost)
- resizingGhost.ResizeTo(_resizeStartW + dw, _resizeStartH + dh);
+ resizingGhost.ResizeToPreview(_resizeStartW + dw, _resizeStartH + dh);
InvalidateAndMeasure();
e.Handled = true;
return;
@@ -1751,7 +2012,7 @@ protected override void OnPointerMoved(PointerEventArgs e)
// ── Element drag (single or group) ────────────────────────────────
if (_multiSelection.Count > 1 && _multiSelection.Contains(_dragging))
{
- // Group drag: move every element in the multi-selection by the per-frame delta.
+ // Group drag: preview every element in the multi-selection by the per-frame delta.
var dx = mpos.X - _groupDragLastPos.X;
var dy = mpos.Y - _groupDragLastPos.Y;
_groupDragLastPos = mpos;
@@ -1759,21 +2020,21 @@ protected override void OnPointerMoved(PointerEventArgs e)
{
double nx = Math.Max(0, el.X + dx);
double ny = Math.Max(0, el.Y + dy);
- if (el is NodeViewModel gnvm) gnvm.MoveTo(nx, ny);
- else if (el is StartViewModel gsvm) gsvm.MoveTo(nx, ny);
- else if (el is CommentBlockViewModel gcvm) gcvm.MoveTo(nx, ny);
- else if (el is GhostNodeViewModel ggvm) ggvm.MoveTo(nx, ny);
+ if (el is NodeViewModel gnvm) gnvm.MoveToPreview(nx, ny);
+ else if (el is StartViewModel gsvm) gsvm.MoveToPreview(nx, ny);
+ else if (el is CommentBlockViewModel gcvm) gcvm.MoveToPreview(nx, ny);
+ else if (el is GhostNodeViewModel ggvm) ggvm.MoveToPreview(nx, ny);
}
}
else
{
- // Single-element drag.
+ // Single-element drag: preview only, no session command issued yet.
var newX = Math.Max(0, mpos.X - _dragOffset.X);
var newY = Math.Max(0, mpos.Y - _dragOffset.Y);
- if (_dragging is NodeViewModel nvm) nvm.MoveTo(newX, newY);
- if (_dragging is StartViewModel svm) svm.MoveTo(newX, newY);
- if (_dragging is CommentBlockViewModel cvm) cvm.MoveTo(newX, newY);
- if (_dragging is GhostNodeViewModel gvm) gvm.MoveTo(newX, newY);
+ if (_dragging is NodeViewModel nvm) nvm.MoveToPreview(newX, newY);
+ if (_dragging is StartViewModel svm) svm.MoveToPreview(newX, newY);
+ if (_dragging is CommentBlockViewModel cvm) cvm.MoveToPreview(newX, newY);
+ if (_dragging is GhostNodeViewModel gvm) gvm.MoveToPreview(newX, newY);
}
InvalidateAndMeasure();
@@ -1828,6 +2089,13 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e)
// ── Left-button release: end resize drag ─────────────────────────
if (_resizing is not null)
{
+ // Commit the final size to the session (single undo entry).
+ if (_resizing is NodeViewModel committingNode)
+ committingNode.CommitResize();
+ else if (_resizing is CommentBlockViewModel committingComment)
+ committingComment.CommitResize();
+ else if (_resizing is GhostNodeViewModel committingGhost)
+ committingGhost.CommitResize();
_resizing = null;
e.Pointer.Capture(null);
Cursor = Cursor.Default;
@@ -1908,6 +2176,26 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e)
// ── Left-button release: end element drag ─────────────────────────
if (_dragging is null) return;
+
+ // Commit the preview position as a single session command (one undo entry).
+ if (_multiSelection.Count > 1 && _multiSelection.Contains(_dragging))
+ {
+ foreach (var el in _multiSelection)
+ {
+ if (el is NodeViewModel gnvm) gnvm.CommitMove();
+ else if (el is StartViewModel gsvm) gsvm.CommitMove();
+ else if (el is CommentBlockViewModel gcvm) gcvm.CommitMove();
+ else if (el is GhostNodeViewModel ggvm) ggvm.CommitMove();
+ }
+ }
+ else
+ {
+ if (_dragging is NodeViewModel nvm) nvm.CommitMove();
+ else if (_dragging is StartViewModel svm) svm.CommitMove();
+ else if (_dragging is CommentBlockViewModel cvm) cvm.CommitMove();
+ else if (_dragging is GhostNodeViewModel gvm) gvm.CommitMove();
+ }
+
_dragging = null;
e.Pointer.Capture(null);
InvalidateAndMeasure();
@@ -2137,11 +2425,34 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link)
/// Override width of the editor overlay (use -1 to auto-derive).
private void BeginParamEdit(NodeViewModel node, double rowX = -1, double rowY = -1, double rowW = -1)
{
+ HideVarDropdown();
_editingParamNode = node;
_editingParamEditorX = rowX >= 0 ? rowX : node.X;
_editingParamEditorY = rowY >= 0 ? rowY : node.Y + NodeHeaderHeight;
_editingParamEditorW = rowW >= 0 ? rowW : NodeRenderWidth(node);
_inlineEditor.Text = node.ParameterValueRepresentation;
+
+ // For scripted parameters the text is rendered by Render() with syntax colours;
+ // make the TextBox itself transparent so the coloured tokens show through.
+ if (node.IsScriptedParameter)
+ {
+ _inlineEditor.Foreground = Brushes.Transparent;
+ bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light;
+ _inlineEditor.CaretBrush = isLight ? Brushes.Black : Brushes.White;
+ _scriptTokens = TokenizeScript(node.ParameterValueRepresentation);
+ _scriptOverlay.Tokens = _scriptTokens;
+ _scriptOverlay.IsVisible = true;
+ }
+ else
+ {
+ _inlineEditor.Foreground = ParamValueTextBrush;
+ _inlineEditor.Background = new SolidColorBrush(Color.FromRgb(0x18, 0x28, 0x38));
+ _inlineEditor.CaretBrush = null; // default (uses Foreground)
+ _scriptTokens = Array.Empty<(string, IBrush)>();
+ _scriptOverlay.Tokens = _scriptTokens;
+ _scriptOverlay.IsVisible = false;
+ }
+
_inlineEditor.IsVisible = true;
// Re-layout so ArrangeOverride positions the TextBox at the right row.
InvalidateMeasure();
@@ -2154,31 +2465,109 @@ private void BeginParamEdit(NodeViewModel node, double rowX = -1, double rowY =
}
/// Commits the current editor text as the new parameter value.
+ ///
+ /// For ScriptedParameter nodes the value is validated before the editor is closed.
+ /// If the save fails the editor remains open so the user can correct the expression,
+ /// and an error toast is shown instead.
+ ///
private void CommitParamEdit()
{
- if (_editingParamNode is null) return;
- var node = _editingParamNode;
- var value = _inlineEditor.Text ?? string.Empty;
- // Clear first so LostFocus re-entry is guarded.
- _editingParamNode = null;
- _inlineEditor.IsVisible = false;
- if (!node.SetParameterValue(value, out var error))
- _vm?.ShowToast(error?.Message ?? "Failed to set parameter value.",
- isError: true, durationMs: 5000);
- InvalidateAndMeasure();
+ if (_commitParamEditInProgress) return;
+ _commitParamEditInProgress = true;
+ try
+ {
+ HideVarDropdown();
+ if (_editingParamNode is null) return;
+ var node = _editingParamNode;
+ var value = _inlineEditor.Text ?? string.Empty;
+
+ // Attempt to save. For ScriptedParameter this validates the expression first.
+ if (!node.SetParameterValue(value, out var error))
+ {
+ // Save failed – keep the editor open, restore focus, show the error.
+ _vm?.ShowToast(error?.Message ?? "Failed to set parameter value.",
+ isError: true, durationMs: 5000);
+ Avalonia.Threading.Dispatcher.UIThread.Post(
+ () => _inlineEditor.Focus(),
+ Avalonia.Threading.DispatcherPriority.Input);
+ return;
+ }
+
+ // Save succeeded – close the editor.
+ _editingParamNode = null;
+ _inlineEditor.IsVisible = false;
+ _inlineEditor.Foreground = ParamValueTextBrush;
+ _inlineEditor.Background = new SolidColorBrush(Color.FromRgb(0x18, 0x28, 0x38));
+ _inlineEditor.CaretBrush = null;
+ _scriptTokens = Array.Empty<(string, IBrush)>();
+ _scriptOverlay.Tokens = _scriptTokens;
+ _scriptOverlay.IsVisible = false;
+ InvalidateAndMeasure();
+ }
+ finally
+ {
+ _commitParamEditInProgress = false;
+ }
}
/// Discards the current edit without saving.
private void CancelParamEdit()
{
- _editingParamNode = null;
- _inlineEditor.IsVisible = false;
+ HideVarDropdown();
+ _editingParamNode = null;
+ _inlineEditor.IsVisible = false;
+ _inlineEditor.Foreground = ParamValueTextBrush;
+ _inlineEditor.Background = new SolidColorBrush(Color.FromRgb(0x18, 0x28, 0x38));
+ _inlineEditor.CaretBrush = null;
+ _scriptTokens = Array.Empty<(string, IBrush)>();
+ _scriptOverlay.Tokens = _scriptTokens;
+ _scriptOverlay.IsVisible = false;
InvalidateAndMeasure();
Focus();
}
private void OnInlineEditorKeyDown(object? sender, KeyEventArgs e)
{
+ // ── Variable autocomplete dropdown navigation ─────────────────────
+ if (_varDropdownVisible)
+ {
+ int count = _varDropdownStack.Children.Count;
+ if (e.Key == Key.Down)
+ {
+ _varSelectedIndex = Math.Min(_varSelectedIndex + 1, count - 1);
+ UpdateDropdownHighlight();
+ e.Handled = true;
+ return;
+ }
+ if (e.Key == Key.Up)
+ {
+ _varSelectedIndex = Math.Max(_varSelectedIndex - 1, 0);
+ UpdateDropdownHighlight();
+ e.Handled = true;
+ return;
+ }
+ if (e.Key == Key.Tab)
+ {
+ SelectCurrentDropdownItem();
+ e.Handled = true;
+ return;
+ }
+ if (e.Key is Key.Return or Key.Enter)
+ {
+ // Complete with the highlighted item; do NOT commit the whole edit.
+ SelectCurrentDropdownItem();
+ e.Handled = true;
+ return;
+ }
+ if (e.Key == Key.Escape)
+ {
+ HideVarDropdown();
+ e.Handled = true;
+ return;
+ }
+ }
+
+ // ── Standard inline-editor keys ───────────────────────────────────
if (e.Key is Key.Return or Key.Enter)
{
CommitParamEdit();
@@ -2192,13 +2581,478 @@ private void OnInlineEditorKeyDown(object? sender, KeyEventArgs e)
}
}
+ // ── Variable autocomplete helpers ─────────────────────────────────────
+
+ // ── Script syntax tokenizer ───────────────────────────────────────────
+
+ ///
+ /// Breaks into coloured segments for display in the
+ /// scripted-parameter inline editor.
+ ///
+ /// - Known model-system variables → (green)
+ /// - Unrecognised identifiers → (red)
+ /// - Numeric literals → (light-blue)
+ /// - String literals → (orange)
+ /// - true / false → (gold)
+ /// - Operators & punctuation → (steel-blue)
+ /// - Whitespace → (neutral)
+ ///
+ ///
+ private (string text, IBrush brush)[] TokenizeScript(string text)
+ {
+ if (_vm is null || string.IsNullOrEmpty(text))
+ return Array.Empty<(string, IBrush)>();
+
+ var knownNames = new HashSet(
+ _vm.ModelSystemVariables.Select(v => v.Name),
+ StringComparer.OrdinalIgnoreCase);
+
+ var tokens = new List<(string, IBrush)>();
+ int i = 0;
+ while (i < text.Length)
+ {
+ char c = text[i];
+
+ // ── String literal ─────────────────────────────────────────────
+ if (c == '"')
+ {
+ int start = i++;
+ while (i < text.Length && text[i] != '"') i++;
+ if (i < text.Length) i++; // consume closing quote
+ tokens.Add((text[start..i], ScriptStringBrush));
+ continue;
+ }
+
+ // ── Whitespace ────────────────────────────────────────────────
+ if (char.IsWhiteSpace(c))
+ {
+ int start = i;
+ while (i < text.Length && char.IsWhiteSpace(text[i])) i++;
+ tokens.Add((text[start..i], ParamValueTextBrush));
+ continue;
+ }
+
+ // ── Identifier / keyword ───────────────────────────────────────
+ if (char.IsLetter(c) || c == '_')
+ {
+ int start = i;
+ while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] == '_')) i++;
+ var word = text[start..i];
+ IBrush brush = word switch
+ {
+ "true" or "false" => ScriptKeywordBrush,
+ _ => knownNames.Contains(word)
+ ? ScriptVarKnownBrush
+ : ScriptVarUnknownBrush,
+ };
+ tokens.Add((word, brush));
+ continue;
+ }
+
+ // ── Numeric literal ──────────────────────────────────────────
+ if (char.IsDigit(c))
+ {
+ int start = i;
+ while (i < text.Length && (char.IsDigit(text[i]) || text[i] == '.')) i++;
+ tokens.Add((text[start..i], ScriptNumberBrush));
+ continue;
+ }
+
+ // ── Operator / punctuation (single or double char) ────────────────
+ {
+ int start = i++;
+ // Absorb two-char operators: &&, ||, ==, !=, >=, <=
+ if (i < text.Length && (
+ (c == '&' && text[i] == '&') ||
+ (c == '|' && text[i] == '|') ||
+ (c == '=' && text[i] == '=') ||
+ (c == '!' && text[i] == '=') ||
+ (c == '>' && text[i] == '=') ||
+ (c == '<' && text[i] == '=')))
+ {
+ i++;
+ }
+ tokens.Add((text[start..i], ScriptOperatorBrush));
+ }
+ }
+ return tokens.ToArray();
+ }
+
+ ///
+ /// Returns true for characters that terminate a variable token
+ /// in a scripted-parameter expression.
+ ///
+ private static bool IsExpressionSpecialChar(char c) =>
+ c is '+' or '-' or '*' or '/' or '^' or '?' or ':'
+ or '&' or '|' or '<' or '>' or '=' or '!' or '(' or ')' or '"';
+
+ ///
+ /// Called whenever the inline-editor text changes. When editing a
+ /// node, extracts the
+ /// token at the caret and populates (or hides) the autocomplete dropdown.
+ ///
+ private void OnInlineEditorTextChanged(object? sender, TextChangedEventArgs e)
+ {
+ // ── Syntax-highlight tokens for scripted params ─────────────────────────
+ if (_editingParamNode is { IsScriptedParameter: true })
+ {
+ _scriptTokens = TokenizeScript(_inlineEditor.Text ?? string.Empty);
+ _scriptOverlay.Tokens = _scriptTokens;
+ _scriptOverlay.InvalidateVisual();
+ }
+ else
+ {
+ _scriptTokens = Array.Empty<(string, IBrush)>();
+ _scriptOverlay.Tokens = _scriptTokens;
+ }
+
+ // ── Variable autocomplete dropdown ──────────────────────────────────
+ if (_editingParamNode is null || !_editingParamNode.IsScriptedParameter || _vm is null)
+ {
+ HideVarDropdown();
+ return;
+ }
+
+ var text = _inlineEditor.Text ?? string.Empty;
+ var caret = Math.Clamp(_inlineEditor.CaretIndex, 0, text.Length);
+
+ // Walk backwards from the caret to find the start of the current token.
+ int tokenStart = caret;
+ while (tokenStart > 0)
+ {
+ char ch = text[tokenStart - 1];
+ if (char.IsWhiteSpace(ch) || IsExpressionSpecialChar(ch))
+ break;
+ tokenStart--;
+ }
+
+ var token = text[tokenStart..caret];
+ _varTokenStart = tokenStart;
+
+ if (token.Length == 0)
+ {
+ HideVarDropdown();
+ return;
+ }
+
+ bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light;
+
+ var matches = _vm.ModelSystemVariables
+ .Where(v => v.Name.Contains(token, StringComparison.OrdinalIgnoreCase))
+ .Select(v => v.Name)
+ .OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
+ .Take(MaxVarDropdownItems)
+ .ToList();
+
+ if (matches.Count == 0)
+ {
+ HideVarDropdown();
+ return;
+ }
+
+ var normalBg = isLight
+ ? new SolidColorBrush(Color.FromRgb(0xF8, 0xF9, 0xFF))
+ : new SolidColorBrush(Color.FromRgb(0x1E, 0x2E, 0x3E));
+ IBrush normalFg = isLight ? Brushes.Black : Brushes.White;
+
+ _varDropdownBorder.Background = normalBg;
+ _varDropdownBorder.BorderBrush = isLight
+ ? new SolidColorBrush(Color.FromRgb(0x88, 0xAA, 0xCC))
+ : new SolidColorBrush(Color.FromRgb(0x44, 0x88, 0xCC));
+
+ _varDropdownStack.Children.Clear();
+ foreach (var name in matches)
+ {
+ var captured = name;
+ var tb = new TextBlock
+ {
+ Text = captured,
+ Padding = new Thickness(8, 3, 8, 3),
+ Foreground = normalFg,
+ Background = normalBg,
+ FontSize = HookFontSize,
+ FontFamily = new Avalonia.Media.FontFamily("Segoe UI, Arial, sans-serif"),
+ };
+ tb.PointerEntered += (_, _) =>
+ {
+ tb.Background = new SolidColorBrush(Color.FromRgb(0x20, 0x60, 0xA0));
+ tb.Foreground = Brushes.White;
+ };
+ tb.PointerExited += (_, _) =>
+ {
+ // UpdateDropdownHighlight will repaint based on _varSelectedIndex.
+ UpdateDropdownHighlight();
+ };
+ tb.PointerPressed += (_, pe) =>
+ {
+ CompleteVariable(captured);
+ pe.Handled = true;
+ };
+ _varDropdownStack.Children.Add(tb);
+ }
+
+ _varSelectedIndex = 0;
+ UpdateDropdownHighlight();
+ _varDropdownBorder.IsVisible = true;
+ _varDropdownVisible = true;
+ InvalidateMeasure();
+ }
+
+ /// Repaints the selection highlight so only the row at is highlighted.
+ private void UpdateDropdownHighlight()
+ {
+ bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light;
+ var normalBg = isLight
+ ? new SolidColorBrush(Color.FromRgb(0xF8, 0xF9, 0xFF))
+ : new SolidColorBrush(Color.FromRgb(0x1E, 0x2E, 0x3E));
+ var selBg = new SolidColorBrush(Color.FromRgb(0x20, 0x60, 0xA0));
+ IBrush normalFg = isLight ? Brushes.Black : Brushes.White;
+
+ for (int i = 0; i < _varDropdownStack.Children.Count; i++)
+ {
+ if (_varDropdownStack.Children[i] is not TextBlock tb) continue;
+ bool sel = i == _varSelectedIndex;
+ tb.Background = sel ? selBg : normalBg;
+ tb.Foreground = sel ? Brushes.White : normalFg;
+ }
+ }
+
+ /// Completes the current token with the currently highlighted dropdown item.
+ private void SelectCurrentDropdownItem()
+ {
+ int count = _varDropdownStack.Children.Count;
+ if (_varSelectedIndex < 0 || _varSelectedIndex >= count) return;
+ if (_varDropdownStack.Children[_varSelectedIndex] is TextBlock tb && tb.Text is { } name)
+ CompleteVariable(name);
+ }
+
+ ///
+ /// Replaces the token starting at up to the current
+ /// caret position with , then closes the dropdown.
+ /// The editor remains open so the user can continue typing.
+ ///
+ private void CompleteVariable(string name)
+ {
+ var text = _inlineEditor.Text ?? string.Empty;
+ var caret = Math.Clamp(_inlineEditor.CaretIndex, 0, text.Length);
+ _inlineEditor.Text = text[.._varTokenStart] + name + text[caret..];
+ _inlineEditor.CaretIndex = _varTokenStart + name.Length;
+ HideVarDropdown();
+ // Clicking a TextBlock item shifted focus to the canvas; return it to the
+ // inline editor so the user can keep typing without clicking again.
+ Avalonia.Threading.Dispatcher.UIThread.Post(
+ () => _inlineEditor.Focus(),
+ Avalonia.Threading.DispatcherPriority.Input);
+ }
+
+ /// Hides and clears the variable autocomplete dropdown.
+ private void HideVarDropdown()
+ {
+ if (!_varDropdownVisible) return;
+ _varDropdownVisible = false;
+ _varDropdownBorder.IsVisible = false;
+ _varDropdownStack.Children.Clear();
+ InvalidateMeasure();
+ }
+
private void OnInlineEditorLostFocus(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
+ // When the variable autocomplete dropdown is visible the user may have clicked
+ // a suggestion item. TextBlock items are non-focusable, so focus falls to the
+ // canvas. We must NOT commit here; CompleteVariable() keeps the session open
+ // and will immediately return focus to the inline editor.
+ if (_varDropdownVisible) return;
+
// Commit on focus loss (e.g. user clicks away to another element).
if (_editingParamNode is not null)
CommitParamEdit();
}
+ ///
+ /// Opens the inline comment editor for the currently selected comment block.
+ /// Called externally (e.g. from the F2 key handler in the editor view).
+ /// Does nothing if the selected element is not a .
+ ///
+ // ── Inline name editor helpers ───────────────────────────────────────────
+
+ /// Opens the single-line name editor over (node or start).
+ private void BeginNameEdit(ICanvasElement element)
+ {
+ CommitParamEdit();
+ CommitCommentEdit();
+
+ bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light;
+ _nameEditor.Foreground = isLight ? Brushes.Black : Brushes.White;
+ _nameEditor.Background = isLight
+ ? new SolidColorBrush(Color.FromRgb(0xE8, 0xF0, 0xFE))
+ : new SolidColorBrush(Color.FromRgb(0x1A, 0x2C, 0x40));
+
+ if (element is NodeViewModel nvm)
+ {
+ _nameEditorX = nvm.X;
+ _nameEditorY = nvm.Y;
+ _nameEditorW = NodeRenderWidth(nvm);
+ _nameEditorH = NodeHeaderHeight;
+ }
+ else if (element is StartViewModel svm)
+ {
+ _nameEditorX = svm.X - StartViewModel.Radius;
+ _nameEditorY = svm.Y + StartViewModel.Radius + 2;
+ _nameEditorW = svm.Diameter + 20;
+ _nameEditorH = NodeHeaderHeight;
+ }
+ else
+ {
+ return;
+ }
+
+ _editingNameElement = element;
+ _nameEditor.Text = element.Name;
+ _nameEditor.IsVisible = true;
+ InvalidateMeasure();
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ _nameEditor.Focus();
+ _nameEditor.SelectAll();
+ }, Avalonia.Threading.DispatcherPriority.Render);
+ }
+
+ /// Saves the name editor text and closes the editor.
+ private void CommitNameEdit()
+ {
+ if (_editingNameElement is null) return;
+ var element = _editingNameElement;
+ var name = (_nameEditor.Text ?? string.Empty).Trim();
+ _editingNameElement = null;
+ _nameEditor.IsVisible = false;
+ if (!string.IsNullOrWhiteSpace(name))
+ {
+ _ = element switch
+ {
+ NodeViewModel nvm => nvm.SetName(name, out _),
+ StartViewModel svm => svm.SetName(name, out _),
+ _ => true,
+ };
+ }
+ InvalidateAndMeasure();
+ }
+
+ /// Discards the name edit without saving.
+ private void CancelNameEdit()
+ {
+ _editingNameElement = null;
+ _nameEditor.IsVisible = false;
+ InvalidateAndMeasure();
+ Focus();
+ }
+
+ private void OnNameEditorKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key is Key.Return or Key.Enter)
+ {
+ CommitNameEdit();
+ Focus();
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Escape)
+ {
+ CancelNameEdit();
+ e.Handled = true;
+ }
+ }
+
+ private void OnNameEditorLostFocus(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (_editingNameElement is not null)
+ CommitNameEdit();
+ }
+
+ ///
+ /// Opens the inline name editor for the currently selected node or start.
+ /// Called from the editor view when F2 is pressed and a node/start is selected.
+ ///
+ public void BeginNameEditForSelected()
+ {
+ if (_vm?.SelectedElement is NodeViewModel or StartViewModel)
+ BeginNameEdit(_vm.SelectedElement);
+ }
+
+ // ── Inline comment editor helpers ────────────────────────────────────────
+
+ public void BeginCommentEditForSelected()
+ {
+ if (_vm?.SelectedElement is CommentBlockViewModel comment)
+ BeginCommentEdit(comment);
+ }
+
+ /// Shows the multi-line comment editor over .
+ private void BeginCommentEdit(CommentBlockViewModel comment)
+ {
+ _editingCommentBlock = comment;
+ _editingCommentEditorX = comment.X;
+ _editingCommentEditorY = comment.Y;
+ _editingCommentEditorW = comment.Width;
+ _editingCommentEditorH = comment.Height;
+ _commentEditor.Text = comment.Name; // Name returns the underlying Comment text.
+ // Pick colours based on the active theme.
+ bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light;
+ _commentEditor.Foreground = isLight
+ ? CommentTextBrush // dark text on sticky-note yellow
+ : Brushes.White; // white text on dark background
+ _commentEditor.Background = isLight
+ ? new SolidColorBrush(Color.FromArgb(0xF2, 0xFF, 0xF0, 0x96)) // sticky-note yellow
+ : new SolidColorBrush(Color.FromRgb(0x18, 0x28, 0x18)); // dark green-tinted panel
+ _commentEditor.IsVisible = true;
+ InvalidateMeasure();
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ _commentEditor.Focus();
+ }, Avalonia.Threading.DispatcherPriority.Render);
+ }
+
+ /// Saves the comment editor text and closes the editor.
+ private void CommitCommentEdit()
+ {
+ if (_editingCommentBlock is null) return;
+ var comment = _editingCommentBlock;
+ var text = _commentEditor.Text ?? string.Empty;
+ _editingCommentBlock = null;
+ _commentEditor.IsVisible = false;
+ comment.SetText(text);
+ InvalidateAndMeasure();
+ }
+
+ /// Discards the comment edit without saving.
+ private void CancelCommentEdit()
+ {
+ _editingCommentBlock = null;
+ _commentEditor.IsVisible = false;
+ InvalidateAndMeasure();
+ Focus();
+ }
+
+ private void OnCommentEditorKeyDown(object? sender, KeyEventArgs e)
+ {
+ if ((e.Key is Key.Return or Key.Enter) && (e.KeyModifiers & KeyModifiers.Control) != 0)
+ {
+ // Ctrl+Enter commits; plain Enter inserts a newline (default).
+ CommitCommentEdit();
+ Focus();
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Escape)
+ {
+ CancelCommentEdit();
+ e.Handled = true;
+ }
+ }
+
+ private void OnCommentEditorLostFocus(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (_editingCommentBlock is not null)
+ CommitCommentEdit();
+ }
+
///
/// Returns the whose resize handle (bottom-right
/// corner square) contains , or null if none.
@@ -2425,4 +3279,5 @@ private void RenderSelectionRect(DrawingContext ctx)
var pen = new Pen(Brushes.CornflowerBlue, 1.5 / _scale, SelectionRectDash);
ctx.DrawRectangle(SelectionRectFill, pen, rect, 2 / _scale, 2 / _scale);
}
+
}
diff --git a/src/XTMF2.GUI/Controls/ScriptSyntaxOverlay.cs b/src/XTMF2.GUI/Controls/ScriptSyntaxOverlay.cs
new file mode 100644
index 0000000..dd3ea24
--- /dev/null
+++ b/src/XTMF2.GUI/Controls/ScriptSyntaxOverlay.cs
@@ -0,0 +1,88 @@
+/*
+ Copyright 2026 University of Toronto
+
+ This file is part of XTMF2.
+
+ XTMF2 is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ XTMF2 is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with XTMF2. If not, see .
+*/
+using System;
+using System.Globalization;
+using System.Text;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace XTMF2.GUI.Controls;
+
+///
+/// A transparent, non-interactive overlay control that paints syntax-highlighted
+/// tokens over the scripted-parameter . It is added to
+/// after the TextBox so it renders on top of it.
+///
+internal sealed class ScriptSyntaxOverlay : Control
+{
+ private static readonly Typeface OverlayTypeface = new("Segoe UI, Arial, sans-serif");
+
+ public (string text, IBrush brush)[] Tokens { get; set; } = Array.Empty<(string, IBrush)>();
+
+ ///
+ /// Scaled font size to use when drawing; updated by
+ /// on every layout pass so the text matches the current zoom level.
+ ///
+ public double FontSize { get; set; } = 10.0;
+
+ public ScriptSyntaxOverlay()
+ {
+ IsHitTestVisible = false;
+ IsVisible = false;
+ }
+
+ public override void Render(DrawingContext ctx)
+ {
+ if (Tokens.Length == 0) return;
+
+ // 4 px left-pad matches the TextBox inner padding so the first character
+ // lines up with the native cursor position.
+ const double leftPad = 4.0;
+
+ // Build the full expression string from all token segments so that
+ // Avalonia measures it in one pass. Per-token width accumulation is
+ // unreliable: FormattedText.Width excludes trailing whitespace, so any
+ // whitespace-only token advances x by 0 and every subsequent token is
+ // shifted left. Drawing as a single FormattedText with SetForegroundBrush
+ // span colouring avoids the issue entirely.
+ var sb = new StringBuilder();
+ foreach (var (seg, _) in Tokens)
+ sb.Append(seg);
+ string fullText = sb.ToString();
+
+ var ft = new FormattedText(
+ fullText,
+ CultureInfo.InvariantCulture,
+ FlowDirection.LeftToRight,
+ OverlayTypeface,
+ FontSize,
+ Brushes.White); // default; overridden per span below
+
+ int offset = 0;
+ foreach (var (seg, brush) in Tokens)
+ {
+ ft.SetForegroundBrush(brush, offset, seg.Length);
+ offset += seg.Length;
+ }
+
+ double ty = (Bounds.Height - ft.Height) / 2.0;
+ ctx.DrawText(ft, new Point(leftPad, ty));
+ }
+}
diff --git a/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs b/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs
index bad4546..71ab9a1 100644
--- a/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs
+++ b/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs
@@ -42,18 +42,23 @@ public sealed partial class CommentBlockViewModel : ObservableObject, ICanvasEle
private readonly ModelSystemSession _session;
private readonly User _user;
- // ── Coordinates read directly from the underlying model ──────────────
+ // ── Coordinates read directly from the underlying model (or preview during drag) ─
+ private double? _previewX;
+ private double? _previewY;
+ private double? _previewW;
+ private double? _previewH;
+
///
- public double X => (double)UnderlyingBlock.Location.X;
+ public double X => _previewX ?? (double)UnderlyingBlock.Location.X;
///
- public double Y => (double)UnderlyingBlock.Location.Y;
+ public double Y => _previewY ?? (double)UnderlyingBlock.Location.Y;
/// Rendered width; falls back to when the model value is 0.
- public double Width => UnderlyingBlock.Location.Width is 0 ? DefaultWidth : (double)UnderlyingBlock.Location.Width;
+ public double Width => _previewW ?? (UnderlyingBlock.Location.Width is 0 ? DefaultWidth : (double)UnderlyingBlock.Location.Width);
/// Rendered height; falls back to when the model value is 0.
- public double Height => UnderlyingBlock.Location.Height is 0 ? DefaultHeight : (double)UnderlyingBlock.Location.Height;
+ public double Height => _previewH ?? (UnderlyingBlock.Location.Height is 0 ? DefaultHeight : (double)UnderlyingBlock.Location.Height);
// ICanvasElement: Name maps to Comment so the property panel can reuse SelectedElementEditName.
///
@@ -94,6 +99,34 @@ private void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
}
}
+ ///
+ /// Updates the visual position without touching the session (for drag preview).
+ /// Call on mouse-up to persist the change.
+ ///
+ public void MoveToPreview(double x, double y)
+ {
+ _previewX = x;
+ _previewY = y;
+ OnPropertyChanged(nameof(X));
+ OnPropertyChanged(nameof(Y));
+ OnPropertyChanged(nameof(CenterX));
+ OnPropertyChanged(nameof(CenterY));
+ }
+
+ ///
+ /// Commits the current preview position to the session (call once on mouse-up).
+ /// Does nothing if no preview is active.
+ ///
+ public void CommitMove()
+ {
+ if (_previewX is null) return;
+ var x = _previewX.Value;
+ var y = _previewY!.Value;
+ _previewX = null;
+ _previewY = null;
+ MoveTo(x, y);
+ }
+
///
/// Move the comment block to a new canvas position, persisting the change via the session
/// (supports undo/redo).
@@ -108,6 +141,45 @@ public void MoveTo(double x, double y)
// OnModelPropertyChanged("Location") fires automatically and propagates X/Y changes.
}
+ ///
+ /// Update the comment text, persisting the change via the session (supports undo/redo).
+ /// Whitespace-only text is ignored.
+ ///
+ public void SetText(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return;
+ _session.SetCommentBlockText(_user, UnderlyingBlock, text, out _);
+ }
+
+ ///
+ /// Updates the visual size without touching the session (for resize-drag preview).
+ /// Call on mouse-up to persist the change.
+ /// Width is clamped to a minimum of 60; height to a minimum of 30.
+ ///
+ public void ResizeToPreview(double w, double h)
+ {
+ _previewW = Math.Max(60.0, w);
+ _previewH = Math.Max(30.0, h);
+ OnPropertyChanged(nameof(Width));
+ OnPropertyChanged(nameof(Height));
+ OnPropertyChanged(nameof(CenterX));
+ OnPropertyChanged(nameof(CenterY));
+ }
+
+ ///
+ /// Commits the current preview size to the session (call once on mouse-up).
+ /// Does nothing if no resize preview is active.
+ ///
+ public void CommitResize()
+ {
+ if (_previewW is null) return;
+ var w = _previewW.Value;
+ var h = _previewH!.Value;
+ _previewW = null;
+ _previewH = null;
+ ResizeTo(w, h);
+ }
+
///
/// Resize the comment block, persisting the change via the session (supports undo/redo).
/// Width is clamped to a minimum of 60; height to a minimum of 30.
diff --git a/src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs b/src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs
index cb8648f..282e099 100644
--- a/src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs
+++ b/src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs
@@ -38,18 +38,23 @@ public sealed partial class GhostNodeViewModel : ObservableObject, ICanvasElemen
private readonly ModelSystemSession _session;
private readonly User _user;
- // ── Coordinates read directly from the underlying model ──────────────
+ // ── Coordinates read directly from the underlying model (or preview during drag) ─
+ private double? _previewX;
+ private double? _previewY;
+ private double? _previewW;
+ private double? _previewH;
+
///
- public double X => (double)UnderlyingGhostNode.Location.X;
+ public double X => _previewX ?? (double)UnderlyingGhostNode.Location.X;
///
- public double Y => (double)UnderlyingGhostNode.Location.Y;
+ public double Y => _previewY ?? (double)UnderlyingGhostNode.Location.Y;
/// Rendered width; falls back to 120 when the model value is 0.
- public double Width => UnderlyingGhostNode.Location.Width is 0 ? 120.0 : (double)UnderlyingGhostNode.Location.Width;
+ public double Width => _previewW ?? (UnderlyingGhostNode.Location.Width is 0 ? 120.0 : (double)UnderlyingGhostNode.Location.Width);
/// Rendered height; falls back to 50 when the model value is 0.
- public double Height => UnderlyingGhostNode.Location.Height is 0 ? 50.0 : (double)UnderlyingGhostNode.Location.Height;
+ public double Height => _previewH ?? (UnderlyingGhostNode.Location.Height is 0 ? 50.0 : (double)UnderlyingGhostNode.Location.Height);
///
public double CenterX => X + Width / 2.0;
@@ -88,6 +93,34 @@ private void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
}
}
+ ///
+ /// Updates the visual position without touching the session (for drag preview).
+ /// Call on mouse-up to persist the change.
+ ///
+ public void MoveToPreview(double x, double y)
+ {
+ _previewX = x;
+ _previewY = y;
+ OnPropertyChanged(nameof(X));
+ OnPropertyChanged(nameof(Y));
+ OnPropertyChanged(nameof(CenterX));
+ OnPropertyChanged(nameof(CenterY));
+ }
+
+ ///
+ /// Commits the current preview position to the session (call once on mouse-up).
+ /// Does nothing if no preview is active.
+ ///
+ public void CommitMove()
+ {
+ if (_previewX is null) return;
+ var x = _previewX.Value;
+ var y = _previewY!.Value;
+ _previewX = null;
+ _previewY = null;
+ MoveTo(x, y);
+ }
+
///
/// Move the ghost node to a new canvas position, persisting via the session
/// (supports undo/redo).
@@ -101,6 +134,35 @@ public void MoveTo(double x, double y)
new Rectangle((float)x, (float)y, w, h), out _);
}
+ ///
+ /// Updates the visual size without touching the session (for resize-drag preview).
+ /// Call on mouse-up to persist the change.
+ /// Width is clamped to a minimum of 120; height to a minimum of 28.
+ ///
+ public void ResizeToPreview(double w, double h)
+ {
+ _previewW = Math.Max(120.0, w);
+ _previewH = Math.Max(28.0, h);
+ OnPropertyChanged(nameof(Width));
+ OnPropertyChanged(nameof(Height));
+ OnPropertyChanged(nameof(CenterX));
+ OnPropertyChanged(nameof(CenterY));
+ }
+
+ ///
+ /// Commits the current preview size to the session (call once on mouse-up).
+ /// Does nothing if no resize preview is active.
+ ///
+ public void CommitResize()
+ {
+ if (_previewW is null) return;
+ var w = _previewW.Value;
+ var h = _previewH!.Value;
+ _previewW = null;
+ _previewH = null;
+ ResizeTo(w, h);
+ }
+
///
/// Resize the ghost node, persisting via the session.
/// Width is clamped to a minimum of 120; height to a minimum of 28.
diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
index a06c83e..99dd9d9 100644
--- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
+++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
@@ -129,6 +129,31 @@ public string CurrentBoundaryLabel
/// Observable view-models for the model system's variable list.
public ObservableCollection ModelSystemVariables { get; } = new();
+ /// Text typed into the variables filter box; filters .
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(FilteredModelSystemVariables))]
+ private string _variableFilter = string.Empty;
+
+ ///
+ /// Sorted (by name) and filtered (by ) view of
+ /// . Matches on name or boundary path.
+ ///
+ public IEnumerable FilteredModelSystemVariables
+ {
+ get
+ {
+ var q = ModelSystemVariables.AsEnumerable();
+ if (!string.IsNullOrWhiteSpace(VariableFilter))
+ {
+ var f = VariableFilter.Trim();
+ q = q.Where(v =>
+ v.Name.Contains(f, StringComparison.OrdinalIgnoreCase) ||
+ v.BoundaryPath.Contains(f, StringComparison.OrdinalIgnoreCase));
+ }
+ return q.OrderBy(v => v.Name, StringComparer.OrdinalIgnoreCase);
+ }
+ }
+
/// The currently selected link, if any. Mutually exclusive with .
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NothingSelected))]
@@ -193,6 +218,12 @@ partial void OnNodeSearchSelectionChanged(NodeViewModel? value)
///
public bool SelectedElementIsNode => SelectedElement is NodeViewModel;
+ /// True when the selected element is a .
+ public bool SelectedElementIsComment => SelectedElement is CommentBlockViewModel;
+
+ /// True when the selected element is NOT a , used to hide the rename box for comments.
+ public bool SelectedElementIsNotComment => SelectedElement is not CommentBlockViewModel;
+
///
/// True when the selected node is a BasicParameter or ScriptedParameter,
/// used to gate the parameter value editor in the property panel.
@@ -256,6 +287,8 @@ partial void OnSelectedElementChanged(ICanvasElement? value)
OnPropertyChanged(nameof(SelectedElementFieldLabel));
OnPropertyChanged(nameof(SelectedElementTypeName));
OnPropertyChanged(nameof(SelectedElementIsNode));
+ OnPropertyChanged(nameof(SelectedElementIsComment));
+ OnPropertyChanged(nameof(SelectedElementIsNotComment));
OnPropertyChanged(nameof(SelectedElementIsParameter));
SelectedElementParameterValue =
value is NodeViewModel pnvm && pnvm.IsParameterNode
@@ -1216,6 +1249,7 @@ private void OnModelSystemVariablesChanged(object? sender,
// Full rebuild keeps the code simple; the list is expected to be small.
SyncModelSystemVariables();
OnPropertyChanged(nameof(HasNoModelSystemVariables));
+ OnPropertyChanged(nameof(FilteredModelSystemVariables));
}
/// Commit the name/comment currently in back to the model.
@@ -1306,15 +1340,16 @@ private async Task RunModelSystem()
}
else
{
- // Multiple starts: ask the user to type the start name (listing the options).
- var startList = string.Join(", ", availableStarts.Select(s => s.Name));
- var startDialog = new InputDialog(
+ // Multiple starts: show a ComboBox so the user can pick one.
+ var startNames = availableStarts.Select(s => s.Name).ToList();
+ var startDialog = new StartPickerDialog(
title: "Select Start",
- prompt: $"Available starts: {startList}\nEnter the start to execute:",
- defaultText: availableStarts[0].Name);
+ prompt: "Select the start to execute:",
+ startNames: startNames,
+ defaultStart: startNames[0]);
await startDialog.ShowDialog(ParentWindow);
if (startDialog.WasCancelled) return;
- startToExecute = startDialog.InputText?.Trim() ?? availableStarts[0].Name;
+ startToExecute = startDialog.SelectedStartName ?? startNames[0];
if (string.IsNullOrEmpty(startToExecute)) return;
}
diff --git a/src/XTMF2.GUI/ViewModels/NodeViewModel.cs b/src/XTMF2.GUI/ViewModels/NodeViewModel.cs
index ecff9f7..f97255f 100644
--- a/src/XTMF2.GUI/ViewModels/NodeViewModel.cs
+++ b/src/XTMF2.GUI/ViewModels/NodeViewModel.cs
@@ -39,18 +39,23 @@ public sealed partial class NodeViewModel : ObservableObject, ICanvasElement
private readonly ModelSystemSession _session;
private readonly User _user;
- // ── Coordinates read directly from the underlying model ──────────────
+ // ── Coordinates read directly from the underlying model (or preview during drag) ─
+ private double? _previewX;
+ private double? _previewY;
+ private double? _previewW;
+ private double? _previewH;
+
///
- public double X => (double)UnderlyingNode.Location.X;
+ public double X => _previewX ?? (double)UnderlyingNode.Location.X;
///
- public double Y => (double)UnderlyingNode.Location.Y;
+ public double Y => _previewY ?? (double)UnderlyingNode.Location.Y;
/// Rendered width; falls back to 120 when the model value is 0.
- public double Width => UnderlyingNode.Location.Width is 0 ? 120.0 : (double)UnderlyingNode.Location.Width;
+ public double Width => _previewW ?? (UnderlyingNode.Location.Width is 0 ? 120.0 : (double)UnderlyingNode.Location.Width);
/// Rendered height; falls back to 50 when the model value is 0.
- public double Height => UnderlyingNode.Location.Height is 0 ? 50.0 : (double)UnderlyingNode.Location.Height;
+ public double Height => _previewH ?? (UnderlyingNode.Location.Height is 0 ? 50.0 : (double)UnderlyingNode.Location.Height);
/// Centre X, used to compute link endpoints after a move.
public double CenterX => X + Width / 2.0;
@@ -135,6 +140,34 @@ private void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
}
}
+ ///
+ /// Updates the visual position without touching the session (for drag preview).
+ /// Call on mouse-up to persist the change.
+ ///
+ public void MoveToPreview(double x, double y)
+ {
+ _previewX = x;
+ _previewY = y;
+ OnPropertyChanged(nameof(X));
+ OnPropertyChanged(nameof(Y));
+ OnPropertyChanged(nameof(CenterX));
+ OnPropertyChanged(nameof(CenterY));
+ }
+
+ ///
+ /// Commits the current preview position to the session (call once on mouse-up).
+ /// Does nothing if no preview is active.
+ ///
+ public void CommitMove()
+ {
+ if (_previewX is null) return;
+ var x = _previewX.Value;
+ var y = _previewY!.Value;
+ _previewX = null;
+ _previewY = null;
+ MoveTo(x, y);
+ }
+
///
/// Move the node to a new canvas position, persisting the change to the
/// underlying model via the session (supports undo/redo).
@@ -149,6 +182,39 @@ public void MoveTo(double x, double y)
// PropertyChanged for X, Y, CenterX, CenterY automatically.
}
+ /// Rename the node, persisting the change via the session (supports undo/redo).
+ public bool SetName(string name, out CommandError? error)
+ => _session.SetNodeName(_user, UnderlyingNode, name, out error);
+
+ ///
+ /// Updates the visual size without touching the session (for resize-drag preview).
+ /// Call on mouse-up to persist the change.
+ /// Width is clamped to a minimum of 120; height to a minimum of 28.
+ ///
+ public void ResizeToPreview(double w, double h)
+ {
+ _previewW = Math.Max(120.0, w);
+ _previewH = Math.Max(28.0, h);
+ OnPropertyChanged(nameof(Width));
+ OnPropertyChanged(nameof(Height));
+ OnPropertyChanged(nameof(CenterX));
+ OnPropertyChanged(nameof(CenterY));
+ }
+
+ ///
+ /// Commits the current preview size to the session (call once on mouse-up).
+ /// Does nothing if no resize preview is active.
+ ///
+ public void CommitResize()
+ {
+ if (_previewW is null) return;
+ var w = _previewW.Value;
+ var h = _previewH!.Value;
+ _previewW = null;
+ _previewH = null;
+ ResizeTo(w, h);
+ }
+
///
/// Resize the node, persisting the change via the session (supports undo/redo).
/// Width is clamped to a minimum of 120; height to a minimum of 28.
diff --git a/src/XTMF2.GUI/ViewModels/StartViewModel.cs b/src/XTMF2.GUI/ViewModels/StartViewModel.cs
index e95ce48..dda7e50 100644
--- a/src/XTMF2.GUI/ViewModels/StartViewModel.cs
+++ b/src/XTMF2.GUI/ViewModels/StartViewModel.cs
@@ -39,12 +39,15 @@ public sealed partial class StartViewModel : ObservableObject, ICanvasElement
private readonly ModelSystemSession _session;
private readonly User _user;
- // ── Coordinates read directly from the underlying model ──────────────
+ // ── Coordinates read directly from the underlying model (or preview during drag) ─
+ private double? _previewX;
+ private double? _previewY;
+
///
- public double X => (double)UnderlyingStart.Location.X;
+ public double X => _previewX ?? (double)UnderlyingStart.Location.X;
///
- public double Y => (double)UnderlyingStart.Location.Y;
+ public double Y => _previewY ?? (double)UnderlyingStart.Location.Y;
/// Centre X of the circle.
public double CenterX => X + Radius;
@@ -85,6 +88,34 @@ private void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
}
}
+ ///
+ /// Updates the visual position without touching the session (for drag preview).
+ /// Call on mouse-up to persist the change.
+ ///
+ public void MoveToPreview(double x, double y)
+ {
+ _previewX = x;
+ _previewY = y;
+ OnPropertyChanged(nameof(X));
+ OnPropertyChanged(nameof(Y));
+ OnPropertyChanged(nameof(CenterX));
+ OnPropertyChanged(nameof(CenterY));
+ }
+
+ ///
+ /// Commits the current preview position to the session (call once on mouse-up).
+ /// Does nothing if no preview is active.
+ ///
+ public void CommitMove()
+ {
+ if (_previewX is null) return;
+ var x = _previewX.Value;
+ var y = _previewY!.Value;
+ _previewX = null;
+ _previewY = null;
+ MoveTo(x, y);
+ }
+
///
/// Move the start to a new canvas position, persisting the change to the
/// underlying model via the session (supports undo/redo).
@@ -96,4 +127,8 @@ public void MoveTo(double x, double y)
// OnModelPropertyChanged("Location") is fired by the model; it raises
// PropertyChanged for X, Y, CenterX, CenterY automatically.
}
+
+ /// Rename the start, persisting the change via the session (supports undo/redo).
+ public bool SetName(string name, out CommandError? error)
+ => _session.SetNodeName(_user, UnderlyingStart, name, out error);
}
diff --git a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml
index 68b2602..474db91 100644
--- a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml
+++ b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml
@@ -176,18 +176,6 @@
-
-
-
-
-
+
diff --git a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml.cs b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml.cs
index 2443613..6f4e944 100644
--- a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml.cs
+++ b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml.cs
@@ -42,12 +42,12 @@ public ModelSystemEditorView()
DataContextChanged += OnDataContextChanged;
AttachedToVisualTree += OnAttachedToVisualTree;
- // Pressing Enter in the name box commits the rename without requiring the Rename button.
- NameEditBox.KeyDown += OnNameEditBoxKeyDown;
-
// Pressing Enter in the parameter value box commits the value.
ParameterValueEditBox.KeyDown += OnParameterValueEditBoxKeyDown;
+ // Escape in the variable filter box clears the filter.
+ VariableFilterBox.KeyDown += OnVariableFilterBoxKeyDown;
+
// F2 anywhere in this view focuses the rename box (when an element is selected).
KeyDown += OnViewKeyDown;
@@ -71,8 +71,15 @@ private void OnViewKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.F2 && _vm?.SelectedElement is not null)
{
- NameEditBox.Focus();
- NameEditBox.SelectAll();
+ if (_vm.SelectedElement is CommentBlockViewModel)
+ {
+ TheCanvas.BeginCommentEditForSelected();
+ }
+ else
+ {
+ // Route F2 for nodes/starts to the inline canvas name editor.
+ TheCanvas.BeginNameEditForSelected();
+ }
e.Handled = true;
}
else if (e.Key == Key.S && e.KeyModifiers.HasFlag(KeyModifiers.Control))
@@ -93,10 +100,13 @@ private void OnViewKeyDown(object? sender, KeyEventArgs e)
}
}
- private void OnNameEditBoxKeyDown(object? sender, KeyEventArgs e)
+ private void OnVariableFilterBoxKeyDown(object? sender, KeyEventArgs e)
{
- if (e.Key == Key.Enter)
- _vm?.CommitRenameCommand.Execute(null);
+ if (e.Key == Key.Escape && _vm is not null)
+ {
+ _vm.VariableFilter = string.Empty;
+ e.Handled = true;
+ }
}
private void OnParameterValueEditBoxKeyDown(object? sender, KeyEventArgs e)
diff --git a/src/XTMF2.GUI/Views/StartPickerDialog.axaml b/src/XTMF2.GUI/Views/StartPickerDialog.axaml
new file mode 100644
index 0000000..c4320cb
--- /dev/null
+++ b/src/XTMF2.GUI/Views/StartPickerDialog.axaml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/XTMF2.GUI/Views/StartPickerDialog.axaml.cs b/src/XTMF2.GUI/Views/StartPickerDialog.axaml.cs
new file mode 100644
index 0000000..8b16ecd
--- /dev/null
+++ b/src/XTMF2.GUI/Views/StartPickerDialog.axaml.cs
@@ -0,0 +1,99 @@
+/*
+ Copyright 2026 University of Toronto
+
+ This file is part of XTMF2.
+
+ XTMF2 is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ XTMF2 is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with XTMF2. If not, see .
+*/
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using System.Collections.Generic;
+using System.ComponentModel;
+
+namespace XTMF2.GUI.Views;
+
+///
+/// A small dialog that lets the user pick a Start from a ComboBox.
+///
+public partial class StartPickerDialog : Window, INotifyPropertyChanged
+{
+ private string? _prompt;
+ private string? _selectedStartName;
+
+ public new event PropertyChangedEventHandler? PropertyChanged;
+
+ public string? Prompt
+ {
+ get => _prompt;
+ set
+ {
+ _prompt = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Prompt)));
+ }
+ }
+
+ public IReadOnlyList StartNames { get; }
+
+ public string? SelectedStartName
+ {
+ get => _selectedStartName;
+ set
+ {
+ _selectedStartName = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedStartName)));
+ }
+ }
+
+ ///
+ /// true when the user dismissed the dialog without clicking OK.
+ ///
+ public bool WasCancelled { get; private set; } = true;
+
+ public StartPickerDialog()
+ {
+ InitializeComponent();
+ StartNames = [];
+ DataContext = this;
+ }
+
+ /// Window title.
+ /// Label shown above the ComboBox.
+ /// List of available start names to display.
+ /// The start that should be pre-selected.
+ public StartPickerDialog(string title, string prompt,
+ IReadOnlyList startNames,
+ string? defaultStart = null)
+ {
+ InitializeComponent();
+ Title = title;
+ Prompt = prompt;
+ StartNames = startNames;
+ SelectedStartName = defaultStart ?? (startNames.Count > 0 ? startNames[0] : null);
+ DataContext = this;
+
+ Opened += (_, _) => StartComboBox.Focus();
+ }
+
+ private void OK_Click(object? sender, RoutedEventArgs e)
+ {
+ WasCancelled = false;
+ Close();
+ }
+
+ private void Cancel_Click(object? sender, RoutedEventArgs e)
+ {
+ WasCancelled = true;
+ Close();
+ }
+}
diff --git a/src/XTMF2/Editing/EditingStack.cs b/src/XTMF2/Editing/EditingStack.cs
index ef25331..85f97b5 100644
--- a/src/XTMF2/Editing/EditingStack.cs
+++ b/src/XTMF2/Editing/EditingStack.cs
@@ -89,6 +89,11 @@ public void Add(CommandBatch item)
return null;
}
+ private static int SafeMod(int numerator, int denominator)
+ {
+ return ((numerator %= denominator) < 0) ? numerator + denominator : numerator;
+ }
+
///
/// Attempt to pop the top element off of the stack
///
@@ -103,7 +108,7 @@ public bool TryPop(out CommandBatch? command)
{
Count--;
command = _Data[_Head];
- _Head = (_Head - 1) % Capacity;
+ _Head = SafeMod(_Head - 1, Capacity);
popped = true;
}
else
@@ -148,7 +153,7 @@ public bool Contains(CommandBatch item)
for(int i = 0; i < Count; i++)
{
var headoffset = (_Head - i);
- int index = headoffset < 0 ? Capacity + headoffset : headoffset;
+ int index = SafeMod(headoffset, Capacity);
if (_Data[index] == item)
{
return true;
diff --git a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/ParameterCompiler.cs b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/ParameterCompiler.cs
index 08f5e50..292912b 100644
--- a/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/ParameterCompiler.cs
+++ b/src/XTMF2/ModelSystemConstruct/Parameters/Compiler/ParameterCompiler.cs
@@ -341,6 +341,14 @@ private static bool GetBracket(IList nodes, ReadOnlyMemory text, int
return Compile(nodes, text.Slice(first + 1, second - first - 1), offset + first + 1, out expression);
}
+ ///
+ /// Returns true if the character is a special character that is used as an operator or delimiter
+ /// in the expression grammar and therefore cannot be part of a variable name.
+ ///
+ private static bool IsSpecialCharacter(char c) =>
+ c is '?' or ':' or '&' or '|' or '+' or '-' or '*' or '/' or '^'
+ or '<' or '>' or '=' or '!' or '(' or ')' or '"';
+
private static bool GetVariable(IList nodes, ReadOnlyMemory text, int offset, [NotNullWhen(true)] out Expression? expression)
{
expression = null;
@@ -353,7 +361,7 @@ private static bool GetVariable(IList nodes, ReadOnlyMemory text, in
var end = start;
for (; end < span.Length; end++)
{
- if (char.IsWhiteSpace(span[end]))
+ if (IsSpecialCharacter(span[end]))
{
break;
}
@@ -364,8 +372,8 @@ private static bool GetVariable(IList nodes, ReadOnlyMemory text, in
{
return false;
}
- var innerText = text[start..end];
- var node = nodes.FirstOrDefault(n => innerText.Span.Equals(n.Name.AsSpan(), StringComparison.InvariantCulture));
+ var innerText = text[start..end].Trim();
+ var node = nodes.FirstOrDefault(n => innerText.Span.Equals(n.Name.AsSpan().Trim(), StringComparison.InvariantCulture));
if (node is not null)
{
expression = Variable.CreateVariableForNode(node, innerText, offset + start);
diff --git a/tests/XTMF2.UnitTests/ModelSystemConstruct/Parameters/Compiler/TestVariables.cs b/tests/XTMF2.UnitTests/ModelSystemConstruct/Parameters/Compiler/TestVariables.cs
index 59c850b..04439bd 100644
--- a/tests/XTMF2.UnitTests/ModelSystemConstruct/Parameters/Compiler/TestVariables.cs
+++ b/tests/XTMF2.UnitTests/ModelSystemConstruct/Parameters/Compiler/TestVariables.cs
@@ -157,6 +157,58 @@ public void TestWhitespaceAroundVariable()
});
}
+ [TestMethod]
+ public void TestSpaceInVariableName()
+ {
+ TestHelper.RunInModelSystemContext("TestSpaceInVariableName", (User user, ProjectSession project, ModelSystemSession session) =>
+ {
+ string error = null;
+ var nodes = new List()
+ {
+ CreateNodeForVariable(session, user, "my String Variable", "12345.6")
+ };
+ var text = "my String Variable";
+ Assert.IsTrue(ParameterCompiler.CreateExpression(nodes, text, out var expression, ref error), $"Failed to compile {text}");
+ Assert.IsNotNull(expression);
+ Assert.AreEqual(typeof(string), expression.Type);
+ Assert.IsTrue(ParameterCompiler.Evaluate(null, expression, out var result, ref error), error);
+ if (result is string strResult)
+ {
+ Assert.AreEqual("12345.6", strResult);
+ }
+ else
+ {
+ Assert.Fail("The result is not a string!");
+ }
+ });
+ }
+
+ [TestMethod]
+ public void TestSpaceInVariableNameWithSpecialCharacter()
+ {
+ TestHelper.RunInModelSystemContext("TestSpaceInVariableNameWithSpecialCharacter", (User user, ProjectSession project, ModelSystemSession session) =>
+ {
+ string error = null;
+ var nodes = new List()
+ {
+ CreateNodeForVariable(session, user, "my String Variable", "12345.6")
+ };
+ var text = "my String Variable + \"1\"";
+ Assert.IsTrue(ParameterCompiler.CreateExpression(nodes, text, out var expression, ref error), $"Failed to compile {text}");
+ Assert.IsNotNull(expression);
+ Assert.AreEqual(typeof(string), expression.Type);
+ Assert.IsTrue(ParameterCompiler.Evaluate(null, expression, out var result, ref error), error);
+ if (result is string strResult)
+ {
+ Assert.AreEqual("12345.61", strResult);
+ }
+ else
+ {
+ Assert.Fail("The result is not a string!");
+ }
+ });
+ }
+
[TestMethod]
public void TestBadVariableNames()
{