Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
947 changes: 901 additions & 46 deletions src/XTMF2.GUI/Controls/ModelSystemCanvas.cs

Large diffs are not rendered by default.

88 changes: 88 additions & 0 deletions src/XTMF2.GUI/Controls/ScriptSyntaxOverlay.cs
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
using System;
using System.Globalization;
using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;

namespace XTMF2.GUI.Controls;

/// <summary>
/// A transparent, non-interactive overlay control that paints syntax-highlighted
/// tokens over the scripted-parameter <see cref="Avalonia.Controls.TextBox"/>. It is added to
/// <see cref="Control.VisualChildren"/> after the TextBox so it renders on top of it.
/// </summary>
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)>();

/// <summary>
/// Scaled font size to use when drawing; updated by <see cref="ModelSystemCanvas.ArrangeOverride"/>
/// on every layout pass so the text matches the current zoom level.
/// </summary>
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));
}
}
82 changes: 77 additions & 5 deletions src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <inheritdoc/>
public double X => (double)UnderlyingBlock.Location.X;
public double X => _previewX ?? (double)UnderlyingBlock.Location.X;

/// <inheritdoc/>
public double Y => (double)UnderlyingBlock.Location.Y;
public double Y => _previewY ?? (double)UnderlyingBlock.Location.Y;

/// <summary>Rendered width; falls back to <see cref="DefaultWidth"/> when the model value is 0.</summary>
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);

/// <summary>Rendered height; falls back to <see cref="DefaultHeight"/> when the model value is 0.</summary>
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.
/// <inheritdoc/>
Expand Down Expand Up @@ -94,6 +99,34 @@ private void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
}
}

/// <summary>
/// Updates the visual position without touching the session (for drag preview).
/// Call <see cref="CommitMove"/> on mouse-up to persist the change.
/// </summary>
public void MoveToPreview(double x, double y)
{
_previewX = x;
_previewY = y;
OnPropertyChanged(nameof(X));
OnPropertyChanged(nameof(Y));
OnPropertyChanged(nameof(CenterX));
OnPropertyChanged(nameof(CenterY));
}

/// <summary>
/// Commits the current preview position to the session (call once on mouse-up).
/// Does nothing if no preview is active.
/// </summary>
public void CommitMove()
{
if (_previewX is null) return;
var x = _previewX.Value;
var y = _previewY!.Value;
_previewX = null;
_previewY = null;
MoveTo(x, y);
}

/// <summary>
/// Move the comment block to a new canvas position, persisting the change via the session
/// (supports undo/redo).
Expand All @@ -108,6 +141,45 @@ public void MoveTo(double x, double y)
// OnModelPropertyChanged("Location") fires automatically and propagates X/Y changes.
}

/// <summary>
/// Update the comment text, persisting the change via the session (supports undo/redo).
/// Whitespace-only text is ignored.
/// </summary>
public void SetText(string text)
{
if (string.IsNullOrWhiteSpace(text)) return;
_session.SetCommentBlockText(_user, UnderlyingBlock, text, out _);
}

/// <summary>
/// Updates the visual size without touching the session (for resize-drag preview).
/// Call <see cref="CommitResize"/> on mouse-up to persist the change.
/// Width is clamped to a minimum of 60; height to a minimum of 30.
/// </summary>
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));
}

/// <summary>
/// Commits the current preview size to the session (call once on mouse-up).
/// Does nothing if no resize preview is active.
/// </summary>
public void CommitResize()
{
if (_previewW is null) return;
var w = _previewW.Value;
var h = _previewH!.Value;
_previewW = null;
_previewH = null;
ResizeTo(w, h);
}

/// <summary>
/// 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.
Expand Down
72 changes: 67 additions & 5 deletions src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <inheritdoc/>
public double X => (double)UnderlyingGhostNode.Location.X;
public double X => _previewX ?? (double)UnderlyingGhostNode.Location.X;

