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."); + }); + } } }