// Copyright (c) 2012-2023 Wojciech Figat. All rights reserved. using System; using System.Collections.Generic; using System.Linq; using FlaxEditor.GUI.ContextMenu; using FlaxEditor.GUI.Input; using FlaxEngine; using FlaxEngine.GUI; using FlaxEngine.Utilities; namespace FlaxEditor.Surface.ContextMenu { /// /// The Visject Surface dedicated context menu for nodes spawning. /// /// [HideInEditor] public class VisjectCM : ContextMenuBase { /// /// Visject context menu item clicked delegate. /// /// The item that was clicked /// The currently user-selected box. Can be null. public delegate void ItemClickedDelegate(VisjectCMItem clickedItem, Elements.Box selectedBox); /// /// Visject Surface node archetype spawn ability checking delegate. /// /// The node archetype to check. /// True if can use this node to spawn it on a surface, otherwise false.. public delegate bool NodeSpawnCheckDelegate(NodeArchetype arch); /// /// Visject Surface parameters getter delegate. /// /// TThe list of surface parameters or null if failed (readonly). public delegate List ParameterGetterDelegate(); private readonly List _groups = new List(16); private CheckBox _contextSensitiveToggle; private bool _contextSensitiveSearchEnabled = true; private readonly TextBox _searchBox; private bool _waitingForInput; private VisjectCMGroup _surfaceParametersGroup; private Panel _panel1; private VerticalPanel _groupsPanel; private readonly ParameterGetterDelegate _parametersGetter; private Elements.Box _selectedBox; private NodeArchetype _parameterGetNodeArchetype; private NodeArchetype _parameterSetNodeArchetype; /// /// The selected item /// public VisjectCMItem SelectedItem; /// /// Event fired when any item in this popup menu gets clicked. /// public event ItemClickedDelegate ItemClicked; /// /// Gets or sets a value indicating whether show groups expanded or collapsed. /// public bool ShowExpanded { get; set; } /// /// Gets the groups (read-only). /// public IList Groups => _groups; /// /// The surface context menu initialization parameters. /// public struct InitInfo { /// /// True if surface parameters are not read-only and can be modified via setter node. /// public bool CanSetParameters; /// /// The groups archetypes. Cannot be null. /// public List Groups; /// /// The custom callback to handle node types validation for spawning. Cannot be null. /// public NodeSpawnCheckDelegate CanSpawnNode; /// /// The surface parameters getter. Can be null. /// public ParameterGetterDelegate ParametersGetter; /// /// The group with custom nodes group. Can be null. /// public GroupArchetype CustomNodesGroup; /// /// The parameter getter node archetype to spawn when adding the parameter getter. Can be null. /// public NodeArchetype ParameterGetNodeArchetype; /// /// The parameter setter node archetype to spawn when adding the parameter getter. Can be null. /// public NodeArchetype ParameterSetNodeArchetype; } /// /// Initializes a new instance of the class. /// /// The initialization info data. public VisjectCM(InitInfo info) { if (info.Groups == null) throw new ArgumentNullException(nameof(info.Groups)); if (info.CanSpawnNode == null) throw new ArgumentNullException(nameof(info.CanSpawnNode)); _parametersGetter = info.ParametersGetter; _parameterGetNodeArchetype = info.ParameterGetNodeArchetype ?? Archetypes.Parameters.Nodes[0]; if (info.CanSetParameters) _parameterSetNodeArchetype = info.ParameterSetNodeArchetype ?? Archetypes.Parameters.Nodes[3]; // Context menu dimensions Size = new Float2(300, 400); var headerPanel = new Panel(ScrollBars.None) { Parent = this, Height = 20, Width = Width - 4, X = 2, Y = 1, }; // Title bar var titleFontReference = new FontReference(Style.Current.FontLarge.Asset, 10); var titleLabel = new Label { Width = Width * 0.5f - 8f, Height = 20, X = 4, Parent = headerPanel, Text = "Select Node", HorizontalAlignment = TextAlignment.Near, Font = titleFontReference, }; // Context sensitive toggle var contextSensitiveLabel = new Label { Width = Width * 0.5f - 28, Height = 20, X = Width * 0.5f, Parent = headerPanel, Text = "Context Sensitive", TooltipText = "Should the nodes be filtered to only show those that can be connected in the current context?", HorizontalAlignment = TextAlignment.Far, Font = titleFontReference, }; _contextSensitiveToggle = new CheckBox { Width = 20, Height = 20, X = Width - 24, Parent = headerPanel, Checked = _contextSensitiveSearchEnabled, }; _contextSensitiveToggle.StateChanged += OnContextSensitiveToggleStateChanged; // Search box _searchBox = new SearchBox(false, 2, 22) { Width = Width - 4, Parent = this }; _searchBox.TextChanged += OnSearchFilterChanged; // Create first panel (for scrollbar) var panel1 = new Panel(ScrollBars.Vertical) { Bounds = new Rectangle(0, _searchBox.Bottom + 1, Width, Height - _searchBox.Bottom - 2), Parent = this }; _panel1 = panel1; // Create second panel (for groups arrangement) var panel2 = new VerticalPanel { Parent = panel1, AnchorPreset = AnchorPresets.HorizontalStretchTop, IsScrollable = true, }; _groupsPanel = panel2; // Init groups var nodes = new List(); foreach (var groupArchetype in info.Groups) { // Get valid nodes nodes.Clear(); foreach (var nodeArchetype in groupArchetype.Archetypes) { if ((nodeArchetype.Flags & NodeFlags.NoSpawnViaGUI) == 0 && info.CanSpawnNode(nodeArchetype)) { nodes.Add(nodeArchetype); } } // Check if can create group for them if (nodes.Count > 0) { var group = CreateGroup(groupArchetype); group.Close(false); for (int i = 0; i < nodes.Count; i++) { var item = new VisjectCMItem(group, groupArchetype, nodes[i]) { Parent = group }; } group.SortChildren(); group.Parent = panel2; _groups.Add(group); } } // Add custom nodes (special handling) if (info.CustomNodesGroup?.Archetypes != null) { foreach (var nodeArchetype in info.CustomNodesGroup.Archetypes) { if ((nodeArchetype.Flags & NodeFlags.NoSpawnViaGUI) != 0) continue; var groupName = Archetypes.Custom.GetNodeGroup(nodeArchetype); // Find group to reuse VisjectCMGroup group = null; for (int j = 0; j < _groups.Count; j++) { if (string.Equals(_groups[j].Name, groupName, StringComparison.OrdinalIgnoreCase)) { group = _groups[j]; break; } } // Create new group if name is unique if (group == null) { group = CreateGroup(info.CustomNodesGroup, true, groupName); group.Close(false); group.Parent = _groupsPanel; _groups.Add(group); } // Add new item var item = new VisjectCMItem(group, info.CustomNodesGroup, nodeArchetype) { Parent = group }; // Order items group.SortChildren(); } } } /// /// Adds the group archetype to add to the menu. /// /// The group. /// True if merge group items into any existing group of the same name. public void AddGroup(GroupArchetype groupArchetype, bool withGroupMerge = true) { // Check if can create group for them to be spawned via GUI if (groupArchetype.Archetypes.Any(x => (x.Flags & NodeFlags.NoSpawnViaGUI) == 0)) { Profiler.BeginEvent("VisjectCM.AddGroup"); var group = CreateGroup(groupArchetype, withGroupMerge); group.Close(false); foreach (var nodeArchetype in groupArchetype.Archetypes) { var item = new VisjectCMItem(group, groupArchetype, nodeArchetype) { Parent = group }; } group.SortChildren(); group.Parent = _groupsPanel; _groups.Add(group); if (!IsLayoutLocked) { group.UnlockChildrenRecursive(); SortGroups(); group.PerformLayout(); if (_searchBox.TextLength != 0) { OnSearchFilterChanged(); } } else if (_contextSensitiveSearchEnabled) { group.EvaluateVisibilityWithBox(_selectedBox); } Profiler.EndEvent(); } } /// /// Adds the group archetypes to add to the menu. /// /// The groups. /// True if merge group items into any existing group of the same name. public void AddGroups(IEnumerable groupArchetypes, bool withGroupMerge = true) { // Check if can create group for them to be spawned via GUI if (groupArchetypes.Any(g => g.Archetypes.Any(x => (x.Flags & NodeFlags.NoSpawnViaGUI) == 0))) { Profiler.BeginEvent("VisjectCM.AddGroups"); var isLayoutLocked = IsLayoutLocked; LockChildrenRecursive(); Profiler.BeginEvent("Create Groups"); var groups = new List(); foreach (var groupArchetype in groupArchetypes) { var group = CreateGroup(groupArchetype, withGroupMerge); group.Close(false); foreach (var nodeArchetype in groupArchetype.Archetypes) { var item = new VisjectCMItem(group, groupArchetype, nodeArchetype) { Parent = group }; } if (_contextSensitiveSearchEnabled) group.EvaluateVisibilityWithBox(_selectedBox); group.SortChildren(); group.Parent = _groupsPanel; _groups.Add(group); groups.Add(group); } Profiler.EndEvent(); if (!isLayoutLocked) { SortGroups(); Profiler.BeginEvent("Perform Layout"); UnlockChildrenRecursive(); foreach (var group in groups) group.PerformLayout(); PerformLayout(); Profiler.EndEvent(); if (_searchBox.TextLength != 0) { OnSearchFilterChanged(); } } Profiler.EndEvent(); } } /// /// Removes the group archetype to from to the menu. /// /// The group. public void RemoveGroup(GroupArchetype groupArchetype) { for (int i = 0; i < _groups.Count; i++) { var group = _groups[i]; if (group.Archetypes.Remove(groupArchetype)) { Profiler.BeginEvent("VisjectCM.RemoveGroup"); if (group.Archetypes.Count == 0) { _groups.RemoveAt(i); group.Dispose(); } else { var children = group.Children.ToArray(); foreach (var child in children) { if (child is VisjectCMItem item && item.GroupArchetype == groupArchetype) item.Dispose(); } } Profiler.EndEvent(); break; } } } /// /// Removes the group to from to the menu. /// /// The group. public void RemoveGroup(VisjectCMGroup group) { Profiler.BeginEvent("VisjectCM.RemoveGroup"); group.Dispose(); _groups.Remove(group); Profiler.EndEvent(); } private VisjectCMGroup CreateGroup(GroupArchetype groupArchetype, bool withGroupMerge = true, string name = null) { if (name == null) name = groupArchetype.Name; if (withGroupMerge) { for (int i = 0; i < _groups.Count; i++) { if (string.Equals(_groups[i].HeaderText, name, StringComparison.Ordinal)) return _groups[i]; } } return new VisjectCMGroup(this, groupArchetype) { HeaderText = name }; } private void OnSearchFilterChanged() { // Skip events during setup or init stuff if (IsLayoutLocked) return; Profiler.BeginEvent("VisjectCM.OnSearchFilterChanged"); UpdateFilters(); _searchBox.Focus(); Profiler.EndEvent(); } private void OnContextSensitiveToggleStateChanged(CheckBox checkBox) { // Skip events during setup or init stuff if (IsLayoutLocked) return; Profiler.BeginEvent("VisjectCM.OnContextSensitiveToggleStateChanged"); _contextSensitiveSearchEnabled = checkBox.Checked; UpdateFilters(); Profiler.EndEvent(); } private void UpdateFilters() { if (string.IsNullOrEmpty(_searchBox.Text) && _selectedBox == null) { ResetView(); Profiler.EndEvent(); return; } // Update groups LockChildrenRecursive(); for (int i = 0; i < _groups.Count; i++) { _groups[i].UpdateFilter(_searchBox.Text, _contextSensitiveSearchEnabled ? _selectedBox : null); _groups[i].UpdateItemSort(_selectedBox); } SortGroups(); UnlockChildrenRecursive(); // If no item is selected (or it's not visible anymore), select the top one Profiler.BeginEvent("VisjectCM.Layout"); if (SelectedItem == null || !SelectedItem.VisibleInHierarchy) SelectedItem = _groups.Find(g => g.Visible)?.Children.Find(c => c.Visible && c is VisjectCMItem) as VisjectCMItem; PerformLayout(); if (SelectedItem != null) _panel1.ScrollViewTo(SelectedItem); Profiler.EndEvent(); } /// /// Sort the groups and keeps in sync /// private void SortGroups() { Profiler.BeginEvent("VisjectCM.SortGroups"); // Sort groups _groupsPanel.SortChildren(); // Synchronize with _groups[] for (int i = 0, groupsIndex = 0; i < _groupsPanel.ChildrenCount; i++) { if (_groupsPanel.Children[i] is VisjectCMGroup group) { _groups[groupsIndex] = group; groupsIndex++; } } Profiler.EndEvent(); } /// /// Called when user clicks on an item. /// /// The item. public void OnClickItem(VisjectCMItem item) { Hide(); ItemClicked?.Invoke(item, _selectedBox); } /// /// Expands all the groups. /// /// Enable/disable animation feature. public void ExpandAll(bool animate = false) { for (int i = 0; i < _groups.Count; i++) _groups[i].Open(animate); } /// /// Resets the view. /// public void ResetView() { Profiler.BeginEvent("VisjectCM.ResetView"); LockChildrenRecursive(); _searchBox.Clear(); SelectedItem = null; for (int i = 0; i < _groups.Count; i++) { _groups[i].ResetView(); if (_contextSensitiveSearchEnabled) _groups[i].EvaluateVisibilityWithBox(_selectedBox); } UnlockChildrenRecursive(); SortGroups(); PerformLayout(); Profiler.EndEvent(); } /// /// Updates the surface parameters group. /// private void UpdateSurfaceParametersGroup() { Profiler.BeginEvent("VisjectCM.UpdateSurfaceParametersGroup"); // Remove the old one if (_surfaceParametersGroup != null) { _groups.Remove(_surfaceParametersGroup); _surfaceParametersGroup.Dispose(); _surfaceParametersGroup = null; } // Check if surface has any parameters var parameters = _parametersGetter?.Invoke(); int count = parameters?.Count(x => x.IsPublic) ?? 0; if (count > 0) { // TODO: cache the allocated memory to reduce dynamic allocations if (_parameterSetNodeArchetype != null) count *= 2; var archetypes = new NodeArchetype[count]; int archetypeIndex = 0; // ReSharper disable once PossibleNullReferenceException for (int i = 0; i < parameters.Count; i++) { var param = parameters[i]; if (!param.IsPublic) continue; var node = (NodeArchetype)_parameterGetNodeArchetype.Clone(); node.Title = "Get " + param.Name; node.DefaultValues[0] = param.ID; archetypes[archetypeIndex++] = node; if (_parameterSetNodeArchetype != null) { node = (NodeArchetype)_parameterSetNodeArchetype.Clone(); node.Title = "Set " + param.Name; node.DefaultValues[0] = param.ID; node.DefaultValues[1] = TypeUtils.GetDefaultValue(param.Type); archetypes[archetypeIndex++] = node; } } var groupArchetype = new GroupArchetype { GroupID = 6, Name = "Surface Parameters", Color = new Color(52, 73, 94), Archetypes = archetypes }; var group = CreateGroup(groupArchetype); group.ArrowImageOpened = new SpriteBrush(Style.Current.ArrowDown); group.ArrowImageClosed = new SpriteBrush(Style.Current.ArrowRight); group.Close(false); archetypeIndex = 0; for (int i = 0; i < parameters.Count; i++) { var param = parameters[i]; if (!param.IsPublic) continue; var item = new VisjectCMItem(group, groupArchetype, archetypes[archetypeIndex++]) { Parent = group }; if (_parameterSetNodeArchetype != null) { item = new VisjectCMItem(group, groupArchetype, archetypes[archetypeIndex++]) { Parent = group }; } } group.SortChildren(); group.UnlockChildrenRecursive(); group.Parent = _groupsPanel; _groups.Add(group); _surfaceParametersGroup = group; } Profiler.EndEvent(); } /// public override void Show(Control parent, Float2 location) { Show(parent, location, null); } /// /// Show context menu over given control. /// /// Parent control to attach to it. /// Popup menu origin location in parent control coordinates. /// The currently selected box that the new node will get connected to. Can be null public void Show(Control parent, Float2 location, Elements.Box startBox) { _selectedBox = startBox; base.Show(parent, location); } /// protected override void OnShow() { // Prepare UpdateSurfaceParametersGroup(); ResetView(); _panel1.VScrollBar.TargetValue = 0; Focus(); _waitingForInput = true; base.OnShow(); } /// public override void Hide() { Focus(null); base.Hide(); } /// public override bool OnKeyDown(KeyboardKeys key) { if (key == KeyboardKeys.Escape) { Hide(); return true; } else if (key == KeyboardKeys.Return) { if (SelectedItem != null) OnClickItem(SelectedItem); else Hide(); return true; } else if (key == KeyboardKeys.ArrowUp) { if (SelectedItem == null) return true; var previousSelectedItem = GetPreviousSiblings(SelectedItem).FirstOrDefault(c => c.Visible) ?? (GetPreviousSiblings(SelectedItem.Group).FirstOrDefault(c => c.Visible)?.Children .FindLast(c => c.Visible && c is VisjectCMItem) as VisjectCMItem); if (previousSelectedItem != null) { SelectedItem = previousSelectedItem; // Scroll into view (without smoothing) _panel1.ScrollViewTo(SelectedItem, true); } return true; } else if (key == KeyboardKeys.ArrowDown) { if (SelectedItem == null) return true; var nextSelectedItem = GetNextSiblings(SelectedItem).FirstOrDefault(c => c.Visible) ?? (GetNextSiblings(SelectedItem.Group).FirstOrDefault(c => c.Visible)?.Children .OfType().FirstOrDefault(c => c.Visible)); if (nextSelectedItem != null) { SelectedItem = nextSelectedItem; _panel1.ScrollViewTo(SelectedItem); } return true; } if (_waitingForInput) { _waitingForInput = false; _searchBox.Focus(); return _searchBox.OnKeyDown(key); } return base.OnKeyDown(key); } /// /// Gets the next siblings of a control. /// /// A control that is attached to a parent /// An with the siblings that come after the current one. private IEnumerable GetNextSiblings(Control item) { if (item?.Parent == null) yield break; var parent = item.Parent; for (int i = item.IndexInParent + 1; i < parent.ChildrenCount; i++) { yield return parent.GetChild(i); } } /// /// Gets the next siblings of a control that have a specific type. /// /// The type that the controls should have. /// A control that is attached to a parent /// An with the siblings that come after the current one. private IEnumerable GetNextSiblings(Control item) where T : Control { return GetNextSiblings(item).OfType(); } /// /// Gets the previous siblings of a control. /// /// A control that is attached to a parent /// An with the siblings that come before the current one. private IEnumerable GetPreviousSiblings(Control item) { if (item?.Parent == null) yield break; var parent = item.Parent; for (int i = item.IndexInParent - 1; i >= 0; i--) { yield return parent.GetChild(i); } } /// /// Gets the previous sibling of a control that have a specific type. /// /// The type that the controls should have. /// A control that is attached to a parent /// An with the siblings that come before the current one. private IEnumerable GetPreviousSiblings(Control item) where T : Control { return GetPreviousSiblings(item).OfType(); } /// public override void OnDestroy() { _contextSensitiveToggle.StateChanged -= OnContextSensitiveToggleStateChanged; base.OnDestroy(); } } }