using ImGuiNET; using T3.Core.Operator; using T3.Editor.Gui.MagGraph.Model; using T3.Editor.Gui.MagGraph.States; using T3.Editor.Gui.OutputUi; using T3.Editor.UiModel; using T3.Editor.UiModel.Commands; using T3.Editor.UiModel.Commands.Sections; using T3.Editor.UiModel.Commands.Graph; using T3.Editor.UiModel.InputsAndTypes; using T3.Editor.UiModel.Modification; using T3.Editor.UiModel.Selection; namespace T3.Editor.Gui.MagGraph.Interaction; internal static class Modifications { /// /// Duplicates the selected operators while preserving their external wiring: connections feeding the /// selection from the outside are recreated on the duplicates, and any external multi-inputs the originals /// fed get the duplicates' outputs appended — pushing snapped items below the new input line down, like an /// interactive connection drop. Single external inputs are left untouched so the duplicate doesn't steal /// the original's connection. Pastes at the mouse position and does not touch the clipboard. /// internal static void DuplicateWithConnections(GraphUiContext context) { var compositionOp = context.CompositionInstance; var compositionSymbolUi = compositionOp.GetSymbolUi(); var compositionSymbol = compositionSymbolUi.Symbol; var selectedChildUis = context.Selector.GetSelectedChildUis().ToList(); if (selectedChildUis.Count == 0) return; var selectedSections = context.Selector.GetSelectedNodes
().ToList(); var targetPosition = context.View.InverseTransformPositionFloat(ImGui.GetMousePos()); var copyCommand = new CopySymbolChildrenCommand(compositionSymbolUi, selectedChildUis, selectedSections, compositionSymbolUi, targetPosition); // Authoritative set of duplicated children (includes any pulled in via collapsed sections). var oldToNewChildIds = copyCommand.OldToNewChildIds; // Connections feeding the selection from outside, kept with their original multi-input index so // inserting them ascending lands each one at its original slot among the copied internal connections. var inboundCommands = new List<(int multiInputIndex, AddConnectionCommand command)>(); // Outputs of the selection feeding external multi-inputs, appended right after the original connection. // The original connection is kept for the push-down of snapped items below its input line. var outboundEntries = new List<(Symbol.Connection original, int multiInputIndex, AddConnectionCommand command)>(); foreach (var connection in compositionSymbol.Connections) { var sourceDuplicated = connection.SourceParentOrChildId != Guid.Empty && oldToNewChildIds.ContainsKey(connection.SourceParentOrChildId); var targetDuplicated = connection.TargetParentOrChildId != Guid.Empty && oldToNewChildIds.ContainsKey(connection.TargetParentOrChildId); var multiInputIndex = compositionSymbol.GetMultiInputIndexFor(connection); if (targetDuplicated && !sourceDuplicated) { // External source -> duplicated target (also covers connections from the composition's own inputs). var newConnection = new Symbol.Connection(connection.SourceParentOrChildId, connection.SourceSlotId, oldToNewChildIds[connection.TargetParentOrChildId], connection.TargetSlotId); inboundCommands.Add((multiInputIndex, new AddConnectionCommand(compositionSymbol, newConnection, multiInputIndex))); } else if (sourceDuplicated && !targetDuplicated && compositionSymbol.IsTargetMultiInput(connection)) { // Duplicated source -> external multi-input. Single inputs are skipped on purpose: adding the // duplicate's output there would replace (steal) the original's connection. var newConnection = new Symbol.Connection(oldToNewChildIds[connection.SourceParentOrChildId], connection.SourceSlotId, connection.TargetParentOrChildId, connection.TargetSlotId); outboundEntries.Add((connection, multiInputIndex, new AddConnectionCommand(compositionSymbol, newConnection, multiInputIndex + 1))); } } // Inbound ascending: each insert at the original index reconstructs the source order on duplicated inputs. inboundCommands.Sort((a, b) => a.multiInputIndex.CompareTo(b.multiInputIndex)); // Outbound descending: inserting at (originalIndex + 1) high-to-low keeps earlier indices valid as the // multi-input grows. outboundEntries.Sort((a, b) => b.multiInputIndex.CompareTo(a.multiInputIndex)); var macroCommand = context.StartMacroCommand("Duplicate with connections"); macroCommand.AddAndExecCommand(copyCommand); foreach (var entry in inboundCommands) { macroCommand.AddAndExecCommand(entry.command); } foreach (var entry in outboundEntries) { macroCommand.AddAndExecCommand(entry.command); } // Each appended multi-input connection adds an input line to its target: push snapped items below // the new line down by one row, like an interactive connection drop. The layout still reflects the // pre-duplication state here, which is exactly the geometry the thresholds need. foreach (var entry in outboundEntries) { if (!context.Layout.Items.TryGetValue(entry.original.TargetParentOrChildId, out var targetItem)) continue; var lines = targetItem.InputLines; var originalLineIndex = -1; for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++) { if (lines[lineIndex].Id == entry.original.TargetSlotId && lines[lineIndex].MultiInputIndex == entry.multiInputIndex) { originalLineIndex = lineIndex; break; } } if (originalLineIndex == -1) continue; var snappedItems = MagItemMovement.CollectSnappedItems(targetItem); snappedItems.Remove(targetItem); MagItemMovement.MoveSnappedItemsVertically(context, snappedItems, targetItem.PosOnCanvas.Y + MagGraphItem.GridSize.Y * (originalLineIndex + 0.5f), MagGraphItem.GridSize.Y); } // Duplicating into a section near its bottom/right border grows the frame to fit SectionTree.GrowSectionToFitPlacedItems(compositionSymbolUi, targetPosition, copyCommand.NewSymbolChildIds, context.MacroCommand!, context.Selector); context.CompleteMacroCommand(); // Select the duplicated nodes context.Selector.Clear(); foreach (var newId in copyCommand.NewSymbolChildIds) { if (compositionSymbolUi.ChildUis.TryGetValue(newId, out var newChildUi) && compositionOp.Children.TryGetChildInstance(newId, out var instance)) { context.Selector.AddSelection(newChildUi, instance); } } foreach (var newId in copyCommand.NewSymbolSectionIds) { if (compositionSymbolUi.Sections.TryGetValue(newId, out var section)) { context.Selector.AddSelection(section); } } } internal static ChangeSymbol.SymbolModificationResults AlignSelectionToLeft(GraphUiContext context) { var results = ChangeSymbol.SymbolModificationResults.Nothing; if (context.Selector.Selection.Count == 0) return results; if(!SymbolUiRegistry.TryGetSymbolUi(context.CompositionInstance.Symbol.Id, out var compositionUi)) { Log.Warning("Can't find composition ui?"); return results; } var minX = float.PositiveInfinity; foreach (var i in context.Selector.Selection) { minX = MathF.Min(minX, i.PosOnCanvas.X); } var moveCommand = new ModifyCanvasElementsCommand(compositionUi.Symbol.Id, context.Selector.Selection, context.Selector ); foreach (var i in context.Selector.Selection) { var pos = i.PosOnCanvas; pos.X = minX; i.PosOnCanvas= pos; } moveCommand.StoreCurrentValues(); UndoRedoStack.Add(moveCommand); return ChangeSymbol.SymbolModificationResults.Nothing; } /// /// Deletes the selected items and tries to collapse the gaps and patches the connection gaps is possible /// /// internal static ChangeSymbol.SymbolModificationResults DeleteSelection(GraphUiContext context) { var results = ChangeSymbol.SymbolModificationResults.Nothing; if (context.Selector.Selection.Count == 0) return results; if(!SymbolUiRegistry.TryGetSymbolUi(context.CompositionInstance.Symbol.Id, out var compositionUi)) { Log.Warning("Can't find composition ui?"); return results; } var deletedItems = new List(); var deletedChildUis = new List(); var deletedInputUis = new List(); var deletedOutputUis = new List(); var deletedSections = new List(); foreach (var s in context.Selector.Selection) { if (context.Layout.Items.TryGetValue(s.Id, out var item)) { if (item.Variant != MagGraphItem.Variants.Operator) { if (compositionUi.Symbol.SymbolPackage.IsReadOnly) { Log.Warning("Can't delete inputs or outputs from a read only symbol."); continue; } if (item.Variant == MagGraphItem.Variants.Input) { deletedInputUis.Add(item.Selectable as IInputUi); } else if (item.Variant == MagGraphItem.Variants.Output) { deletedOutputUis.Add(item.Selectable as IOutputUi); } continue; } if (!compositionUi.ChildUis.TryGetValue(item.Id, out var childUi)) { Log.Warning("Can't find symbol child for " + item); continue; } //uiItems.Add(new ItemWithChildUi(item, childUi)); deletedItems.Add(item); deletedChildUis.Add(childUi); } else if (s is Section section) { if (!context.Layout.Sections.TryGetValue(s.Id, out var mga)) { Log.Warning("Can't find section: " + s.Id); continue; } deletedSections.Add(mga); } else { Log.Warning("Can't find selectable item " + s); continue; } } // Deleting a collapsed frame deletes its hidden contents with it - once the // header is gone they would have no visible representation left on the canvas if (deletedSections.Count > 0) { var hiddenOps = new List(); var hiddenSections = new List
(); foreach (var magSection in deletedSections.ToArray()) { if (magSection.Section.Collapsed) { SectionTree.CollectHiddenContents(compositionUi, magSection.Section, hiddenOps, hiddenSections); } } foreach (var hiddenOp in hiddenOps) { if (deletedChildUis.Contains(hiddenOp)) continue; deletedChildUis.Add(hiddenOp); if (context.Layout.Items.TryGetValue(hiddenOp.Id, out var hiddenItem)) { deletedItems.Add(hiddenItem); } } foreach (var hiddenSection in hiddenSections) { if (!context.Layout.Sections.TryGetValue(hiddenSection.Id, out var hiddenMagSection)) { Log.Warning("Can't find hidden nested section: " + hiddenSection.Id); continue; } if (!deletedSections.Contains(hiddenMagSection)) { deletedSections.Add(hiddenMagSection); } } } // find obsolete connections... var obsoleteConnections = new HashSet(); foreach (var i in deletedItems) { foreach (var inLine in i.InputLines) { if (inLine.ConnectionIn != null) obsoleteConnections.Add(inLine.ConnectionIn); } foreach (var outLine in i.OutputLines) { obsoleteConnections.UnionWith(outLine.ConnectionsOut); } } if (deletedChildUis.Count == 0 && deletedInputUis.Count == 0 && deletedOutputUis.Count == 0 && deletedSections.Count==0) return results; var macroCommand = new MacroCommand("Delete items"); if (deletedChildUis.Count > 0) { var collapsableConnectionPairs= MagItemMovement.FindLinkedVerticalCollapsableConnectionPairs(obsoleteConnections.ToList()); // The bridge index must be computed before the deletion below removes // other connections into the same multi-input and shifts the positions. var deletedChildIds = new HashSet(); foreach (var childUi in deletedChildUis) { deletedChildIds.Add(childUi.Id); } var bridgeMultiInputIndices = new List(collapsableConnectionPairs.Count); foreach (var pair in collapsableConnectionPairs) { bridgeMultiInputIndices.Add(ComputeMultiInputIndexAfterDeletion(context.CompositionInstance.Symbol, pair.Cb, deletedChildIds)); } // Delete items... macroCommand.AddAndExecCommand(new DeleteSymbolChildrenCommand(compositionUi, deletedChildUis)); // Collapse vertical gaps var relevantItems = MagItemMovement.CollectSnappedItems(deletedItems); var movableItems = new HashSet(relevantItems.Except(deletedItems)); for (var pairIndex = 0; pairIndex < collapsableConnectionPairs.Count; pairIndex++) { var pair = collapsableConnectionPairs[pairIndex]; var mci = pair.Ca; var mco = pair.Cb; var affectedItems = MagItemMovement.MoveToCollapseVerticalGaps(mci, mco, movableItems, true); if (affectedItems.Count == 0) continue; var affectedItemsAsNodes = movableItems.Select(i => i as ISelectableCanvasObject).ToList(); var newMoveCommand = new ModifyCanvasElementsCommand(context.CompositionInstance.Symbol.Id, affectedItemsAsNodes, context.Selector); macroCommand.AddExecutedCommandForUndo(newMoveCommand); MagItemMovement.MoveToCollapseVerticalGaps(mci, mco, movableItems, dryRun:false); newMoveCommand.StoreCurrentValues(); macroCommand.AddAndExecCommand(new AddConnectionCommand(context.CompositionInstance.Symbol, new Symbol.Connection(mci.SourceItem.Id, mci.SourceOutput.Id, mco.TargetItem.Id, mco.TargetInput.Id), bridgeMultiInputIndices[pairIndex])); } // Removing a connection can also free an input row on a surviving target // (e.g. an extra multi-input line): move snapped items below up to close the gap. var rebridgedConnections = new HashSet(); foreach (var pair in collapsableConnectionPairs) { rebridgedConnections.Add(pair.Ca); rebridgedConnections.Add(pair.Cb); } foreach (var obsoleteConnection in obsoleteConnections) { if (rebridgedConnections.Contains(obsoleteConnection)) continue; var targetItem = obsoleteConnection.TargetItem; if (targetItem == null || deletedItems.Contains(targetItem)) continue; var lines = targetItem.InputLines; var lineIndex = obsoleteConnection.InputLineIndex; if (lineIndex >= lines.Length) continue; var line = lines[lineIndex]; var linesForSlot = 0; foreach (var otherLine in lines) { if (otherLine.Id == line.Id) linesForSlot++; } // The row only disappears for extra multi-input lines and for optional inputs shown while connected var freesInputLine = linesForSlot > 1 || (targetItem.Variant == MagGraphItem.Variants.Operator && line.InputUi is { Relevancy: Relevancy.Optional } && lineIndex > 0); if (!freesInputLine) continue; var snappedItems = MagItemMovement.CollectSnappedItems(targetItem); var yThreshold = targetItem.PosOnCanvas.Y + MagGraphItem.GridSize.Y * (lineIndex - 0.5f); var itemsBelow = new List(); foreach (var snappedItem in snappedItems) { if (snappedItem != targetItem && !deletedItems.Contains(snappedItem) && snappedItem.PosOnCanvas.Y > yThreshold) { itemsBelow.Add(snappedItem); } } if (itemsBelow.Count == 0) continue; var moveUpCommand = new ModifyCanvasElementsCommand(context.CompositionInstance.Symbol.Id, itemsBelow, context.Selector); macroCommand.AddExecutedCommandForUndo(moveUpCommand); foreach (var itemBelow in itemsBelow) { var pos = itemBelow.PosOnCanvas; pos.Y -= MagGraphItem.GridSize.Y; itemBelow.PosOnCanvas = pos; } moveUpCommand.StoreCurrentValues(); } } if (deletedInputUis.Count > 0 || deletedOutputUis.Count > 0) { // Executed after the child deletes above, so the command only captures the connections // still present (child-side connections were already removed by DeleteSymbolChildrenCommand). macroCommand.AddAndExecCommand(new RemoveInputsOrOutputsCommand(compositionUi.Symbol.Id, deletedInputUis.Select(entry => entry.Id).ToArray(), deletedOutputUis.Select(entry => entry.Id).ToArray())); } if (deletedSections.Count > 0) { foreach (var a in deletedSections) { macroCommand.AddAndExecCommand(new DeleteSectionCommand(compositionUi, a.Section)); } } UndoRedoStack.Add(macroCommand); context.Layout.FlagStructureAsChanged(); context.Selector.Clear(); context.Layout.FlagStructureAsChanged(); return results; } //private record ItemWithChildUi(MagGraphItem Item, SymbolUi.Child UiChild); /// /// Computes the multi-input index a bridging connection should be inserted at once the /// given children are deleted: the number of surviving connections into the same target /// slot that precede the replaced connection. /// private static int ComputeMultiInputIndexAfterDeletion(Symbol symbol, MagGraphConnection replacedConnection, HashSet deletedChildIds) { var survivingIndex = 0; var slotConnectionIndex = 0; foreach (var connection in symbol.Connections) { if (connection.TargetParentOrChildId != replacedConnection.TargetItem.Id || connection.TargetSlotId != replacedConnection.TargetInput.Id) continue; if (slotConnectionIndex == replacedConnection.MultiInputIndex) break; slotConnectionIndex++; if (!deletedChildIds.Contains(connection.SourceParentOrChildId) && !deletedChildIds.Contains(connection.TargetParentOrChildId)) { survivingIndex++; } } return survivingIndex; } }