From b4486bb084d25b98d4423491cca7cc3707fdddd7 Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Wed, 1 Apr 2026 22:18:46 -0400 Subject: [PATCH 01/13] Add drop shadows for canvas objects --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 50 +++++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index e4865b3..5ba8743 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)); @@ -100,9 +103,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; @@ -468,8 +476,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 +499,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 +512,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 +531,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 +1108,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 ────────────── @@ -1274,6 +1287,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 +1325,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, From eb2ea2e9f66f1f3a4370eb5bf33938e5d4e78eca Mon Sep 17 00:00:00 2001 From: James Vaughan Date: Wed, 1 Apr 2026 22:29:19 -0400 Subject: [PATCH 02/13] Add inline editing for comments --- src/XTMF2.GUI/Controls/ModelSystemCanvas.cs | 146 +++++++++++++++++- .../ViewModels/CommentBlockViewModel.cs | 10 ++ .../ViewModels/ModelSystemEditorViewModel.cs | 8 + .../Views/ModelSystemEditorView.axaml | 5 +- .../Views/ModelSystemEditorView.axaml.cs | 12 +- 5 files changed, 174 insertions(+), 7 deletions(-) diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs index 5ba8743..b3392f7 100644 --- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs +++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs @@ -165,6 +165,14 @@ private readonly Dictionary> /// Screen position and width of the inline editor overlay (set in ). private double _editingParamEditorX, _editingParamEditorY, _editingParamEditorW; + // ── 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; + // ── Inlined BasicParameter caches (rebuilt by BuildHookAnchorCache) ─── /// /// Maps (origin node, hook) → the BasicParameter node that is currently inlined @@ -207,6 +215,28 @@ public ModelSystemCanvas() LogicalChildren.Add(_inlineEditor); VisualChildren.Add(_inlineEditor); + // 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); + // ── Zoom control (pinned to viewport bottom-right) ──────────────── _zoomTextBox = new TextBox { @@ -439,6 +469,11 @@ protected override Size MeasureOverride(Size availableSize) : NodeRenderWidth(_editingParamNode) * _scale, HookRowHeight * _scale)); } + // Measure the comment editor. + if (_editingCommentBlock is not null) + { + _commentEditor.Measure(new Size(_editingCommentEditorW * _scale, _editingCommentEditorH * _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); @@ -456,6 +491,16 @@ protected override Size ArrangeOverride(Size finalSize) _editingParamEditorW * _scale, HookRowHeight * _scale)); } + // 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)); + } // Pin the zoom control to the bottom-right of the visible viewport. var sv = GetScrollViewer(); var zw = _zoomBar.DesiredSize.Width; @@ -1500,7 +1545,8 @@ 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(); ClearMultiSelection(); _resizing = resizeHit; _resizeStartPos = mpos; @@ -1520,7 +1566,8 @@ 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(); minimizeHit.InlineBasicParameter(); InvalidateAndMeasure(); e.Handled = true; @@ -1551,7 +1598,8 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) return; } // Clicking elsewhere commits any open edit. - if (_editingParamNode is not null) CommitParamEdit(); + if (_editingParamNode is not null) CommitParamEdit(); + if (_editingCommentBlock is not null) CommitCommentEdit(); } // ── Hook toggle icon click (left button, any click count) ───────── @@ -1586,6 +1634,16 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) 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. @@ -1622,7 +1680,8 @@ 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 (hit is NodeViewModel or CommentBlockViewModel or GhostNodeViewModel) { @@ -2231,6 +2290,85 @@ private void OnInlineEditorLostFocus(object? sender, Avalonia.Interactivity.Rout 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 . + /// + 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. diff --git a/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs b/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs index bad4546..7d9c5c5 100644 --- a/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/CommentBlockViewModel.cs @@ -108,6 +108,16 @@ 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 _); + } + /// /// 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/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs index a06c83e..b538e5f 100644 --- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs +++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs @@ -193,6 +193,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 +262,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 diff --git a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml index 68b2602..6bd82a8 100644 --- a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml +++ b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml @@ -178,13 +178,16 @@ IsVisible="{Binding SelectedElement, Converter={x:Static ObjectConverters.IsNotNull}}"> + Opacity="0.7" + IsVisible="{Binding SelectedElementIsNotComment}"/>