diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs
index d9e5e21..f45cc94 100644
--- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs
+++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs
@@ -144,7 +144,13 @@ public sealed partial class ModelSystemCanvas : Control
private CommentBlockViewModel? _editingCommentBlock;
/// Model-space position and size of the comment editor overlay.
private double _editingCommentEditorX, _editingCommentEditorY, _editingCommentEditorW, _editingCommentEditorH;
-
+ // ── Inline comment header editor ───────────────────────────────
+ /// Overlay single-line TextBox used for editing the comment block header.
+ private readonly TextBox _commentHeaderEditor;
+ /// The comment block whose header is being edited, or null when idle.
+ private CommentBlockViewModel? _editingCommentHeaderBlock;
+ /// Model-space position and size of the comment header editor overlay.
+ private double _editingCommentHeaderEditorX, _editingCommentHeaderEditorY, _editingCommentHeaderEditorW, _editingCommentHeaderEditorH;
// ── Inline name editor ───────────────────────────────────────────────────
/// Overlay single-line TextBox used for renaming nodes and starts.
private readonly TextBox _nameEditor;
@@ -167,6 +173,11 @@ public sealed partial class ModelSystemCanvas : Control
private readonly Dictionary<(FunctionInstanceViewModel, FunctionParameterHook), NodeViewModel>
_fiHookInlinedParam = new();
///
+ /// Stores the maximum scroll offset (model-space px) for each comment block, computed during
+ /// render. Used by the wheel handler to clamp scroll without recomputing the text layout.
+ ///
+ private readonly Dictionary _commentMaxScrollOffsets = new();
+ ///
/// BasicParameter nodes that are visible on the canvas AND connected via a Single (or
/// FunctionParameterHook) hook, so they can offer a "minimize to inline" button.
///
@@ -266,6 +277,26 @@ public ModelSystemCanvas()
LogicalChildren.Add(_commentEditor);
VisualChildren.Add(_commentEditor);
+ // Build the single-line comment header editor; Enter commits, Escape cancels.
+ _commentHeaderEditor = new TextBox
+ {
+ FontFamily = new Avalonia.Media.FontFamily("Segoe UI, Arial, sans-serif"),
+ FontSize = CommentHeaderFontSize,
+ FontWeight = Avalonia.Media.FontWeight.Bold,
+ Foreground = CommentTextBrush,
+ Background = new SolidColorBrush(Color.FromArgb(0xF2, 0xFF, 0xD5, 0x1A)),
+ BorderThickness = new Thickness(1),
+ BorderBrush = CommentBorderBrush,
+ Padding = new Thickness(6, 2, 6, 2),
+ VerticalContentAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ IsVisible = false,
+ };
+ _commentHeaderEditor.LostFocus += OnCommentHeaderEditorLostFocus;
+ _commentHeaderEditor.AddHandler(InputElement.KeyDownEvent, OnCommentHeaderEditorKeyDown,
+ Avalonia.Interactivity.RoutingStrategies.Tunnel);
+ LogicalChildren.Add(_commentHeaderEditor);
+ VisualChildren.Add(_commentHeaderEditor);
+
// Build the single-line name editor; Enter commits, Escape cancels.
_nameEditor = new TextBox
{
@@ -634,6 +665,11 @@ protected override Size MeasureOverride(Size availableSize)
{
_commentEditor.Measure(new Size(_editingCommentEditorW * _scale, _editingCommentEditorH * _scale));
}
+ // Measure the comment header editor.
+ if (_editingCommentHeaderBlock is not null)
+ {
+ _commentHeaderEditor.Measure(new Size(_editingCommentHeaderEditorW * _scale, _editingCommentHeaderEditorH * _scale));
+ }
// Measure the name editor.
if (_editingNameElement is not null)
{
@@ -704,6 +740,16 @@ protected override Size ArrangeOverride(Size finalSize)
_editingCommentEditorW * _scale,
_editingCommentEditorH * _scale));
}
+ // Position the comment header editor over the header band of the block being edited.
+ if (_editingCommentHeaderBlock is not null)
+ {
+ _commentHeaderEditor.FontSize = CommentHeaderFontSize * _scale;
+ _commentHeaderEditor.Arrange(new Rect(
+ _editingCommentHeaderEditorX * _scale,
+ _editingCommentHeaderEditorY * _scale,
+ _editingCommentHeaderEditorW * _scale,
+ _editingCommentHeaderEditorH * _scale));
+ }
// Position the name editor over the element header being renamed.
if (_editingNameElement is not null)
{
diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.Input.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.Input.cs
index b021e77..24ba9f4 100644
--- a/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.Input.cs
+++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.Input.cs
@@ -238,6 +238,23 @@ protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
e.Handled = true;
return;
}
+ // If the pointer is over a comment block, scroll its comment body text.
+ if (_vm is not null)
+ {
+ var mpos = ToCanvasPos(e.GetPosition(this));
+ var commentHit = HitTest(mpos, testComments: true) as CommentBlockViewModel;
+ if (commentHit is not null)
+ {
+ const double scrollStep = 24.0;
+ double newOffset = commentHit.CommentScrollOffset - e.Delta.Y * scrollStep;
+ // Clamp to [0, maxScroll] using the last-rendered max (0 if not yet rendered).
+ _commentMaxScrollOffsets.TryGetValue(commentHit, out double maxScroll);
+ commentHit.CommentScrollOffset = Math.Min(Math.Max(0.0, newOffset), maxScroll);
+ InvalidateVisual();
+ e.Handled = true;
+ return;
+ }
+ }
base.OnPointerWheelChanged(e);
}
@@ -266,12 +283,14 @@ private void OnPointerPressedTunnel(object? sender, PointerPressedEventArgs e)
bool isResizingEditedElement =
ReferenceEquals(resizeHit, _editingParamNode) ||
ReferenceEquals(resizeHit, _editingNameElement) ||
- ReferenceEquals(resizeHit, _editingCommentBlock);
+ ReferenceEquals(resizeHit, _editingCommentBlock) ||
+ ReferenceEquals(resizeHit, _editingCommentHeaderBlock);
if (!isResizingEditedElement)
{
if (_editingParamNode is not null) CommitParamEdit();
if (_editingCommentBlock is not null) CommitCommentEdit();
+ if (_editingCommentHeaderBlock is not null) CommitCommentHeaderEdit();
if (_editingNameElement is not null) CommitNameEdit();
}
@@ -320,12 +339,14 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
bool isResizingEditedElement =
ReferenceEquals(resizeHit, _editingParamNode) ||
ReferenceEquals(resizeHit, _editingNameElement) ||
- ReferenceEquals(resizeHit, _editingCommentBlock);
+ ReferenceEquals(resizeHit, _editingCommentBlock) ||
+ ReferenceEquals(resizeHit, _editingCommentHeaderBlock);
if (!isResizingEditedElement)
{
if (_editingParamNode is not null) CommitParamEdit();
if (_editingCommentBlock is not null) CommitCommentEdit();
+ if (_editingCommentHeaderBlock is not null) CommitCommentHeaderEdit();
if (_editingNameElement is not null) CommitNameEdit();
}
ClearMultiSelection();
@@ -349,6 +370,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
{
if (_editingParamNode is not null) CommitParamEdit();
if (_editingCommentBlock is not null) CommitCommentEdit();
+ if (_editingCommentHeaderBlock is not null) CommitCommentHeaderEdit();
if (_editingNameElement is not null) CommitNameEdit();
minimizeHit.InlineBasicParameter();
InvalidateAndMeasure();
@@ -391,6 +413,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
ReferenceEquals(clickedElement, _editingParamNode) ||
ReferenceEquals(clickedElement, _editingNameElement) ||
ReferenceEquals(clickedElement, _editingCommentBlock) ||
+ ReferenceEquals(clickedElement, _editingCommentHeaderBlock) ||
(clickedElement is not null && _multiSelection.Contains(clickedElement) &&
((_editingParamNode is not null && _multiSelection.Contains(_editingParamNode)) ||
(_editingNameElement is not null && _multiSelection.Contains(_editingNameElement)) ||
@@ -401,6 +424,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
{
if (_editingParamNode is not null) CommitParamEdit();
if (_editingCommentBlock is not null) CommitCommentEdit();
+ if (_editingCommentHeaderBlock is not null) CommitCommentHeaderEdit();
if (_editingNameElement is not null) CommitNameEdit();
}
}
@@ -466,12 +490,16 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
return;
}
- // ── Double-click on a comment block: open the inline comment editor ──
+ // ── Double-click on a comment block: open the inline comment or header editor ──
var commentHit = HitTest(mpos, testComments: true) as CommentBlockViewModel;
if (commentHit is not null)
{
_vm.SelectElementCommand.Execute(commentHit);
- BeginCommentEdit(commentHit);
+ // Open the header editor when the click lands in the header band; otherwise the comment editor.
+ if (mpos.Y < commentHit.Y + CommentHeaderHeight)
+ BeginCommentHeaderEdit(commentHit);
+ else
+ BeginCommentEdit(commentHit);
e.Handled = true;
return;
}
@@ -554,6 +582,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
// Always commit any open inline edit first.
if (_editingParamNode is not null) CommitParamEdit();
if (_editingCommentBlock is not null) CommitCommentEdit();
+ if (_editingCommentHeaderBlock is not null) CommitCommentHeaderEdit();
if (_editingNameElement is not null) CommitNameEdit();
if (hit is not null)
@@ -672,7 +701,8 @@ protected override void OnPointerMoved(PointerEventArgs e)
// Only sync inline editor if it's the element being resized
if (ReferenceEquals(_resizing, _editingParamNode) ||
ReferenceEquals(_resizing, _editingNameElement) ||
- ReferenceEquals(_resizing, _editingCommentBlock))
+ ReferenceEquals(_resizing, _editingCommentBlock) ||
+ ReferenceEquals(_resizing, _editingCommentHeaderBlock))
{
SyncEditingElementPositions();
}
@@ -743,14 +773,16 @@ protected override void OnPointerMoved(PointerEventArgs e)
{
if ((_editingParamNode is not null && _multiSelection.Contains(_editingParamNode)) ||
(_editingNameElement is not null && _multiSelection.Contains(_editingNameElement)) ||
- (_editingCommentBlock is not null && _multiSelection.Contains(_editingCommentBlock)))
+ (_editingCommentBlock is not null && _multiSelection.Contains(_editingCommentBlock)) ||
+ (_editingCommentHeaderBlock is not null && _multiSelection.Contains(_editingCommentHeaderBlock)))
{
SyncEditingElementPositions();
}
}
else if (ReferenceEquals(_dragging, _editingParamNode) ||
ReferenceEquals(_dragging, _editingNameElement) ||
- ReferenceEquals(_dragging, _editingCommentBlock))
+ ReferenceEquals(_dragging, _editingCommentBlock) ||
+ ReferenceEquals(_dragging, _editingCommentHeaderBlock))
{
SyncEditingElementPositions();
}
diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.NameCommentEditing.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.NameCommentEditing.cs
index 27e44cf..2d11c0a 100644
--- a/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.NameCommentEditing.cs
+++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.NameCommentEditing.cs
@@ -34,6 +34,7 @@ private void BeginNameEdit(ICanvasElement element)
{
CommitParamEdit();
CommitCommentEdit();
+ CommitCommentHeaderEdit();
bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light;
_nameEditor.Foreground = isLight ? Brushes.Black : Brushes.White;
@@ -179,14 +180,15 @@ public void BeginCommentEditForSelected()
}
}
- /// Shows the multi-line comment editor over .
+ /// Shows the multi-line comment editor over , positioned below the header band.
private void BeginCommentEdit(CommentBlockViewModel comment)
{
+ CommitCommentHeaderEdit();
_editingCommentBlock = comment;
_editingCommentEditorX = comment.X;
- _editingCommentEditorY = comment.Y;
+ _editingCommentEditorY = comment.Y + CommentHeaderHeight;
_editingCommentEditorW = comment.Width;
- _editingCommentEditorH = comment.Height;
+ _editingCommentEditorH = comment.Height - CommentHeaderHeight;
_commentEditor.Text = comment.Name; // Name returns the underlying Comment text.
// Pick colours based on the active theme.
bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light;
@@ -252,4 +254,75 @@ private void OnCommentEditorLostFocus(object? sender, Avalonia.Interactivity.Rou
}
}
+ // ── Inline comment header editor helpers ────────────────────────────
+
+ /// Shows the single-line header editor over the header band of .
+ internal void BeginCommentHeaderEdit(CommentBlockViewModel comment)
+ {
+ CommitCommentEdit();
+ _editingCommentHeaderBlock = comment;
+ _editingCommentHeaderEditorX = comment.X;
+ _editingCommentHeaderEditorY = comment.Y;
+ // Leave room for the fold corner at the top-right.
+ _editingCommentHeaderEditorW = comment.Width - CommentFoldSize;
+ _editingCommentHeaderEditorH = CommentHeaderHeight;
+ _commentHeaderEditor.Text = comment.Header;
+ bool isLight = Application.Current?.ActualThemeVariant == ThemeVariant.Light;
+ _commentHeaderEditor.Foreground = isLight ? CommentTextBrush : Brushes.White;
+ _commentHeaderEditor.Background = isLight
+ ? new SolidColorBrush(Color.FromArgb(0xF2, 0xFF, 0xD5, 0x1A))
+ : new SolidColorBrush(Color.FromRgb(0x2A, 0x20, 0x00));
+ _commentHeaderEditor.IsVisible = true;
+ InvalidateMeasure();
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ _commentHeaderEditor.Focus();
+ _commentHeaderEditor.SelectAll();
+ }, Avalonia.Threading.DispatcherPriority.Render);
+ }
+
+ /// Saves the header editor text and closes the editor.
+ internal void CommitCommentHeaderEdit()
+ {
+ if (_editingCommentHeaderBlock is null) return;
+ var comment = _editingCommentHeaderBlock;
+ var text = (_commentHeaderEditor.Text ?? string.Empty).Trim();
+ _editingCommentHeaderBlock = null;
+ _commentHeaderEditor.IsVisible = false;
+ comment.SetHeader(text);
+ InvalidateAndMeasure();
+ }
+
+ /// Discards the header edit without saving.
+ private void CancelCommentHeaderEdit()
+ {
+ _editingCommentHeaderBlock = null;
+ _commentHeaderEditor.IsVisible = false;
+ InvalidateAndMeasure();
+ Focus();
+ }
+
+ private void OnCommentHeaderEditorKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key is Key.Return or Key.Enter)
+ {
+ CommitCommentHeaderEdit();
+ Focus();
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Escape)
+ {
+ CancelCommentHeaderEdit();
+ e.Handled = true;
+ }
+ }
+
+ private void OnCommentHeaderEditorLostFocus(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (_inDragOrResize) return;
+ if (_editingCommentHeaderBlock is not null)
+ {
+ CommitCommentHeaderEdit();
+ }
+ }
}
diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.ParamEditing.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.ParamEditing.cs
index a897132..d8f02e4 100644
--- a/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.ParamEditing.cs
+++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.ParamEditing.cs
@@ -751,9 +751,18 @@ private void SyncEditingElementPositions()
if (_editingCommentBlock is not null)
{
_editingCommentEditorX = _editingCommentBlock.X;
- _editingCommentEditorY = _editingCommentBlock.Y;
+ _editingCommentEditorY = _editingCommentBlock.Y + CommentHeaderHeight;
_editingCommentEditorW = _editingCommentBlock.Width;
- _editingCommentEditorH = _editingCommentBlock.Height;
+ _editingCommentEditorH = _editingCommentBlock.Height - CommentHeaderHeight;
+ }
+
+ // Sync comment header editor position/size if editing
+ if (_editingCommentHeaderBlock is not null)
+ {
+ _editingCommentHeaderEditorX = _editingCommentHeaderBlock.X;
+ _editingCommentHeaderEditorY = _editingCommentHeaderBlock.Y;
+ _editingCommentHeaderEditorW = _editingCommentHeaderBlock.Width - CommentFoldSize;
+ _editingCommentHeaderEditorH = CommentHeaderHeight;
}
}
diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.Rendering.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.Rendering.cs
index 065ae75..bd4f1ff 100644
--- a/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.Rendering.cs
+++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.Rendering.cs
@@ -160,6 +160,30 @@ private void RenderCommentBlocks(DrawingContext ctx)
// Header Rectangle: full width but tab stops short of the fold on top row.
ctx.DrawRectangle(CommentHeaderBrush, null,
new Rect(x, y, w, CommentHeaderHeight));
+ // Render header text in bold if present.
+ string headerText = comment.Header;
+ if (!string.IsNullOrEmpty(headerText))
+ {
+ var headerTextArea = new Rect(
+ x + CommentPadding,
+ y + 2,
+ w - CommentPadding * 2 - fold,
+ CommentHeaderHeight - 4);
+ if (headerTextArea.Width > 4)
+ {
+ using var clipPush = ctx.PushClip(headerTextArea);
+ var headerLayout = new TextLayout(
+ headerText,
+ CommentHeaderTypeface,
+ CommentHeaderFontSize,
+ CommentTextBrush,
+ textAlignment: TextAlignment.Left,
+ textWrapping: TextWrapping.NoWrap,
+ maxWidth: headerTextArea.Width,
+ maxHeight: headerTextArea.Height);
+ headerLayout.Draw(ctx, new Point(headerTextArea.X, headerTextArea.Y));
+ }
+ }
}
// ── 5. Faint ruled lines ──────────────────────────────────────
@@ -191,7 +215,7 @@ private void RenderCommentBlocks(DrawingContext ctx)
new Point(x + w - fold, y),
new Point(x + w, y + fold));
- // ── 8. Comment text ───────────────────────────────────────────
+ // ── 8. Comment text (with scroll indicator) ───────────────────
var textArea = new Rect(
x + CommentPadding,
y + CommentHeaderHeight + 2,
@@ -199,6 +223,24 @@ private void RenderCommentBlocks(DrawingContext ctx)
h - CommentHeaderHeight - CommentPadding - 2);
if (textArea.Width > 4 && textArea.Height > 4)
{
+ // Reserve space on the right for the scroll indicator.
+ double textW = textArea.Width - CommentScrollBarWidth - 2;
+ // Measure the full (unconstrained) text height to compute max scroll.
+ var measureLayout = new TextLayout(
+ comment.Name,
+ DefaultTypeface,
+ CommentFontSize,
+ CommentTextBrush,
+ textAlignment: TextAlignment.Left,
+ textWrapping: TextWrapping.Wrap,
+ maxWidth: textW,
+ maxHeight: double.MaxValue);
+ double totalTextHeight = measureLayout.Height;
+ double maxScroll = Math.Max(0.0, totalTextHeight - textArea.Height);
+ _commentMaxScrollOffsets[comment] = maxScroll;
+
+ double scrollOffset = Math.Min(comment.CommentScrollOffset, maxScroll);
+
using var clipPush = ctx.PushClip(textArea);
var layout = new TextLayout(
comment.Name,
@@ -207,9 +249,24 @@ private void RenderCommentBlocks(DrawingContext ctx)
CommentTextBrush,
textAlignment: TextAlignment.Left,
textWrapping: TextWrapping.Wrap,
- maxWidth: textArea.Width,
- maxHeight: textArea.Height);
- layout.Draw(ctx, new Point(textArea.X, textArea.Y));
+ maxWidth: textW,
+ maxHeight: textArea.Height + scrollOffset);
+ layout.Draw(ctx, new Point(textArea.X, textArea.Y - scrollOffset));
+
+ // Draw scroll indicator when content overflows.
+ if (totalTextHeight > textArea.Height)
+ {
+ double trackX = textArea.Right - CommentScrollBarWidth;
+ double trackH = textArea.Height;
+ ctx.DrawRectangle(CommentScrollTrackBrush, null,
+ new Rect(trackX, textArea.Y, CommentScrollBarWidth, trackH));
+ double thumbH = Math.Max(8.0, (textArea.Height / totalTextHeight) * trackH);
+ double thumbY = maxScroll > 0
+ ? textArea.Y + (scrollOffset / maxScroll) * (trackH - thumbH)
+ : textArea.Y;
+ ctx.DrawRectangle(CommentScrollThumbBrush, null,
+ new Rect(trackX, thumbY, CommentScrollBarWidth, thumbH), 2, 2);
+ }
}
// ── 9. Resize grip dots (bottom-right) ────────────────────────
diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.Resources.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.Resources.cs
index 4521406..312b73e 100644
--- a/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.Resources.cs
+++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.Resources.cs
@@ -71,6 +71,12 @@ partial class ModelSystemCanvas
private static readonly IBrush CommentShadowBrush = new SolidColorBrush(Color.FromArgb(0x55, 0x00, 0x00, 0x00));
/// Faint pen for horizontal ruled lines on the note body.
private static readonly Pen CommentRulePen = new Pen(new SolidColorBrush(Color.FromArgb(0x50, 0xA0, 0x8A, 0x00)), 0.6);
+ /// Semi-transparent track for the comment body scroll indicator.
+ private static readonly IBrush CommentScrollTrackBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xA0, 0x80, 0x00));
+ /// Thumb for the comment body scroll indicator.
+ private static readonly IBrush CommentScrollThumbBrush = new SolidColorBrush(Color.FromArgb(0xA0, 0xA0, 0x70, 0x00));
+ /// Width of the virtual scroll-indicator strip drawn on the right of the comment body.
+ private const double CommentScrollBarWidth = 4.0;
// Hook colours
private static readonly IBrush HookConnectedBrush = new SolidColorBrush(Color.FromRgb(0x2E, 0xCC, 0x71));
private static readonly IBrush HookUnconnectedBrush = new SolidColorBrush(Color.FromRgb(0x55, 0x66, 0x77));
@@ -259,6 +265,7 @@ partial class ModelSystemCanvas
private const double NodeFontSize = 12.0;
private const double StartFontSize = 11.0;
private const double CommentFontSize = 11.5;
+ private const double CommentHeaderFontSize = 13.0;
private const double CommentPadding = 6.0;
/// Size of the dog-ear fold cut at the top-right corner of a sticky note.
private const double CommentFoldSize = 22.0;
@@ -291,6 +298,8 @@ partial class ModelSystemCanvas
private const double AutoScrollSpeed = 14.0 / 2.0;
private static readonly Typeface DefaultTypeface = new Typeface("Segoe UI, Arial, sans-serif");
+ private static readonly Typeface CommentHeaderTypeface = new Typeface(
+ "Segoe UI, Arial, sans-serif", Avalonia.Media.FontStyle.Normal, Avalonia.Media.FontWeight.Bold);
// ── Dark-mode FunctionParameter brushes (promoted from per-frame allocations) ──
private static readonly IBrush FpBodyFill = new SolidColorBrush(Color.FromArgb(0xCC, 0xFF, 0x8C, 0x00));
diff --git a/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs b/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs
index efb6e18..8dafff7 100644
--- a/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs
+++ b/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs
@@ -64,6 +64,9 @@ public sealed partial class CommentBlockViewModel : ObservableObject, ICanvasEle
///
public string Name => UnderlyingBlock.Comment;
+ /// The header text displayed in bold above the comment body.
+ public string Header => UnderlyingBlock.Header;
+
///
public double CenterX => X + Width / 2.0;
@@ -72,6 +75,21 @@ public sealed partial class CommentBlockViewModel : ObservableObject, ICanvasEle
[ObservableProperty] private bool _isSelected;
+ ///
+ /// Vertical scroll offset (in model-space pixels) applied when rendering the comment body.
+ /// Not persisted — resets to zero when the comment block is re-created.
+ ///
+ private double _commentScrollOffset;
+ public double CommentScrollOffset
+ {
+ get => _commentScrollOffset;
+ set
+ {
+ _commentScrollOffset = Math.Max(0.0, value);
+ OnPropertyChanged(nameof(CommentScrollOffset));
+ }
+ }
+
public CommentBlockViewModel(CommentBlock block, ModelSystemSession session, User user)
{
UnderlyingBlock = block;
@@ -88,6 +106,9 @@ private void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
case nameof(CommentBlock.Comment):
OnPropertyChanged(nameof(Name));
break;
+ case nameof(CommentBlock.Header):
+ OnPropertyChanged(nameof(Header));
+ break;
case nameof(CommentBlock.Location):
OnPropertyChanged(nameof(X));
OnPropertyChanged(nameof(Y));
@@ -168,6 +189,15 @@ public void SetText(string text)
_session.SetCommentBlockText(_user, UnderlyingBlock, text, out _);
}
+ ///
+ /// Update the header text, persisting the change via the session (supports undo/redo).
+ /// An empty header is allowed and will hide the header display.
+ ///
+ public void SetHeader(string header)
+ {
+ _session.SetCommentBlockHeader(_user, UnderlyingBlock, header, out _);
+ }
+
///
/// Updates the visual size without touching the session (for resize-drag preview).
/// Call on mouse-up to persist the change.
diff --git a/src/XTMF2/Editing/ModelSystemSession.cs b/src/XTMF2/Editing/ModelSystemSession.cs
index b10dadd..58a86d9 100644
--- a/src/XTMF2/Editing/ModelSystemSession.cs
+++ b/src/XTMF2/Editing/ModelSystemSession.cs
@@ -456,6 +456,37 @@ public bool SetCommentBlockText(User user, CommentBlock commentBlock, string new
}
}
+ ///
+ /// Set the header text of a comment block, supporting undo/redo.
+ /// An empty header is allowed (it simply removes the header display).
+ ///
+ public bool SetCommentBlockHeader(User user, CommentBlock commentBlock, string newHeader, [NotNullWhen(false)] out CommandError? error)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+ ArgumentNullException.ThrowIfNull(commentBlock);
+ lock (_sessionLock)
+ {
+ if (!_session.HasAccess(user))
+ {
+ error = new CommandError("The user does not have access to this project.", true);
+ return false;
+ }
+ var oldHeader = commentBlock.Header;
+ commentBlock.Header = newHeader;
+ Buffer.AddUndo(new Command(() =>
+ {
+ commentBlock.Header = oldHeader;
+ return (true, null);
+ }, () =>
+ {
+ commentBlock.Header = newHeader;
+ return (true, null);
+ }));
+ error = null;
+ return true;
+ }
+ }
+
///
/// Remove a boundary from a model system
///
diff --git a/src/XTMF2/ModelSystemConstruct/CommentBlock.cs b/src/XTMF2/ModelSystemConstruct/CommentBlock.cs
index 55c39ac..ab885b4 100644
--- a/src/XTMF2/ModelSystemConstruct/CommentBlock.cs
+++ b/src/XTMF2/ModelSystemConstruct/CommentBlock.cs
@@ -37,6 +37,7 @@ public sealed class CommentBlock : INotifyPropertyChanged
private const string WidthProperty = "Width";
private const string HeightProperty = "Height";
private const string CommentProperty = "Comment";
+ private const string HeaderProperty = "Header";
public event PropertyChangedEventHandler? PropertyChanged;
@@ -50,10 +51,13 @@ public sealed class CommentBlock : INotifyPropertyChanged
///
///
///
- public CommentBlock(string comment, Rectangle location, Guid id = default)
+ ///
+ ///
+ public CommentBlock(string comment, Rectangle location, Guid id = default, string header = "")
{
Id = id == default ? Guid.NewGuid() : id;
_comment = comment;
+ _header = header;
_location = location;
}
@@ -68,6 +72,7 @@ private void Notify(string propertyName)
private Rectangle _location;
private string _comment;
+ private string _header;
///
/// The location to place the Comment Block within the boundary
@@ -95,6 +100,19 @@ internal set
}
}
+ ///
+ /// The header string displayed above the comment in bold
+ ///
+ public string Header
+ {
+ get => _header;
+ internal set
+ {
+ _header = value;
+ Notify(nameof(Header));
+ }
+ }
+
internal void Save(Utf8JsonWriter writer)
{
writer.WriteStartObject();
@@ -104,6 +122,7 @@ internal void Save(Utf8JsonWriter writer)
writer.WriteNumber(WidthProperty, Location.Width);
writer.WriteNumber(HeightProperty, Location.Height);
writer.WriteString(CommentProperty, Comment);
+ writer.WriteString(HeaderProperty, Header);
writer.WriteEndObject();
}
@@ -111,6 +130,7 @@ internal static bool Load(ref Utf8JsonReader reader, [NotNullWhen(true)] out Com
{
float x = 0, y = 0, width = 0, height = 0;
string comment = "No comment";
+ string header = string.Empty;
Guid id = Guid.Empty;
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
@@ -171,9 +191,17 @@ internal static bool Load(ref Utf8JsonReader reader, [NotNullWhen(true)] out Com
}
comment = temp;
}
+ else if (reader.ValueTextEquals(HeaderProperty))
+ {
+ if (!reader.Read() || reader.TokenType != JsonTokenType.String)
+ {
+ return FailWith(out block, out error, $"The header of the comment block was not a string!");
+ }
+ header = reader.GetString() ?? string.Empty;
+ }
}
}
- block = new CommentBlock(comment, new Rectangle(x, y, width, height), id);
+ block = new CommentBlock(comment, new Rectangle(x, y, width, height), id, header);
return true;
}
diff --git a/tests/XTMF2.UnitTests/Editing/TestCommentBlock.cs b/tests/XTMF2.UnitTests/Editing/TestCommentBlock.cs
index 9bc8f75..8d7e547 100644
--- a/tests/XTMF2.UnitTests/Editing/TestCommentBlock.cs
+++ b/tests/XTMF2.UnitTests/Editing/TestCommentBlock.cs
@@ -222,5 +222,127 @@ public void TestChangingCommentBlockPosition()
Assert.AreEqual(newLocation, block.Location);
});
}
+
+ [TestMethod]
+ public void TestCommentBlockHeaderDefaultsToEmpty()
+ {
+ TestHelper.RunInModelSystemContext("TestCommentBlockHeaderDefaultsToEmpty", (user, pSession, msSession) =>
+ {
+ CommandError error = null;
+ var ms = msSession.ModelSystem;
+ Assert.IsTrue(msSession.AddCommentBlock(user, ms.GlobalBoundary, "My Comment", new Rectangle(100, 100),
+ out CommentBlock block, out error), error?.Message);
+ Assert.AreEqual(string.Empty, block.Header, "New comment block header should default to empty string.");
+ });
+ }
+
+ [TestMethod]
+ public void TestSettingCommentBlockHeader()
+ {
+ TestHelper.RunInModelSystemContext("TestSettingCommentBlockHeader", (user, pSession, msSession) =>
+ {
+ CommandError error = null;
+ var ms = msSession.ModelSystem;
+ Assert.IsTrue(msSession.AddCommentBlock(user, ms.GlobalBoundary, "My Comment", new Rectangle(100, 100),
+ out CommentBlock block, out error), error?.Message);
+ const string header = "My Header";
+ Assert.IsTrue(msSession.SetCommentBlockHeader(user, block, header, out error), error?.Message);
+ Assert.AreEqual(header, block.Header, "The comment block header was not set.");
+ });
+ }
+
+ [TestMethod]
+ public void TestSettingCommentBlockHeaderWithBadUser()
+ {
+ TestHelper.RunInModelSystemContext("TestSettingCommentBlockHeaderWithBadUser", (user, unauthorizedUser, pSession, msSession) =>
+ {
+ CommandError error = null;
+ var ms = msSession.ModelSystem;
+ Assert.IsTrue(msSession.AddCommentBlock(user, ms.GlobalBoundary, "My Comment", new Rectangle(100, 100),
+ out CommentBlock block, out error), error?.Message);
+ Assert.IsFalse(msSession.SetCommentBlockHeader(unauthorizedUser, block, "Should Fail", out error));
+ Assert.AreEqual(string.Empty, block.Header, "Header should remain empty after unauthorized set.");
+ });
+ }
+
+ [TestMethod]
+ public void TestSettingCommentBlockHeaderUndoRedo()
+ {
+ TestHelper.RunInModelSystemContext("TestSettingCommentBlockHeaderUndoRedo", (user, pSession, msSession) =>
+ {
+ CommandError error = null;
+ var ms = msSession.ModelSystem;
+ Assert.IsTrue(msSession.AddCommentBlock(user, ms.GlobalBoundary, "My Comment", new Rectangle(100, 100),
+ out CommentBlock block, out error), error?.Message);
+ const string header = "Important Header";
+ Assert.IsTrue(msSession.SetCommentBlockHeader(user, block, header, out error), error?.Message);
+ Assert.AreEqual(header, block.Header);
+ Assert.IsTrue(msSession.Undo(user, out error), error?.Message);
+ Assert.AreEqual(string.Empty, block.Header, "Header should be empty after undo.");
+ Assert.IsTrue(msSession.Redo(user, out error), error?.Message);
+ Assert.AreEqual(header, block.Header, "Header should be restored after redo.");
+ });
+ }
+
+ [TestMethod]
+ public void TestSettingCommentBlockHeaderAllowsEmpty()
+ {
+ TestHelper.RunInModelSystemContext("TestSettingCommentBlockHeaderAllowsEmpty", (user, pSession, msSession) =>
+ {
+ CommandError error = null;
+ var ms = msSession.ModelSystem;
+ Assert.IsTrue(msSession.AddCommentBlock(user, ms.GlobalBoundary, "My Comment", new Rectangle(100, 100),
+ out CommentBlock block, out error), error?.Message);
+ Assert.IsTrue(msSession.SetCommentBlockHeader(user, block, "Has Header", out error), error?.Message);
+ Assert.IsTrue(msSession.SetCommentBlockHeader(user, block, string.Empty, out error), error?.Message);
+ Assert.AreEqual(string.Empty, block.Header, "Empty header should be accepted.");
+ });
+ }
+
+ [TestMethod]
+ public void TestCommentBlockHeaderPersistence()
+ {
+ const string comment = "My Comment";
+ const string header = "My Header";
+ var location = new Rectangle(100, 100);
+ TestHelper.RunInModelSystemContext("TestCommentBlockHeaderPersistence", (user, pSession, msSession) =>
+ {
+ CommandError error = null;
+ var ms = msSession.ModelSystem;
+ Assert.IsTrue(msSession.AddCommentBlock(user, ms.GlobalBoundary, comment, location,
+ out CommentBlock block, out error), error?.Message);
+ Assert.IsTrue(msSession.SetCommentBlockHeader(user, block, header, out error), error?.Message);
+ Assert.IsTrue(msSession.Save(out error), error?.Message);
+ }, (user, pSession, msSession) =>
+ {
+ var comBlocks = msSession.ModelSystem.GlobalBoundary.CommentBlocks;
+ Assert.HasCount(1, comBlocks);
+ Assert.AreEqual(comment, comBlocks[0].Comment, "Comment should survive save/load.");
+ Assert.AreEqual(header, comBlocks[0].Header, "Header should survive save/load.");
+ });
+ }
+
+ [TestMethod]
+ public void TestCommentBlockHeaderDefaultsToEmptyOnLoad()
+ {
+ // A model system saved before headers were introduced should load with an empty header.
+ const string comment = "Legacy Comment";
+ var location = new Rectangle(50, 50);
+ TestHelper.RunInModelSystemContext("TestCommentBlockHeaderDefaultsToEmptyOnLoad", (user, pSession, msSession) =>
+ {
+ CommandError error = null;
+ var ms = msSession.ModelSystem;
+ Assert.IsTrue(msSession.AddCommentBlock(user, ms.GlobalBoundary, comment, location,
+ out CommentBlock block, out error), error?.Message);
+ // Leave the header at the default (empty) and save.
+ Assert.IsTrue(msSession.Save(out error), error?.Message);
+ }, (user, pSession, msSession) =>
+ {
+ var comBlocks = msSession.ModelSystem.GlobalBoundary.CommentBlocks;
+ Assert.HasCount(1, comBlocks);
+ Assert.AreEqual(comment, comBlocks[0].Comment);
+ Assert.AreEqual(string.Empty, comBlocks[0].Header, "Header should default to empty string when absent from file.");
+ });
+ }
}
}