/// <inheritdoc/>
public double Y => (double)UnderlyingGhostNode.Location.Y;
public double Y => _previewY ?? (double)UnderlyingGhostNode.Location.Y;

/// <summary>Rendered width; falls back to 120 when the model value is 0.</summary>
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);

/// <summary>Rendered height; falls back to 50 when the model value is 0.</summary>
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);

/// <inheritdoc/>
public double CenterX => X + Width / 2.0;
Expand Down Expand Up @@ -88,6 +93,34 @@ private void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
}
}

/// <summary>
/// Updates the visual position without touching the session (for drag preview).
/// Call <see cref="CommitMove"/> on mouse-up to persist the change.
/// </summary>
public void MoveToPreview(double x, double y)
{
_previewX = x;
_previewY = y;
OnPropertyChanged(nameof(X));
OnPropertyChanged(nameof(Y));
OnPropertyChanged(nameof(CenterX));
OnPropertyChanged(nameof(CenterY));
}

/// <summary>
/// Commits the current preview position to the session (call once on mouse-up).
/// Does nothing if no preview is active.
/// </summary>
public void CommitMove()
{
if (_previewX is null) return;
var x = _previewX.Value;
var y = _previewY!.Value;
_previewX = null;
_previewY = null;
MoveTo(x, y);
}

/// <summary>
/// Move the ghost node to a new canvas position, persisting via the session
/// (supports undo/redo).
Expand All @@ -101,6 +134,35 @@ public void MoveTo(double x, double y)
new Rectangle((float)x, (float)y, w, h), out _);
}

/// <summary>
/// Updates the visual size without touching the session (for resize-drag preview).
/// Call <see cref="CommitResize"/> on mouse-up to persist the change.
/// Width is clamped to a minimum of 120; height to a minimum of 28.
/// </summary>
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));
}

/// <summary>
/// Commits the current preview size to the session (call once on mouse-up).
/// Does nothing if no resize preview is active.
/// </summary>
public void CommitResize()
{
if (_previewW is null) return;
var w = _previewW.Value;
var h = _previewH!.Value;
_previewW = null;
_previewH = null;
ResizeTo(w, h);
}

/// <summary>
/// Resize the ghost node, persisting via the session.
/// Width is clamped to a minimum of 120; height to a minimum of 28.
Expand Down
47 changes: 41 additions & 6 deletions src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,31 @@ public string CurrentBoundaryLabel
/// <summary>Observable view-models for the model system's variable list.</summary>
public ObservableCollection<ModelSystemVariableViewModel> ModelSystemVariables { get; } = new();

/// <summary>Text typed into the variables filter box; filters <see cref="FilteredModelSystemVariables"/>.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FilteredModelSystemVariables))]
private string _variableFilter = string.Empty;

/// <summary>
/// Sorted (by name) and filtered (by <see cref="VariableFilter"/>) view of
/// <see cref="ModelSystemVariables"/>. Matches on name or boundary path.
/// </summary>
public IEnumerable<ModelSystemVariableViewModel> 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);
}
}

/// <summary>The currently selected link, if any. Mutually exclusive with <see cref="SelectedElement"/>.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NothingSelected))]
Expand Down Expand Up @@ -193,6 +218,12 @@ partial void OnNodeSearchSelectionChanged(NodeViewModel? value)
/// </summary>
public bool SelectedElementIsNode => SelectedElement is NodeViewModel;

/// <summary>True when the selected element is a <see cref="CommentBlockViewModel"/>.</summary>
public bool SelectedElementIsComment => SelectedElement is CommentBlockViewModel;

/// <summary>True when the selected element is NOT a <see cref="CommentBlockViewModel"/>, used to hide the rename box for comments.</summary>
public bool SelectedElementIsNotComment => SelectedElement is not CommentBlockViewModel;

/// <summary>
/// True when the selected node is a BasicParameter or ScriptedParameter,
/// used to gate the parameter value editor in the property panel.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
}

/// <summary>Commit the name/comment currently in <see cref="SelectedElementEditName"/> back to the model.</summary>
Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading