554 lines
22 KiB
C#
554 lines
22 KiB
C#
#nullable enable
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using ImGuiNET;
|
|
using T3.Core.Operator;
|
|
using T3.Core.Operator.Slots;
|
|
using T3.Editor.Gui.MagGraph.Interaction;
|
|
using T3.Editor.Gui.Interaction;
|
|
using T3.Editor.Gui.MagGraph.Model;
|
|
using T3.Editor.Gui.MagGraph.States;
|
|
using T3.Editor.Gui.UiHelpers;
|
|
using T3.Editor.UiModel;
|
|
using T3.Editor.UiModel.Commands.Graph;
|
|
using T3.Editor.UiModel.ProjectHandling;
|
|
using T3.Editor.UiModel.Selection;
|
|
|
|
namespace T3.Editor.Gui.MagGraph.Ui;
|
|
|
|
/**
|
|
* Draws and handles interaction with graph.
|
|
*/
|
|
internal sealed partial class MagGraphView : ScalableCanvas, IGraphView
|
|
{
|
|
public ScalableCanvas Canvas => this;
|
|
|
|
public static ProjectView CreateWithComponents(OpenedProject openedProject)
|
|
{
|
|
ProjectView.CreateIndependentComponents(openedProject,
|
|
out var navigationHistory,
|
|
out var nodeSelection,
|
|
out var graphImageBackground);
|
|
|
|
var projectView = new ProjectView(openedProject, navigationHistory, nodeSelection, graphImageBackground);
|
|
|
|
if (projectView.CompositionInstance == null)
|
|
{
|
|
Log.Error("Can't create graph without defined composition op");
|
|
return projectView; // TODO: handle this properly
|
|
}
|
|
|
|
var canvas = new Gui.MagGraph.Ui.MagGraphView(projectView);
|
|
projectView.OnCompositionChanged += canvas.CompositionChangedHandler;
|
|
projectView.OnCompositionContentChanged += canvas.CompositionContentChangedHandler;
|
|
|
|
projectView.GraphView = canvas;
|
|
return projectView;
|
|
}
|
|
|
|
private void CompositionChangedHandler(ProjectView arg1, Guid arg2)
|
|
{
|
|
_context.Layout.FlagStructureAsChanged();
|
|
}
|
|
|
|
private void CompositionContentChangedHandler(ProjectView view, ProjectView.ChangeTypes changes)
|
|
{
|
|
Debug.Assert(view == _projectView);
|
|
if ((changes & (ProjectView.ChangeTypes.Connections | ProjectView.ChangeTypes.Children)) != 0)
|
|
{
|
|
_context.Layout.FlagStructureAsChanged();
|
|
}
|
|
}
|
|
|
|
private readonly ProjectView _projectView;
|
|
|
|
#region implement IGraph canvas
|
|
public bool Destroyed { get => _destroyed; set => _destroyed = value; }
|
|
|
|
void IGraphView.FocusViewToSelection()
|
|
{
|
|
if (_projectView.CompositionInstance == null)
|
|
return;
|
|
|
|
var selectionBounds = NodeSelection.GetSelectionBounds(_projectView.NodeSelection, _projectView.CompositionInstance);
|
|
FitAreaOnCanvas(selectionBounds);
|
|
}
|
|
|
|
public void FlagStructureAsChanged()
|
|
{
|
|
_context?.Layout.FlagStructureAsChanged();
|
|
}
|
|
|
|
public void FocusViewToSelection(GraphUiContext context)
|
|
{
|
|
var areaOnCanvas = NodeSelection.GetSelectionBounds(context.Selector, context.CompositionInstance);
|
|
areaOnCanvas.Expand(200);
|
|
FitAreaOnCanvas(areaOnCanvas);
|
|
}
|
|
|
|
void IGraphView.OpenAndFocusInstance(IReadOnlyList<Guid> path)
|
|
{
|
|
if (path.Count == 1)
|
|
{
|
|
_projectView.TrySetCompositionOp(path, ScalableCanvas.Transition.JumpOut, path[0]);
|
|
return;
|
|
}
|
|
|
|
var compositionPath = path.Take(path.Count - 1).ToList();
|
|
_projectView.TrySetCompositionOp(compositionPath, ScalableCanvas.Transition.JumpIn, path[^1]);
|
|
}
|
|
|
|
public void OpenAndFocusSection(IReadOnlyList<Guid> compositionPath, Guid sectionId)
|
|
{
|
|
if (compositionPath.Count == 1)
|
|
{
|
|
if (!_projectView.TrySetCompositionOp(compositionPath, ScalableCanvas.Transition.JumpOut, compositionPath[0]))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var compositionPath2 = compositionPath.Take(compositionPath.Count).ToList();
|
|
var compositionId = compositionPath[^1];
|
|
if (!_projectView.TrySetCompositionOp(compositionPath2, ScalableCanvas.Transition.JumpIn, compositionId))
|
|
return;
|
|
}
|
|
|
|
// We assume that composition has been set....
|
|
if (_projectView.CompositionInstance == null)
|
|
return;
|
|
|
|
var symbolUi = _projectView.CompositionInstance.GetSymbolUi();
|
|
if (!symbolUi.Sections.TryGetValue(sectionId, out var section))
|
|
return;
|
|
|
|
var area = ImRect.RectWithSize(section.PosOnCanvas, section.Size);
|
|
area.Expand(100);
|
|
RequestTargetViewAreaWithTransition(area, Transition.Smooth);
|
|
}
|
|
|
|
public void OpenAndFocusInputOrOutput(IReadOnlyList<Guid> compositionPath, Guid ioId)
|
|
{
|
|
if (compositionPath.Count == 1)
|
|
{
|
|
if (!_projectView.TrySetCompositionOp(compositionPath, ScalableCanvas.Transition.JumpOut, compositionPath[0]))
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
if (!_projectView.TrySetCompositionOp(compositionPath, ScalableCanvas.Transition.JumpIn, compositionPath[^1]))
|
|
return;
|
|
}
|
|
|
|
if (_projectView.CompositionInstance == null)
|
|
return;
|
|
|
|
var symbolUi = _projectView.CompositionInstance.GetSymbolUi();
|
|
ISelectableCanvasObject? ioUi = null;
|
|
if (symbolUi.InputUis.TryGetValue(ioId, out var inputUi))
|
|
{
|
|
ioUi = inputUi;
|
|
}
|
|
else if (symbolUi.OutputUis.TryGetValue(ioId, out var outputUi))
|
|
{
|
|
ioUi = outputUi;
|
|
}
|
|
|
|
if (ioUi == null)
|
|
return;
|
|
|
|
_projectView.NodeSelection.SetSelection(ioUi);
|
|
var area = ImRect.RectWithSize(ioUi.PosOnCanvas, MagGraphItem.GridSize);
|
|
area.Expand(150);
|
|
RequestTargetViewAreaWithTransition(area, Transition.Smooth);
|
|
}
|
|
|
|
private Instance _previousInstance;
|
|
|
|
void IGraphView.BeginDraw(bool backgroundActive, bool bgHasInteractionFocus)
|
|
{
|
|
//TODO: This should probably be handled by CompositionChangedHandler
|
|
if (_projectView.CompositionInstance != null && _projectView.CompositionInstance != _previousInstance)
|
|
{
|
|
if (bgHasInteractionFocus && _context.StateMachine.CurrentState != GraphStates.BackgroundContentIsInteractive)
|
|
{
|
|
_context.StateMachine.SetState(GraphStates.BackgroundContentIsInteractive, _context);
|
|
}
|
|
|
|
_previousInstance = _projectView.CompositionInstance;
|
|
_context = new GraphUiContext(_projectView, this);
|
|
}
|
|
}
|
|
|
|
public bool HasActiveInteraction => _context.StateMachine.CurrentState != GraphStates.Default;
|
|
|
|
void IGraphView.Close()
|
|
{
|
|
_destroyed = true;
|
|
_projectView.OnCompositionChanged -= CompositionChangedHandler;
|
|
_projectView.OnCompositionContentChanged -= CompositionContentChangedHandler;
|
|
}
|
|
|
|
void IGraphView.CreatePlaceHolderConnectedToInput(SymbolUi.Child symbolChildUi, Symbol.InputDefinition inputInputDefinition)
|
|
{
|
|
if (_context.StateMachine.CurrentState != GraphStates.Default)
|
|
{
|
|
Log.Debug("Can't insert placeholder while interaction is active");
|
|
return;
|
|
}
|
|
|
|
if (_context.Layout.Items.TryGetValue(symbolChildUi.Id, out var item))
|
|
{
|
|
_context.Placeholder.OpenForItemInput(_context, item, inputInputDefinition.Id, MagGraphItem.Directions.Horizontal);
|
|
|
|
// Calculate where the placeholder will be (to the left of the item)
|
|
var placeholderPos = item.PosOnCanvas.X - MagGraphItem.Width;
|
|
var currentVisibleArea = GetVisibleCanvasArea();
|
|
|
|
// If placeholder position is outside the left edge of visible area, scroll to make it visible
|
|
if (placeholderPos < currentVisibleArea.Min.X)
|
|
{
|
|
var scrollOffset = currentVisibleArea.Min.X - placeholderPos;
|
|
scrollOffset += 10; // Add some padding
|
|
ScrollTarget.X -= scrollOffset;
|
|
}
|
|
}
|
|
}
|
|
|
|
void IGraphView.ExtractAsConnectedOperator<T>(InputSlot<T> inputSlot, SymbolUi.Child symbolChildUi, Symbol.Child.Input input)
|
|
{
|
|
if (!_context.Layout.Items.TryGetValue(symbolChildUi.Id, out var sourceItem))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var insertionLineIndex = InputPicking.GetInsertionLineIndex(inputSlot.Parent.Inputs,
|
|
sourceItem.InputLines,
|
|
input.Id,
|
|
out var shouldPushDown);
|
|
|
|
var focusedItemPosOnCanvas = sourceItem.PosOnCanvas + new Vector2(-sourceItem.Size.X, MagGraphItem.GridSize.Y * insertionLineIndex);
|
|
|
|
var macro = _context.StartMacroCommand("Extract parameters");
|
|
if (shouldPushDown)
|
|
{
|
|
MagItemMovement
|
|
.MoveSnappedItemsVertically(_context,
|
|
MagItemMovement.CollectSnappedItems(sourceItem, includeRoot: false),
|
|
sourceItem.PosOnCanvas.Y + (insertionLineIndex - 0.5f) * MagGraphItem.GridSize.Y,
|
|
MagGraphItem.GridSize.Y);
|
|
}
|
|
|
|
ParameterExtraction.ExtractAsConnectedOperator(inputSlot, symbolChildUi, input, focusedItemPosOnCanvas, collectInto: macro);
|
|
_context.Layout.FlagStructureAsChanged();
|
|
_context.CompleteMacroCommand();
|
|
}
|
|
|
|
void IGraphView.StartDraggingFromInputSlot(SymbolUi.Child symbolChildUi, Symbol.InputDefinition inputDefinition)
|
|
{
|
|
// The caller fires every frame while the drag exceeds the click threshold, so only act from idle state.
|
|
if (_context.StateMachine.CurrentState != GraphStates.Default)
|
|
return;
|
|
|
|
if (!_context.Layout.Items.TryGetValue(symbolChildUi.Id, out var item))
|
|
return;
|
|
|
|
StartDraggingFromInput(item, inputDefinition);
|
|
}
|
|
|
|
/** Rips off an existing connection into the input or starts dragging a new one from it. */
|
|
internal void StartDraggingFromInput(MagGraphItem item, Symbol.InputDefinition inputDefinition)
|
|
{
|
|
// If the input is already connected, rip off the connection and drag its loose end...
|
|
MagGraphConnection? connectionIn = null;
|
|
foreach (var c in _context.Layout.MagConnections)
|
|
{
|
|
if (c.TargetItem != item || c.TargetInput.Id != inputDefinition.Id)
|
|
continue;
|
|
|
|
if (connectionIn == null || c.MultiInputIndex < connectionIn.MultiInputIndex)
|
|
connectionIn = c;
|
|
}
|
|
|
|
if (connectionIn != null)
|
|
{
|
|
_context.ActiveSourceOutputId = connectionIn.SourceOutput.Id;
|
|
_context.StartMacroCommand($"Disconnect {item.ReadableName}.{inputDefinition.Name}")
|
|
.AddAndExecCommand(new DeleteConnectionCommand(_context.CompositionInstance.Symbol,
|
|
connectionIn.AsSymbolConnection(),
|
|
connectionIn.MultiInputIndex));
|
|
|
|
if (MagItemMovement.DisconnectedInputWouldCollapseLine(connectionIn))
|
|
{
|
|
var snappedItems = MagItemMovement.CollectSnappedItems(connectionIn.TargetItem);
|
|
snappedItems.Remove(connectionIn.TargetItem);
|
|
MagItemMovement.MoveSnappedItemsVertically(_context,
|
|
snappedItems,
|
|
connectionIn.TargetItem.PosOnCanvas.Y +
|
|
MagGraphItem.GridSize.Y * (connectionIn.InputLineIndex + 0.5f),
|
|
-MagGraphItem.GridSize.Y);
|
|
}
|
|
|
|
_context.TempConnections.Add(new MagGraphConnection
|
|
{
|
|
Style = MagGraphConnection.ConnectionStyles.Unknown,
|
|
SourcePos = connectionIn.SourcePos,
|
|
SourceItem = connectionIn.SourceItem,
|
|
SourceOutput = connectionIn.SourceOutput,
|
|
IsTemporary = true,
|
|
WasDisconnected = true,
|
|
});
|
|
|
|
_context.ActiveSourceItem = connectionIn.SourceItem;
|
|
_context.ActiveItem = connectionIn.SourceItem;
|
|
_context.DraggedPrimaryOutputType = connectionIn.Type;
|
|
_context.StateMachine.SetState(GraphStates.DragConnectionEnd, _context);
|
|
_context.Layout.FlagStructureAsChanged();
|
|
return;
|
|
}
|
|
|
|
// Unconnected input: keep its line visible while dragging, then drag a new connection's beginning.
|
|
// Irrelevant inputs have no line in the layout yet, so recompute synchronously to get its index.
|
|
_context.DisconnectedInputHashes.Add(MagGraphConnection.GetItemInputHash(item.Id, inputDefinition.Id, 0));
|
|
_context.Layout.FlagStructureAsChanged();
|
|
_context.Layout.ComputeLayout(_context);
|
|
|
|
if (!_context.Layout.Items.TryGetValue(item.Id, out var item2))
|
|
return;
|
|
|
|
item = item2; // prevent nullable warning
|
|
|
|
var inputLineIndex = 0;
|
|
while (inputLineIndex < item.InputLines.Length && item.InputLines[inputLineIndex].Id != inputDefinition.Id)
|
|
{
|
|
inputLineIndex++;
|
|
}
|
|
|
|
if (inputLineIndex == item.InputLines.Length)
|
|
{
|
|
Log.Warning($"Can't find input line for {inputDefinition.Name}?");
|
|
return;
|
|
}
|
|
|
|
_context.StartMacroCommand($"Connect to {item.ReadableName}.{inputDefinition.Name}");
|
|
_context.TempConnections.Add(new MagGraphConnection
|
|
{
|
|
Style = MagGraphConnection.ConnectionStyles.Unknown,
|
|
TargetPos = new Vector2(item.Area.Min.X,
|
|
item.Area.Min.Y + MagGraphItem.GridSize.Y * (0.5f + inputLineIndex)),
|
|
TargetItem = item,
|
|
InputLineIndex = inputLineIndex,
|
|
MultiInputIndex = item.InputLines[inputLineIndex].MultiInputIndex,
|
|
IsTemporary = true,
|
|
});
|
|
_context.DraggedPrimaryOutputType = inputDefinition.DefaultValue.ValueType;
|
|
_context.StateMachine.SetState(GraphStates.DragConnectionBeginning, _context);
|
|
}
|
|
#endregion
|
|
|
|
private MagGraphView(ProjectView projectView)
|
|
{
|
|
_projectView = projectView;
|
|
EnableParentZoom = false;
|
|
_context = new GraphUiContext(projectView, this);
|
|
_previousInstance = projectView.CompositionInstance!;
|
|
}
|
|
|
|
private ImRect _visibleCanvasArea;
|
|
|
|
private bool IsRectVisible(ImRect rect)
|
|
{
|
|
return _visibleCanvasArea.Overlaps(rect);
|
|
}
|
|
|
|
public bool IsItemVisible(ISelectableCanvasObject item)
|
|
{
|
|
return IsRectVisible(ImRect.RectWithSize(item.PosOnCanvas, item.Size));
|
|
}
|
|
|
|
public bool IsFocused { get; private set; }
|
|
public bool IsHovered { get; private set; }
|
|
|
|
/// <summary>
|
|
/// This is an intermediate helper method that should be replaced with a generalized implementation shared by
|
|
/// all graph windows. It's especially unfortunate because it relies on GraphWindow.Focus to exist as open window :(
|
|
///
|
|
/// It uses changes to context.CompositionOp to refresh the view to either the complete content or to the
|
|
/// view saved in user settings...
|
|
/// </summary>
|
|
// private void InitializeCanvasScope(GraphUiContext context)
|
|
// {
|
|
// if (ProjectView.Focused?.GraphCanvas is not ScalableCanvas canvas)
|
|
// return;
|
|
//
|
|
//
|
|
// // Meh: This relies on TargetScope already being set to new composition.
|
|
// var newViewArea = canvas.GetVisibleCanvasArea();
|
|
// if (UserSettings.Config.ViewedCanvasAreaForSymbolChildId.TryGetValue(context.CompositionInstance.SymbolChildId, out var savedCanvasView))
|
|
// {
|
|
// newViewArea = savedCanvasView;
|
|
// }
|
|
//
|
|
// var scope = GetScopeForCanvasArea(newViewArea);
|
|
// context.Canvas.SetScopeWithTransition(scope, ICanvas.Transition.Instant);
|
|
// }
|
|
|
|
private void HandleFenceSelection(GraphUiContext context, SelectionFence selectionFence)
|
|
{
|
|
var shouldBeActive =
|
|
// ChildWindows: custom op-UIs (e.g. SampleCurve) use BeginChild; crossing them must not cancel an active fence drag.
|
|
ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByPopup | ImGuiHoveredFlags.ChildWindows)
|
|
&& (_context.StateMachine.CurrentState == GraphStates.Default
|
|
|| _context.StateMachine.CurrentState == GraphStates.HoldBackground)
|
|
&& _context.StateMachine.StateTime > 0.01f // Prevent glitches when coming from other states.
|
|
;
|
|
|
|
if (!shouldBeActive)
|
|
{
|
|
selectionFence.Reset();
|
|
return;
|
|
}
|
|
|
|
switch (selectionFence.UpdateAndDraw(out var selectMode))
|
|
{
|
|
case SelectionFence.States.PressedButNotMoved:
|
|
if (selectMode == SelectionFence.SelectModes.Replace)
|
|
_context.Selector.Clear();
|
|
break;
|
|
|
|
case SelectionFence.States.Updated:
|
|
HandleSelectionFenceUpdate(selectionFence.BoundsUnclamped, selectMode);
|
|
break;
|
|
|
|
case SelectionFence.States.CompletedAsClick:
|
|
// A hack to prevent clearing selection when opening parameter popup
|
|
if (ImGui.IsPopupOpen("", ImGuiPopupFlags.AnyPopup))
|
|
break;
|
|
|
|
_context.Selector.Clear();
|
|
_context.Selector.SetSelectionToComposition(context.CompositionInstance);
|
|
break;
|
|
case SelectionFence.States.Inactive:
|
|
break;
|
|
case SelectionFence.States.CompletedAsArea:
|
|
break;
|
|
default:
|
|
throw new ArgumentOutOfRangeException();
|
|
}
|
|
}
|
|
|
|
private void HandleSelectionFenceUpdate(ImRect bounds, SelectionFence.SelectModes selectMode)
|
|
{
|
|
var boundsInCanvas = InverseTransformRect(bounds);
|
|
|
|
if (selectMode == SelectionFence.SelectModes.Replace)
|
|
{
|
|
_context.Selector.Clear();
|
|
}
|
|
|
|
// Add items
|
|
foreach (var item in _context.Layout.Items.Values)
|
|
{
|
|
var rect = new ImRect(item.PosOnCanvas, item.PosOnCanvas + item.Size);
|
|
if (!rect.Overlaps(boundsInCanvas))
|
|
continue;
|
|
|
|
if (selectMode == SelectionFence.SelectModes.Remove)
|
|
{
|
|
_context.Selector.DeselectNode(item, item.Instance);
|
|
}
|
|
else
|
|
{
|
|
if (item.IsCollapsedAway)
|
|
continue;
|
|
|
|
if (item.Variant == MagGraphItem.Variants.Operator)
|
|
{
|
|
_context.Selector.AddSelection(item.Selectable, item.Instance);
|
|
}
|
|
else
|
|
{
|
|
_context.Selector.AddSelection(item.Selectable);
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var magSection in _context.Layout.Sections.Values)
|
|
{
|
|
var sectionArea = magSection.Section.Collapsed
|
|
? new ImRect(magSection.PosOnCanvas,
|
|
magSection.PosOnCanvas + new Vector2(magSection.Size.X, MagGraphItem.GridSize.Y))
|
|
: new ImRect(magSection.PosOnCanvas, magSection.PosOnCanvas + magSection.Size);
|
|
|
|
if (!boundsInCanvas.Contains(sectionArea))
|
|
continue;
|
|
|
|
if (selectMode == SelectionFence.SelectModes.Remove)
|
|
{
|
|
_context.Selector.DeselectNode(magSection.Section);
|
|
}
|
|
else
|
|
{
|
|
_context.Selector.AddSelection(magSection.Section);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
public static bool IsPathInside(string filePath, string directoryPath)
|
|
{
|
|
var normalizedFile = Path.GetFullPath(filePath);
|
|
var normalizedDir = Path.GetFullPath(directoryPath);
|
|
|
|
if (!normalizedDir.EndsWith(Path.DirectorySeparatorChar.ToString()))
|
|
{
|
|
normalizedDir += Path.DirectorySeparatorChar;
|
|
}
|
|
return normalizedFile.StartsWith(normalizedDir, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// TODO: Support non graph items like sections.
|
|
|
|
// private void CenterView()
|
|
// {
|
|
// var visibleArea = new ImRect();
|
|
// var isFirst = true;
|
|
//
|
|
// foreach (var item in _context.Layout.Items.Values)
|
|
// {
|
|
// if (isFirst)
|
|
// {
|
|
// visibleArea = item.Area;
|
|
// isFirst = false;
|
|
// continue;
|
|
// }
|
|
//
|
|
// visibleArea.Add(item.PosOnCanvas);
|
|
// }
|
|
//
|
|
// FitAreaOnCanvas(visibleArea);
|
|
// }
|
|
|
|
private float GetHoverTimeForId(Guid id)
|
|
{
|
|
if (id != _lastHoverId)
|
|
return 0;
|
|
|
|
return HoverTime;
|
|
}
|
|
|
|
private readonly SelectionFence _selectionFence = new();
|
|
private Vector2 GridSizeOnScreen => TransformDirection(MagGraphItem.GridSize);
|
|
private float CanvasScale => Scale.X;
|
|
|
|
// ReSharper disable once FieldCanBeMadeReadOnly.Global
|
|
public bool ShowDebug = false; //ImGui.GetIO().KeyAlt;
|
|
|
|
private Guid _lastHoverId;
|
|
private double _hoverStartTime;
|
|
private float HoverTime => (float)(ImGui.GetTime() - _hoverStartTime);
|
|
private GraphUiContext _context;
|
|
private bool _destroyed;
|
|
protected override ScalableCanvas? Parent => null;
|
|
} |