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
48 changes: 47 additions & 1 deletion src/XTMF2.GUI/Controls/ModelSystemCanvas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,13 @@ public sealed partial class ModelSystemCanvas : Control
private CommentBlockViewModel? _editingCommentBlock;
/// <summary>Model-space position and size of the comment editor overlay.</summary>
private double _editingCommentEditorX, _editingCommentEditorY, _editingCommentEditorW, _editingCommentEditorH;

// ── Inline comment header editor ───────────────────────────────
/// <summary>Overlay single-line TextBox used for editing the comment block header.</summary>
private readonly TextBox _commentHeaderEditor;
/// <summary>The comment block whose header is being edited, or <c>null</c> when idle.</summary>
private CommentBlockViewModel? _editingCommentHeaderBlock;
/// <summary>Model-space position and size of the comment header editor overlay.</summary>
private double _editingCommentHeaderEditorX, _editingCommentHeaderEditorY, _editingCommentHeaderEditorW, _editingCommentHeaderEditorH;
// ── Inline name editor ───────────────────────────────────────────────────
/// <summary>Overlay single-line TextBox used for renaming nodes and starts.</summary>
private readonly TextBox _nameEditor;
Expand All @@ -167,6 +173,11 @@ public sealed partial class ModelSystemCanvas : Control
private readonly Dictionary<(FunctionInstanceViewModel, FunctionParameterHook), NodeViewModel>
_fiHookInlinedParam = new();
/// <summary>
/// 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.
/// </summary>
private readonly Dictionary<CommentBlockViewModel, double> _commentMaxScrollOffsets = new();
/// <summary>
/// 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.
/// </summary>
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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)) ||
Expand All @@ -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();
}
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -179,14 +180,15 @@ public void BeginCommentEditForSelected()
}
}

/// <summary>Shows the multi-line comment editor over <paramref name="comment"/>.</summary>
/// <summary>Shows the multi-line comment editor over <paramref name="comment"/>, positioned below the header band.</summary>
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;
Expand Down Expand Up @@ -252,4 +254,75 @@ private void OnCommentEditorLostFocus(object? sender, Avalonia.Interactivity.Rou
}
}

// ── Inline comment header editor helpers ────────────────────────────

/// <summary>Shows the single-line header editor over the header band of <paramref name="comment"/>.</summary>
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);
}

/// <summary>Saves the header editor text and closes the editor.</summary>
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();
}

/// <summary>Discards the header edit without saving.</summary>
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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
Loading
Loading