diff --git a/EvenBetterFastSim/EvenBetterFastSim.csproj b/EvenBetterFastSim/EvenBetterFastSim.csproj index 5d60372..bc24521 100644 --- a/EvenBetterFastSim/EvenBetterFastSim.csproj +++ b/EvenBetterFastSim/EvenBetterFastSim.csproj @@ -45,8 +45,8 @@ - - + + diff --git a/EvenBetterFastSim/WPF/ViewModels/Responders/NodeResponderViewModel.cs b/EvenBetterFastSim/WPF/ViewModels/Responders/NodeResponderViewModel.cs index 0c0f1a6..0b8f3cd 100644 --- a/EvenBetterFastSim/WPF/ViewModels/Responders/NodeResponderViewModel.cs +++ b/EvenBetterFastSim/WPF/ViewModels/Responders/NodeResponderViewModel.cs @@ -53,9 +53,6 @@ public partial class NodeResponderViewModel : ObservableObject, IBaseViewModel private ResponderFieldMode fieldMode; private Func>? fieldParameterLookup; - /// Bindings mode: cloned message shapes keyed by the Receive node id that captured them. - private readonly Dictionary triggerShapes = []; - /// Bindings mode: selectable parameters per Receive node id. private readonly Dictionary> parametersByNode = []; @@ -71,13 +68,13 @@ public partial class NodeResponderViewModel : ObservableObject, IBaseViewModel /// First dropdown options โ€” the received messages available on this path. public ObservableCollection SourceMessages { get; } = []; - public ObservableCollection SampleInputs { get; } = []; - public IReadOnlyList ConditionKinds { get; } = Enum.GetValues(); public IReadOnlyList BindingSourceKinds { get; } = Enum.GetValues(); - [ObservableProperty] public partial string TestResult { get; set; } = string.Empty; - public ObservableCollection TestPreviewFields { get; } = []; + /// Row selected in the field tree โ€” target for the Add / Duplicate / Delete / Move buttons. + [ObservableProperty] public partial ResponderFieldViewModel? SelectedField { get; set; } + + partial void OnSelectedFieldChanged(ResponderFieldViewModel? value) => NotifyStructureCommands(); /// Row selected in the field tree โ€” target for the Add / Duplicate / Delete / Move buttons. [ObservableProperty] public partial ResponderFieldViewModel? SelectedField { get; set; } @@ -118,7 +115,6 @@ public void InitializeForSend(ScenarioNodeViewModel sendNode, IReadOnlyList(); @@ -177,8 +173,6 @@ private void BuildFields(SecsGemDataMessage? message, ResponderFieldMode mode, private void CollectParameters(ResponderFieldViewModel field, string sourceNodeId, string sourceLabel, List into) { into.Add(new PathOption(field.Path, $"[{field.Path}] {field.DisplayLabel}", sourceNodeId)); - if (field.IsLeaf) - SampleInputs.Add(new SampleInputViewModel(sourceNodeId, $"{sourceLabel} ยท [{field.Path}]", field.Path)); foreach (var child in field.Children) CollectParameters(child, sourceNodeId, sourceLabel, into); @@ -236,53 +230,223 @@ private static IEnumerable Flatten(IEnumerable CurrentConditions() => + Flatten(Fields).Select(f => f.ToCondition()).OfType(); + + private IEnumerable CurrentBindings() => + Flatten(Fields).Select(f => f.ToBinding()).OfType(); - [RelayCommand] - private void RunTest() + // ---- structural message editing -------------------------------- + // + // The toolbar in NodeResponderView drives these. Each one mutates `workingMessage`, then + // CommitStructuralChange rebuilds the field tree (so positional paths are recomputed) while + // carrying the user's in-progress condition / binding edits across by item identity. + + /// Add Item targets a container: a selected List, or the message body when nothing is selected. + private bool CanAddChildItem() => workingMessage is not null && SelectedField is null or { IsList: true }; + private bool HasSelectedField() => workingMessage is not null && SelectedField is not null; + + private void NotifyStructureCommands() + { + AddItemCommand.NotifyCanExecuteChanged(); + AddSiblingCommand.NotifyCanExecuteChanged(); + DuplicateItemCommand.NotifyCanExecuteChanged(); + DeleteItemCommand.NotifyCanExecuteChanged(); + MoveItemUpCommand.NotifyCanExecuteChanged(); + MoveItemDownCommand.NotifyCanExecuteChanged(); + } + + /// Adds a U1 item as the last child of the selected List (or of the message body). + [RelayCommand(CanExecute = nameof(CanAddChildItem))] + private void AddItem() + { + if (workingMessage is null) return; + + ICanBeParent container = SelectedField is { IsList: true } listField ? listField.Item : workingMessage; + var newItem = SecsGemItem.Create(SecsGemItemFormatType.U1); + newItem.SetParent(container); + CommitStructuralChange(newItem); + } + + /// Adds a U1 item as the next sibling of the selected item. + [RelayCommand(CanExecute = nameof(HasSelectedField))] + private void AddSibling() + { + if (SelectedField is not { } field || ContainerOf(field) is not { } location) return; + + var newItem = SecsGemItem.Create(SecsGemItemFormatType.U1); + newItem.SetParent(location.Container); + MoveWithin(location.Container, ChildrenOf(location.Container).Count - 1, location.Index + 1); + CommitStructuralChange(newItem); + } + + [RelayCommand(CanExecute = nameof(HasSelectedField))] + private void DuplicateItem() + { + if (SelectedField is not { } field || ContainerOf(field) is not { } location) return; + + var clone = field.Item.Clone(); + clone.SetParent(location.Container); + MoveWithin(location.Container, ChildrenOf(location.Container).Count - 1, location.Index + 1); + CommitStructuralChange(clone); + } + + [RelayCommand(CanExecute = nameof(HasSelectedField))] + private void DeleteItem() + { + if (SelectedField is not { } field || ContainerOf(field) is not { } location) return; + + var siblings = ChildrenOf(location.Container); + field.Item.SetParent(null); + var next = siblings.Count == 0 ? null : siblings[Math.Min(location.Index, siblings.Count - 1)] as SecsGemItem; + CommitStructuralChange(next); + } + + [RelayCommand(CanExecute = nameof(CanMoveItemUp))] + private void MoveItemUp() => MoveSelected(-1); + + private bool CanMoveItemUp() => + SelectedField is { } f && ContainerOf(f) is { } location && location.Index > 0; + + [RelayCommand(CanExecute = nameof(CanMoveItemDown))] + private void MoveItemDown() => MoveSelected(+1); + + private bool CanMoveItemDown() => + SelectedField is { } f && ContainerOf(f) is { } location + && location.Index < ChildrenOf(location.Container).Count - 1; + + private void MoveSelected(int delta) + { + if (SelectedField is not { } field || ContainerOf(field) is not { } location) return; + + var target = location.Index + delta; + if (target < 0 || target >= ChildrenOf(location.Container).Count) return; + MoveWithin(location.Container, location.Index, target); + CommitStructuralChange(field.Item); + } + + private void OnFieldFormatChangeRequested(ResponderFieldViewModel field) { - TestPreviewFields.Clear(); - if (Mode != NodeResponderMode.Bindings || node?.Transaction is null || workingMessage is null) + // The change comes from a ComboBox selection commit; rebuilding Fields synchronously + // underneath it upsets the binding pipeline, so hop through the dispatcher when there is one. + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher is null || dispatcher.CheckAccess() == false) + ApplyFormatChange(field); + else + dispatcher.BeginInvoke(() => ApplyFormatChange(field)); + } + + private void ApplyFormatChange(ResponderFieldViewModel field) + { + if (workingMessage is null || field.Format == field.Item.FormatType) return; + if (ContainerOf(field) is not { } location) return; + + var replacement = SecsGemItem.Create(field.Format); + replacement.Description = field.Item.Description; + if (field.Format == SecsGemItemFormatType.List) { - TestResult = "Nothing to preview."; - return; + // Preserve any children when a value item becomes a list. + foreach (var child in field.Item.Children.OfType().ToList()) + child.SetParent(replacement); + } + else + { + // Best-effort value carry-over; parsing drops anything the new format can't represent. + replacement.SetValuesFromStrings(field.Item.GetStringValues()); } - var samples = triggerShapes.ToDictionary( - kvp => kvp.Key, - kvp => (SecsGemDataMessage)kvp.Value.Clone()); + var siblings = ChildrenOf(location.Container); + field.Item.SetParent(null); + replacement.SetParent(location.Container); + MoveWithin(location.Container, siblings.Count - 1, location.Index); + CommitStructuralChange(replacement); + } + + /// The container (message body or a List item) holding a field, plus the field's index in it. + private readonly record struct FieldLocation(ICanBeParent Container, int Index); + + private FieldLocation? ContainerOf(ResponderFieldViewModel field) + { + if (workingMessage is null) return null; + + var segments = field.Path.Split('.'); + if (!int.TryParse(segments[^1], out var index)) return null; + + if (segments.Length == 1) + return new FieldLocation(workingMessage, index); + + var parentPath = string.Join('.', segments[..^1]); + return SecsGemItemPath.TryResolve(workingMessage, parentPath, out var parentItem) + ? new FieldLocation(parentItem, index) + : null; + } + + private static ObservableCollection ChildrenOf(ICanBeParent container) => + (ObservableCollection)((IDataItem)container).Children; + + private static void MoveWithin(ICanBeParent container, int from, int to) + { + var children = ChildrenOf(container); + if (from >= 0 && to >= 0 && from < children.Count && to < children.Count && from != to) + children.Move(from, to); + } - foreach (var sample in SampleInputs) + /// + /// Rebuilds the field tree from the mutated , re-applies the user's + /// pending condition / binding edits (keyed by item identity, stable across the rebuild), and + /// re-selects . + /// + private void CommitStructuralChange(SecsGemItem? itemToSelect) + { + structureDirty = true; + + var conditionEdits = new Dictionary(); + var bindingEdits = new Dictionary(); + foreach (var field in Flatten(Fields)) { - if (samples.TryGetValue(sample.SourceNodeId, out var message) - && SecsGemItemPath.TryResolve(message, sample.ItemPath, out var item)) + if (fieldMode == ResponderFieldMode.Trigger) { - item.SetValuesFromStrings(sample.Value.Split(',', StringSplitOptions.RemoveEmptyEntries)); + if (field.Condition != ConditionKind.Any) + conditionEdits[field.Item] = (field.Condition, field.ConditionValue); + } + else + { + bindingEdits[field.Item] = + (field.Source, field.StaticValue, field.SourceMessage?.NodeId, field.SourceParameter?.Path); } } - var fallback = samples.Values.LastOrDefault(); - SecsGemDataMessage? Resolve(string? id) => - id is not null && samples.TryGetValue(id, out var m) ? m : fallback; - - var outgoing = (SecsGemDataMessage)workingMessage.Clone(); - foreach (var binding in CurrentBindings()) - binding.Apply(outgoing, Resolve); + BuildFields(workingMessage, fieldMode, fieldParameterLookup); - var index = 0; - foreach (var item in outgoing.Children.OfType()) + foreach (var field in Flatten(Fields)) { - TestPreviewFields.Add(new ResponderFieldViewModel(item, index.ToString(), ResponderFieldMode.Response)); - index++; - } - TestResult = $"Resolved {node.Transaction.PrimaryMessage.Name}"; - } + if (conditionEdits.TryGetValue(field.Item, out var condition)) + { + field.Condition = condition.Kind; + field.ConditionValue = condition.Value; + } - private IEnumerable CurrentConditions() => - Flatten(Fields).Select(f => f.ToCondition()).OfType(); + if (bindingEdits.TryGetValue(field.Item, out var binding)) + { + field.Source = binding.Source; + if (binding.Source == BindingSourceKind.Literal) + { + field.StaticValue = binding.StaticValue; + } + else + { + field.SourceMessage = SourceMessages.FirstOrDefault(m => m.NodeId == binding.NodeId) + ?? SourceMessages.FirstOrDefault(); + field.SourceParameter = field.AvailableParameters.FirstOrDefault(p => p.Path == binding.ParamPath); + } + } + } - private IEnumerable CurrentBindings() => - Flatten(Fields).Select(f => f.ToBinding()).OfType(); + SelectedField = itemToSelect is null + ? null + : Flatten(Fields).FirstOrDefault(f => ReferenceEquals(f.Item, itemToSelect)); + NotifyStructureCommands(); + } // ---- structural message editing -------------------------------- // @@ -535,14 +699,3 @@ private void AcceptButtonClick() [RelayCommand] private void CancelClick() => CloseAction?.Invoke(); } - -/// An incoming leaf plus a sample value, used by the Bindings-mode "Test" button. -public partial class SampleInputViewModel(string sourceNodeId, string label, string itemPath) : ObservableObject -{ - public string SourceNodeId { get; } = sourceNodeId; - public string Label { get; } = label; - public string ItemPath { get; } = itemPath; - - [ObservableProperty] - public partial string Value { get; set; } = string.Empty; -} diff --git a/EvenBetterFastSim/WPF/ViewModels/Responders/ResponderFieldViewModel.cs b/EvenBetterFastSim/WPF/ViewModels/Responders/ResponderFieldViewModel.cs index 2c0b4d0..59e19ec 100644 --- a/EvenBetterFastSim/WPF/ViewModels/Responders/ResponderFieldViewModel.cs +++ b/EvenBetterFastSim/WPF/ViewModels/Responders/ResponderFieldViewModel.cs @@ -83,10 +83,6 @@ public string DisplayLabel [ObservableProperty] public partial string ConditionValue { get; set; } = string.Empty; - /// Value used for this leaf when the editor's "Test" builds a sample incoming message. - [ObservableProperty] - public partial string SampleValue { get; set; } = string.Empty; - // --- Response mode ----------------------------------------------------- [ObservableProperty] public partial BindingSourceKind Source { get; set; } = BindingSourceKind.Literal; @@ -120,13 +116,8 @@ public ResponderFieldViewModel( this.parameterLookup = parameterLookup; InitialLeafValue = IsLeaf ? string.Join(",", item.GetStringValues()) : string.Empty; - if (IsLeaf) - { - if (mode == ResponderFieldMode.Response) - StaticValue = InitialLeafValue; - else - SampleValue = InitialLeafValue; - } + if (IsLeaf && mode == ResponderFieldMode.Response) + StaticValue = InitialLeafValue; var index = 0; foreach (var child in item.Children.OfType()) diff --git a/EvenBetterFastSim/WPF/Windows/NodeResponderView.xaml b/EvenBetterFastSim/WPF/Windows/NodeResponderView.xaml index 461612e..1c6abaf 100644 --- a/EvenBetterFastSim/WPF/Windows/NodeResponderView.xaml +++ b/EvenBetterFastSim/WPF/Windows/NodeResponderView.xaml @@ -53,7 +53,7 @@ ToolTip="Fixed value for this item" Text="{Binding StaticValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"> -