using Editor.NodeEditor; using Facepunch.ActionGraphs; using Sandbox.ActionGraphs; using System; using System.Collections.Generic; using System.Linq; using System.Text.Json.Nodes; using System.Threading; using static Editor.NodeEditor.GraphView; using DisplayInfo = Facepunch.ActionGraphs.DisplayInfo; namespace Editor.ActionGraphs; public interface IActionPlug : IPlug { Type LastType { get; set; } int Index { get; } } public abstract class ActionPlug : IActionPlug where T : Node.IParameter { private readonly T _defaultParam; public EditorNode Node { get; } public T Parameter => Source.TryGetValue( Identifier, out var param ) ? param : _defaultParam; public int Index { get; set; } public virtual bool IsArrayElement => false; public IReadOnlyDictionary Source { get; } public Type LastType { get; set; } INode IPlug.Node => Node; public string Identifier { get; } public virtual Type Type => Parameter.Type; public string Title => Parameter.Node == Node.Node ? Parameter.Display.Title : GetAccessorNodeTitle( Parameter.Node ); private static string GetAccessorNodeTitle( Node node ) { var title = node.DisplayInfo.Title; if ( title.LastIndexOf( '→' ) is var index and not -1 ) title = title[(index + 1)..].TrimStart(); if ( title.StartsWith( "Get " ) || title.StartsWith( "Set " ) ) title = title[4..]; return title; } public Sandbox.DisplayInfo DisplayInfo => new() { Name = !IsArrayElement && Index == 0 ? Title : $"{Title}[{Index}]", Description = Parameter.Node.Parent != null ? Parameter.Node.DisplayInfo.Description : Parameter.Display.Description, Group = GetGroupName() }; private string GetGroupName() { var node = Parameter.Node; string groupName = null; while ( node.Parent is { } parent && node != Node.Node ) { var parentOutputTitle = parent == Node.Node ? node.Inputs.Values.First( x => x.Link is not null ) .Link!.Source.Display.Title : GetAccessorNodeTitle( parent ); if ( groupName is null ) { groupName = parentOutputTitle; } else { groupName = $"{parentOutputTitle} > {groupName}"; } node = parent; } return groupName; } protected ActionPlug( EditorNode node, IReadOnlyDictionary source, string identifier, int index ) { Node = node; Source = source; Identifier = identifier; Index = index; LastType = Type; _defaultParam = Parameter; } public virtual ValueEditor CreateEditor( NodeUI node, Plug plug ) { return null; } public virtual Menu CreateContextMenu( NodeUI node, Plug plug ) { return null; } protected Menu CreateDisplayInfoMenu( Menu menu, Action changed ) { var display = Parameter.Display; var edit = menu.AddMenu( "Edit", "edit" ); edit.AddLineEdit( "Title", value: display.Title, onChange: value => changed( display with { Title = value } ) ); edit.AddLineEdit( "Description", value: display.Description, onChange: value => changed( display with { Description = value } ) ); return menu; } public virtual void OnDoubleClick( NodeUI node, Plug plug, MouseEvent e ) { } public abstract bool ShowConnection { get; } public bool ShowLabel => Node.HasTitleBar && !InTitleBar; public bool AllowStretch => false; public bool InTitleBar => Node.HasTitleBar && (Parameter.Name == "_signal" || Node.Inputs.Count <= 1 && Node.Outputs.Count <= 1); public virtual bool IsReachable => Node.IsReachable; public string ErrorMessage => string.Join( Environment.NewLine, Parameter.GetMessages() .Where( x => x.IsError ) .Select( x => x.Value ) ); public override string ToString() { return $"{Node.Identifier}.{Identifier}"; } } public static class NodeOutputExtensions { public static string GetLabel( this Node.Output output ) { if ( output.Node.Definition == EditorNodeLibrary.Input && output.Node.ActionGraph.Inputs.TryGetValue( output.Name, out var inputDef ) ) { // Hack so that references to special hidden inputs will stay connected on copy / paste. if ( inputDef.IsTarget ) { return "__this"; } if ( inputDef.Type == typeof( CancellationToken ) ) { return "__ct"; } } return output.Node.UserData[ActionOutputPlug.LabelsKey]?[output.Name]?.GetValue(); } public static string GetNiceLabel( this Node.Output output ) { if ( output.Node.Definition == EditorNodeLibrary.Input && output.Node.ActionGraph.Inputs.TryGetValue( output.Name, out var inputDef ) ) { if ( inputDef.IsTarget ) { return "This"; } if ( inputDef.Type == typeof( CancellationToken ) ) { return "Cancellation Token"; } } return output.GetLabel(); } } public class ActionOutputPlug : ActionPlug, IPlugOut { public const string LabelsKey = "Labels"; public string Label { get => Parameter.GetLabel(); set { var labels = Parameter.Node.UserData[LabelsKey]?.AsObject(); if ( value is null ) { labels?.Remove( Identifier ); if ( labels is { Count: 0 } ) { Parameter.Node.UserData.Remove( LabelsKey ); } return; } if ( labels is null ) { Parameter.Node.UserData[LabelsKey] = labels = new JsonObject(); } labels[Identifier] = value; } } public override bool ShowConnection => Label is null; public override bool IsReachable => base.IsReachable && (Parameter.Node == Node.Node || Parameter.IsLinked); public ActionOutputPlug( EditorNode node, IReadOnlyDictionary source, string identifier, int index ) : base( node, source, identifier, index ) { } public override ValueEditor CreateEditor( NodeUI node, Plug plug ) { if ( Parameter.Node.Definition == EditorNodeLibrary.NoOperation ) { return null; } return new DefaultOutputEditor( node, (PlugOut)plug ); } public override Menu CreateContextMenu( NodeUI node, Plug plug ) { if ( Parameter.Node.Definition == EditorNodeLibrary.NoOperation ) { return null; } var editorActionGraph = (EditorActionGraph)node.Graph.Graph; var actionGraph = editorActionGraph!.Graph; var type = Type; var menu = new Menu( plug.Title ); menu.AddLineEdit( "Label", Label, autoFocus: true, onSubmit: value => { node.Graph?.PushUndo( "Set Label" ); Label = string.IsNullOrWhiteSpace( value ) ? null : value; (node.Node as EditorNode)?.MarkDirty(); node.Graph?.PushRedo(); } ); var agNode = Parameter.Node; if ( agNode.Definition == EditorNodeLibrary.Input ) { CreateDisplayInfoMenu( menu, value => { agNode.ActionGraph.SetParameters( agNode.ActionGraph.Inputs.Values .Select( x => x.Name == Parameter.Name ? x with { Display = value } : x ).ToArray(), agNode.ActionGraph.Outputs.Values.ToArray() ); agNode.MarkDirty(); node.MarkNodeChanged(); } ); } if ( Parameter.Node.Parent is not null && !Parameter.IsUsed() ) { menu.AddOption( "Hide", "visibility_off", () => { node.Graph?.PushUndo( "Hide Expanded output" ); if ( !Parameter.IsAnyExpandedOutputVisible() ) { Parameter.Node.Remove(); } else { Parameter.Node.SetVisible( false ); } (node.Node as EditorNode)?.MarkDirty(); node.Graph?.PushRedo(); } ); } if ( Parameter.IsLinked && Parameter.IsUsed() ) { menu.AddOption( "Clear", "clear", () => { node.Graph?.PushUndo( "Clear Links" ); foreach ( var link in Parameter.Links.ToArray() ) { if ( link.Target.Node.Parent != null ) { continue; } link.Remove(); } (node.Node as EditorNode)?.MarkDirty(); node.Graph?.PushRedo(); } ); } menu.AddSeparator(); if ( !Parameter.IsSignal && editorActionGraph.CanModifyParameters ) { var createOutputMenu = menu.AddMenu( "Create Graph Output", "add_box" ); createOutputMenu.AboutToShow += () => { createOutputMenu.Clear(); createOutputMenu.AddLineEdit( "Name", autoFocus: true, onSubmit: value => { if ( string.IsNullOrEmpty( value ) ) return; node.Graph.PushUndo( "Create Graph Output" ); var name = actionGraph.GetNextOutputName(); var output = new OutputDefinition( name, Parameter.Type, 0, new DisplayInfo( value ) ); var hadOutputNde = actionGraph.PrimaryOutputNode is not null; actionGraph.SetParameters( actionGraph.Inputs.Values.ToArray(), actionGraph.Outputs.Values.With( output ) ); actionGraph.AddRequiredNodes(); actionGraph.PrimaryOutputNode!.UpdateParameters(); actionGraph.PrimaryOutputNode.Inputs[name].SetLink( Parameter ); if ( !hadOutputNde ) { var editorOutputNode = new EditorNode( editorActionGraph, actionGraph.PrimaryOutputNode ); editorActionGraph.AddNode( editorOutputNode ); node.Graph.BuildFromNodes( new[] { editorOutputNode }, true ); } editorActionGraph.MarkDirty( Parameter.Node ); editorActionGraph.MarkDirty( actionGraph.PrimaryOutputNode ); node.Graph.PushRedo(); } ); }; } if ( editorActionGraph.CanModifyParameters && Parameter.Node.Definition == EditorNodeLibrary.Input && Parameter.Name.StartsWith( "_in" ) ) { menu.AddOption( "Remove Graph Input", "delete", () => { foreach ( var link in Parameter.Links.ToArray() ) { if ( link.IsArrayElement ) { link.Target.SetLink( new Constant( null ), link.ArrayIndex ); } else { link.Target.ClearLinks(); } } var input = actionGraph.Inputs.Values.First( x => x.Name == Parameter.Name ); actionGraph.SetParameters( actionGraph.Inputs.Values.Without( input ), actionGraph.Outputs.Values.ToArray() ); actionGraph.InputNode!.UpdateParameters(); editorActionGraph.MarkDirty( actionGraph.InputNode ); } ); } EditorEvent.Run( PopulateOutputPlugMenuEvent.EventName, new PopulateOutputPlugMenuEvent( (ActionGraphView)node.Graph, this, menu ) ); if ( !Parameter.IsSignal ) { menu.AddSeparator(); PopulateExpandedOutputMenu( this, node, menu ); } return menu; } public override void OnDoubleClick( NodeUI node, Plug plug, MouseEvent e ) { e.Accepted = true; var visibleChildren = Parameter .GetExpandedOutputs( true ) .Where( x => x != Parameter ) .Where( x => !x.IsUsed() ) .ToArray(); if ( visibleChildren.Length > 0 ) { foreach ( var visibleChild in visibleChildren ) { if ( !visibleChild.IsAnyExpandedOutputVisible() ) { visibleChild.Node.Remove(); } else { visibleChild.Node.SetVisible( false ); } } } else { var nodeTypes = ((ActionGraphView)node.Graph).GetExpansionOptions( this ) .ToArray(); var autoExpanded = nodeTypes .Where( x => x.Inner.AutoExpand == true ) .ToArray(); if ( autoExpanded.Length > 0 ) { nodeTypes = autoExpanded; } foreach ( var nodeType in nodeTypes ) { Expand( nodeType.Inner, node ); } } } private static Node FindExpandedOutput( Node.Output output, LibraryNodeType nodeType ) { return output.Node.Children .Where( x => x.Definition == nodeType.Definition ) .Where( x => x.Inputs.Values.FirstOrDefault( y => y.IsTarget )?.Link?.Source is { } source && source == output ) .Where( x => nodeType.Properties.All( y => x.Properties.TryGetValue( y.Key, out var property ) && property.Value == y.Value ) ) .FirstOrDefault( x => nodeType.Inputs.All( y => x.Inputs.TryGetValue( y.Key, out var input ) && input.Value == y.Value ) ); } private void Expand( LibraryNodeType type, NodeUI nodeUi ) { var child = FindExpandedOutput( Parameter, type ); nodeUi.Graph?.PushUndo( "Show Expanded output" ); if ( child != null ) { child.SetVisible( true ); } else { child = type.CreateNode( (EditorActionGraph)nodeUi.Graph!.Graph, Parameter.Node ).Node; child.Inputs.Values.FirstOrDefault( x => x.IsTarget )?.SetLink( Parameter ); } Node.MarkDirty(); nodeUi.Graph?.PushRedo(); } private static void PopulateExpandedOutputMenu( ActionOutputPlug plug, NodeUI nodeUi, Menu menu ) { var nodeTypes = ((ActionGraphView)nodeUi.Graph) .GetExpansionOptions( plug ) .ToArray(); PopulateNodeMenu( menu, nodeTypes, null, type => { plug.Expand( ((SelectedOutputNodeType)type).Inner, nodeUi ); } ); } } public class ActionInputPlug : ActionPlug, IPlugIn { public ActionInputPlug( EditorNode node, IReadOnlyDictionary source, string identifier, int index ) : base( node, source, identifier, index ) { } public Link InputLink => IsArrayElement ? Index >= 0 && Index < Parameter.LinkArray!.Count ? Parameter.LinkArray[Index] : null : Parameter.Link; public Node InputNestedNode { get { var link = InputLink; if ( link is not { Source.Node: { Parent: { } parent } node } ) { return null; } if ( parent != Node.Node ) { return null; } return node; } } public override Type Type => InputLink?.Source.Type ?? (Parameter.LinkArray is not null ? Parameter.ElementType : Parameter.Type); public IPlugOut ConnectedOutput { get { if ( IsArrayElement ) { if ( Parameter.LinkArray is not { } links ) { return null; } if ( Index < 0 || Index >= links.Count ) { return null; } return links[Index] is { Source: { } output } ? Node.Graph.FindNode( output.Node )?.Outputs[output] : null; } else { if ( Index is 0 && Parameter.Link is { Source: { } output } ) { return Node.Graph.FindNode( output.Node )?.Outputs[output]; } } return null; } set { if ( value is null ) { if ( !Parameter.IsLinked ) { return; } if ( Index == 0 && Parameter.LinkArray?.Count == 1 || !Parameter.IsArray ) { Parameter.ClearLinks(); } else { var defaultValue = Parameter.ElementType.IsValueType ? Activator.CreateInstance( Parameter.ElementType ) : null; Parameter.SetLink( new Constant( defaultValue ), Index ); } Node.MarkDirty(); return; } if ( value is not ActionPlug { Parameter: { } output } ) { return; } if ( !Parameter.IsArray || value.Type.IsArray && Index is 0 && Parameter.LinkArray is null ) { Parameter.SetLink( output ); Node.MarkDirty(); return; } if ( Index < (Parameter.LinkArray?.Count ?? 0) ) { Parameter.SetLink( output, Index ); } else { Parameter.InsertLink( output, Index ); Node.MarkDirty(); } } } public override bool ShowConnection => true; public override bool IsArrayElement => Parameter is { LinkArray: not null }; public override ValueEditor CreateEditor( NodeUI node, Plug plug ) { if ( Node.Definition == EditorNodeLibrary.NoOperation ) { return null; } return new DefaultInputEditor( node, (PlugIn)plug ); } public override Menu CreateContextMenu( NodeUI node, Plug plug ) { if ( Node.Definition == EditorNodeLibrary.NoOperation ) { return null; } var editorActionGraph = (EditorActionGraph)node.Graph.Graph; var actionGraph = editorActionGraph!.Graph; var type = Parameter.LinkArray is not null ? Parameter.ElementType : Parameter.Type; var matchingVariables = Parameter.Node.ActionGraph.Variables.Values .Where( x => !Parameter.IsSignal && x.Type.IsAssignableToExtended( type ) ) .ToArray(); var matchingLabeledOutputs = Parameter.Node.ActionGraph.Nodes.Values .SelectMany( x => x.Outputs.Values ) .Where( x => x.GetLabel() is { } ) .Where( x => x.IsSignal == Parameter.IsSignal && (Parameter.IsSignal || x.Type.IsAssignableToExtended( type )) ) .ToArray(); var matchingComponents = editorActionGraph.AvailableComponentTypes .Where( x => !Parameter.IsSignal && x.IsAssignableToExtended( type ) ) .ToArray(); var menu = new Menu( plug.Title ); var agNode = Parameter.Node; if ( agNode.Definition == EditorNodeLibrary.Output ) { CreateDisplayInfoMenu( menu, value => { agNode.ActionGraph.SetParameters( agNode.ActionGraph.Inputs.Values.ToArray(), agNode.ActionGraph.Outputs.Values .Select( x => x.Name == Parameter.Name ? x with { Display = value } : x ).ToArray() ); agNode.MarkDirty(); } ); } if ( Parameter.IsLinked && (!IsArrayElement || Index < Parameter.LinkArray!.Count) ) { menu.AddOption( "Clear", "clear", () => { node.Graph?.PushUndo( "Clear Link" ); if ( IsArrayElement && Index <= Parameter.LinkArray!.Count ) { var defaultValue = Parameter.ElementType.IsValueType ? Activator.CreateInstance( Parameter.ElementType ) : null; Parameter.SetLink( new Constant( defaultValue ), Index ); } else { Parameter.ClearLinks(); } (node.Node as EditorNode)?.MarkDirty(); node.Graph?.PushRedo(); } ); } if ( IsArrayElement && Index < Parameter.LinkArray!.Count ) { menu.AddOption( "Remove", "remove", () => { node.Graph?.PushUndo( "Remove Link" ); Parameter.LinkArray[Index].Remove(); (node.Node as EditorNode)?.MarkDirty(); node.Graph?.PushRedo(); } ); } if ( IsArrayElement ) { menu.AddOption( "Remove All", "playlist_remove", () => { node.Graph?.PushUndo( "Remove All Links" ); Parameter.ClearLinks(); (node.Node as EditorNode)?.MarkDirty(); node.Graph?.PushRedo(); } ); } menu.AddSeparator(); if ( !Parameter.IsSignal ) { var createVariableMenu = menu.AddMenu( "Create Variable", "add_box" ); createVariableMenu.AboutToShow += () => { createVariableMenu.Clear(); createVariableMenu.AddLineEdit( "Name", autoFocus: true, onSubmit: value => { if ( string.IsNullOrEmpty( value ) ) return; node.Graph?.PushUndo( "Create Variable" ); var variable = actionGraph.AddVariable( value, type ); Parameter.SetLink( variable ); node.Graph?.PushRedo(); } ); }; if ( editorActionGraph.CanModifyParameters ) { var createInputMenu = menu.AddMenu( "Create Graph Input", "add_box" ); createInputMenu.AboutToShow += () => { createInputMenu.Clear(); createInputMenu.AddLineEdit( "Name", autoFocus: true, onSubmit: value => { if ( string.IsNullOrEmpty( value ) ) return; node.Graph.PushUndo( "Create Graph Input" ); var name = actionGraph.GetNextInputName(); var input = new InputDefinition( name, Parameter.Type, InputFlags.Required, new DisplayInfo( value ) ); actionGraph.SetParameters( actionGraph.Inputs.Values.With( input ), actionGraph.Outputs.Values.ToArray() ); actionGraph.InputNode!.UpdateParameters(); Parameter.SetLink( actionGraph.InputNode.Outputs[name] ); editorActionGraph.MarkDirty( actionGraph.InputNode ); node.Graph.PushRedo(); } ); }; } } if ( editorActionGraph.CanModifyParameters && Parameter.Node.Definition == EditorNodeLibrary.Output && Parameter.Name.StartsWith( "_out" ) ) { menu.AddOption( "Remove Graph Output", "delete", () => { Parameter.ClearLinks(); var output = actionGraph.Outputs.Values.First( x => x.Name == Parameter.Name ); actionGraph.SetParameters( actionGraph.Inputs.Values.ToArray(), actionGraph.Outputs.Values.Without( output ) ); actionGraph.PrimaryOutputNode!.UpdateParameters(); editorActionGraph.MarkDirty( actionGraph.PrimaryOutputNode ); } ); } if ( matchingVariables.Length > 0 ) { var varMenu = menu.AddMenu( "Use Variable", "inbox" ); foreach ( var variable in matchingVariables ) { varMenu.AddOption( variable.Name, "inbox", () => { node.Graph?.PushUndo( "Create Variable Link" ); SetLink( node, variable ); node.Graph?.PushRedo(); } ); } } if ( matchingLabeledOutputs.Length > 0 ) { var labelMenu = menu.AddMenu( "Use Labeled Output", "link" ); foreach ( var output in matchingLabeledOutputs ) { labelMenu.AddOption( output.GetNiceLabel(), "link", () => { node.Graph?.PushUndo( "Create Labeled Output Link" ); SetLink( node, output ); node.Graph?.PushRedo(); } ); } } if ( matchingComponents.Length > 0 ) { var componentMenu = menu.AddMenu( "Use Component", "category" ); foreach ( var componentType in matchingComponents ) { var typeDesc = TypeLibrary.GetType( componentType ); componentMenu.AddOption( typeDesc.Title, typeDesc.Icon ?? "category", () => { node.Graph?.PushUndo( "Create Component Link" ); var nodeType = new LocalTargetNodeType( new ComponentNodeType( typeDesc ) ); var getNode = nodeType.CreateNode( actionGraph, agNode ); SetLink( node, getNode.Outputs.Result ); node.Graph?.PushRedo(); } ); } } EditorEvent.Run( PopulateInputPlugMenuEvent.EventName, new PopulateInputPlugMenuEvent( (ActionGraphView)node.Graph, this, menu ) ); return menu; } private void SetLink( NodeUI node, ILinkSource source ) { if ( IsArrayElement ) { if ( Index < Parameter.LinkArray!.Count ) { Parameter.SetLink( source, Index ); } else { Parameter.InsertLink( source, Index ); } } else { Parameter.SetLink( source ); } (node.Node as EditorNode)?.MarkDirty(); } public void GoToSource( NodeUI node, Plug plug ) { if ( ConnectedOutput is ActionOutputPlug connectedOutput ) { node.Graph.SelectNode( connectedOutput.Node ); node.Graph.CenterOnSelection(); return; } var eventArgs = new GoToPlugSourceEvent( (ActionGraphView)node.Graph, this ); EditorEvent.Run( GoToPlugSourceEvent.EventName, eventArgs ); if ( eventArgs.Handled ) { return; } node.Graph.SelectNode( Node ); if ( node.Graph is ActionGraphView actionGraphView ) { actionGraphView.FocusOnInput( Parameter, IsArrayElement ? Index : null ); } } public override void OnDoubleClick( NodeUI node, Plug plug, MouseEvent e ) { e.Accepted = true; GoToSource( node, plug ); } public float? GetHandleOffset( string name ) { return InputLink?.UserData[name]?.GetValue(); } public void SetHandleOffset( string name, float? value ) { if ( InputLink is { } link ) { if ( value is null ) link.UserData.Remove( name ); else link.UserData[name] = value; } } }