diff --git a/src/Artemis.Core/Models/Profile/Conditions/EventCondition.cs b/src/Artemis.Core/Models/Profile/Conditions/EventCondition.cs index d39a0f7f5..f198b5397 100644 --- a/src/Artemis.Core/Models/Profile/Conditions/EventCondition.cs +++ b/src/Artemis.Core/Models/Profile/Conditions/EventCondition.cs @@ -1,7 +1,7 @@ using System; +using System.Collections.Generic; using System.Linq; using Artemis.Core.Internal; -using Artemis.Core.VisualScripting.Internal; using Artemis.Storage.Entities.Profile.Abstract; using Artemis.Storage.Entities.Profile.Conditions; @@ -14,12 +14,12 @@ public class EventCondition : CorePropertyChanged, INodeScriptCondition { private readonly string _displayName; private readonly EventConditionEntity _entity; + private NodeScript _script; + private DefaultNode _startNode; private DataModelPath? _eventPath; private DateTime _lastProcessedTrigger; private object? _lastProcessedValue; private EventOverlapMode _overlapMode; - private NodeScript _script; - private IEventConditionNode _startNode; private EventToggleOffMode _toggleOffMode; private EventTriggerMode _triggerMode; private bool _wasMet; @@ -34,7 +34,7 @@ public class EventCondition : CorePropertyChanged, INodeScriptCondition _entity = new EventConditionEntity(); _displayName = profileElement.GetType().Name; _startNode = new EventConditionEventStartNode {X = -300}; - _script = new NodeScript($"Activate {_displayName}", $"Whether or not the event should activate the {_displayName}", ProfileElement.Profile); + _script = new NodeScript($"Activate {_displayName}", $"Whether or not the event should activate the {_displayName}", ProfileElement.Profile, new List {_startNode}); } internal EventCondition(EventConditionEntity entity, RenderProfileElement profileElement) @@ -96,109 +96,6 @@ public class EventCondition : CorePropertyChanged, INodeScriptCondition set => SetAndNotify(ref _toggleOffMode, value); } - /// - /// Updates the event node, applying the selected event - /// - public void UpdateEventNode() - { - if (EventPath == null) - return; - - Type? pathType = EventPath.GetPropertyType(); - if (pathType == null) - return; - - // Create an event node if the path type is a data model event - if (pathType.IsAssignableTo(typeof(IDataModelEvent))) - { - EventConditionEventStartNode eventNode; - // Ensure the start node is an event node - if (_startNode is not EventConditionEventStartNode node) - { - eventNode = new EventConditionEventStartNode(); - ReplaceStartNode(eventNode); - _startNode = eventNode; - } - else - { - eventNode = node; - } - - IDataModelEvent? dataModelEvent = EventPath?.GetValue() as IDataModelEvent; - eventNode.CreatePins(dataModelEvent); - } - // Create a value changed node if the path type is a regular value - else - { - // Ensure the start nod is a value changed node - EventConditionValueChangedStartNode valueChangedNode; - // Ensure the start node is an event node - if (_startNode is not EventConditionValueChangedStartNode node) - { - valueChangedNode = new EventConditionValueChangedStartNode(); - ReplaceStartNode(valueChangedNode); - } - else - { - valueChangedNode = node; - } - - valueChangedNode.UpdateOutputPins(EventPath); - } - - if (!Script.Nodes.Contains(_startNode)) - Script.AddNode(_startNode); - Script.Save(); - } - - /// - /// Gets the start node of the event script, if any - /// - /// The start node of the event script, if any. - public INode GetStartNode() - { - return _startNode; - } - - private void ReplaceStartNode(IEventConditionNode newStartNode) - { - if (Script.Nodes.Contains(_startNode)) - Script.RemoveNode(_startNode); - - _startNode = newStartNode; - if (!Script.Nodes.Contains(_startNode)) - Script.AddNode(_startNode); - } - - private bool Evaluate() - { - if (EventPath == null) - return false; - - object? value = EventPath.GetValue(); - if (_startNode is EventConditionEventStartNode) - { - if (value is not IDataModelEvent dataModelEvent || dataModelEvent.LastTrigger <= _lastProcessedTrigger) - return false; - - _lastProcessedTrigger = dataModelEvent.LastTrigger; - } - else if (_startNode is EventConditionValueChangedStartNode valueChangedNode) - { - if (Equals(value, _lastProcessedValue)) - return false; - - valueChangedNode.UpdateValues(value, _lastProcessedValue); - _lastProcessedValue = value; - } - - if (!Script.ExitNodeConnected) - return true; - - Script.Run(); - return Script.Result; - } - /// public IConditionEntity Entity => _entity; @@ -264,6 +161,116 @@ public class EventCondition : CorePropertyChanged, INodeScriptCondition Script?.Dispose(); EventPath?.Dispose(); } + + /// + /// Updates the event node, applying the selected event + /// + public void UpdateEventNode(bool updateScript) + { + if (EventPath == null) + return; + + Type? pathType = EventPath.GetPropertyType(); + if (pathType == null) + return; + + // Create an event node if the path type is a data model event + if (pathType.IsAssignableTo(typeof(IDataModelEvent))) + { + EventConditionEventStartNode eventNode; + // Ensure the start node is an event node + if (_startNode is not EventConditionEventStartNode node) + { + eventNode = new EventConditionEventStartNode(); + if (updateScript) + ReplaceStartNode(eventNode); + _startNode = eventNode; + } + else + { + eventNode = node; + } + + IDataModelEvent? dataModelEvent = EventPath?.GetValue() as IDataModelEvent; + eventNode.CreatePins(dataModelEvent); + } + // Create a value changed node if the path type is a regular value + else + { + // Ensure the start nod is a value changed node + EventConditionValueChangedStartNode valueChangedNode; + // Ensure the start node is an event node + if (_startNode is not EventConditionValueChangedStartNode node) + { + valueChangedNode = new EventConditionValueChangedStartNode(); + if (updateScript) + ReplaceStartNode(valueChangedNode); + _startNode = valueChangedNode; + } + else + { + valueChangedNode = node; + } + + valueChangedNode.UpdateOutputPins(EventPath); + } + + // Script can be null if called before load + if (!updateScript) + return; + if (!Script.Nodes.Contains(_startNode)) + { + Script.AddNode(_startNode); + Script.LoadConnections(); + } + Script.Save(); + } + + /// + /// Gets the start node of the event script, if any + /// + /// The start node of the event script, if any. + public INode GetStartNode() + { + return _startNode; + } + + private void ReplaceStartNode(DefaultNode newStartNode) + { + if (Script.Nodes.Contains(_startNode)) + Script.RemoveNode(_startNode); + if (!Script.Nodes.Contains(newStartNode)) + Script.AddNode(newStartNode); + } + + private bool Evaluate() + { + if (EventPath == null) + return false; + + object? value = EventPath.GetValue(); + if (_startNode is EventConditionEventStartNode) + { + if (value is not IDataModelEvent dataModelEvent || dataModelEvent.LastTrigger <= _lastProcessedTrigger) + return false; + + _lastProcessedTrigger = dataModelEvent.LastTrigger; + } + else if (_startNode is EventConditionValueChangedStartNode valueChangedNode) + { + if (Equals(value, _lastProcessedValue)) + return false; + + valueChangedNode.UpdateValues(value, _lastProcessedValue); + _lastProcessedValue = value; + } + + if (!Script.ExitNodeConnected) + return true; + + Script.Run(); + return Script.Result; + } #region Storage @@ -276,11 +283,13 @@ public class EventCondition : CorePropertyChanged, INodeScriptCondition if (_entity.EventPath != null) EventPath = new DataModelPath(_entity.EventPath); - + UpdateEventNode(false); + + string name = $"Activate {_displayName}"; + string description = $"Whether or not the event should activate the {_displayName}"; Script = _entity.Script != null - ? new NodeScript($"Activate {_displayName}", $"Whether or not the event should activate the {_displayName}", _entity.Script, ProfileElement.Profile) - : new NodeScript($"Activate {_displayName}", $"Whether or not the event should activate the {_displayName}", ProfileElement.Profile); - UpdateEventNode(); + ? new NodeScript(name, description, _entity.Script, ProfileElement.Profile, new List {_startNode}) + : new NodeScript(name, description, ProfileElement.Profile, new List {_startNode}); } /// @@ -311,71 +320,9 @@ public class EventCondition : CorePropertyChanged, INodeScriptCondition /// public void LoadNodeScript() { + UpdateEventNode(true); Script.Load(); - - // The load action may have created an event node, use that one over the one we have here - INode? existingEventNode = Script.Nodes.FirstOrDefault(n => n.Id == EventConditionEventStartNode.NodeId || n.Id == EventConditionValueChangedStartNode.NodeId); - if (existingEventNode != null) - _startNode = (IEventConditionNode) existingEventNode; - - UpdateEventNode(); - Script.LoadConnections(); } #endregion -} - -/// -/// Represents a mode for render elements to start their timeline when display conditions events are fired. -/// -public enum EventTriggerMode -{ - /// - /// Play the timeline once. - /// - Play, - - /// - /// Toggle repeating the timeline. - /// - Toggle -} - -/// -/// Represents a mode for render elements to configure the behaviour of events that overlap i.e. trigger again before -/// the timeline finishes. -/// -public enum EventOverlapMode -{ - /// - /// Stop the current run and restart the timeline - /// - Restart, - - /// - /// Play another copy of the timeline on top of the current run - /// - Copy, - - /// - /// Ignore subsequent event fires until the timeline finishes - /// - Ignore -} - -/// -/// Represents a mode for render elements when toggling off the event when using -/// . -/// -public enum EventToggleOffMode -{ - /// - /// When the event toggles the condition off, finish the the current run of the main timeline - /// - Finish, - - /// - /// When the event toggles the condition off, skip to the end segment of the timeline - /// - SkipToEnd } \ No newline at end of file diff --git a/src/Artemis.Core/Models/Profile/Conditions/EventOverlapMode.cs b/src/Artemis.Core/Models/Profile/Conditions/EventOverlapMode.cs new file mode 100644 index 000000000..56068a797 --- /dev/null +++ b/src/Artemis.Core/Models/Profile/Conditions/EventOverlapMode.cs @@ -0,0 +1,23 @@ +namespace Artemis.Core; + +/// +/// Represents a mode for render elements to configure the behaviour of events that overlap i.e. trigger again before +/// the timeline finishes. +/// +public enum EventOverlapMode +{ + /// + /// Stop the current run and restart the timeline + /// + Restart, + + /// + /// Play another copy of the timeline on top of the current run + /// + Copy, + + /// + /// Ignore subsequent event fires until the timeline finishes + /// + Ignore +} \ No newline at end of file diff --git a/src/Artemis.Core/Models/Profile/Conditions/EventToggleOffMode.cs b/src/Artemis.Core/Models/Profile/Conditions/EventToggleOffMode.cs new file mode 100644 index 000000000..c8db3aa6a --- /dev/null +++ b/src/Artemis.Core/Models/Profile/Conditions/EventToggleOffMode.cs @@ -0,0 +1,18 @@ +namespace Artemis.Core; + +/// +/// Represents a mode for render elements when toggling off the event when using +/// . +/// +public enum EventToggleOffMode +{ + /// + /// When the event toggles the condition off, finish the the current run of the main timeline + /// + Finish, + + /// + /// When the event toggles the condition off, skip to the end segment of the timeline + /// + SkipToEnd +} \ No newline at end of file diff --git a/src/Artemis.Core/Models/Profile/Conditions/EventTriggerMode.cs b/src/Artemis.Core/Models/Profile/Conditions/EventTriggerMode.cs new file mode 100644 index 000000000..cfe31816d --- /dev/null +++ b/src/Artemis.Core/Models/Profile/Conditions/EventTriggerMode.cs @@ -0,0 +1,17 @@ +namespace Artemis.Core; + +/// +/// Represents a mode for render elements to start their timeline when display conditions events are fired. +/// +public enum EventTriggerMode +{ + /// + /// Play the timeline once. + /// + Play, + + /// + /// Toggle repeating the timeline. + /// + Toggle +} \ No newline at end of file diff --git a/src/Artemis.Core/Services/Input/InputProvider.cs b/src/Artemis.Core/Services/Input/InputProvider.cs index f04b1a1f9..c51a25d63 100644 --- a/src/Artemis.Core/Services/Input/InputProvider.cs +++ b/src/Artemis.Core/Services/Input/InputProvider.cs @@ -8,6 +8,13 @@ namespace Artemis.Core.Services; /// public abstract class InputProvider : IDisposable { + public InputProvider() + { + ProviderName = GetType().FullName ?? throw new InvalidOperationException("Input provider must have a type with a name"); + } + + internal string ProviderName { get; set; } + /// /// Called when the input service requests a event /// diff --git a/src/Artemis.Core/Services/Input/InputService.cs b/src/Artemis.Core/Services/Input/InputService.cs index 187f0db4b..48ae607f6 100644 --- a/src/Artemis.Core/Services/Input/InputService.cs +++ b/src/Artemis.Core/Services/Input/InputService.cs @@ -10,12 +10,18 @@ internal class InputService : IInputService { private readonly ILogger _logger; private readonly IRgbService _rgbService; + private ArtemisDevice? _firstKeyboard; + private ArtemisDevice? _firstMouse; + private int _keyboardCount; + private int _mouseCount; public InputService(ILogger logger, IRgbService rgbService) { _logger = logger; _rgbService = rgbService; + _rgbService.DeviceAdded += RgbServiceOnDevicesModified; + _rgbService.DeviceRemoved += RgbServiceOnDevicesModified; BustIdentifierCache(); } @@ -132,8 +138,6 @@ internal class InputService : IInputService private readonly Dictionary, ArtemisDevice> _deviceCache = new(); private List _devices = new(); - private ArtemisDevice? _cachedFallbackKeyboard; - private ArtemisDevice? _cachedFallbackMouse; private ArtemisDevice? _identifyingDevice; public void IdentifyDevice(ArtemisDevice device) @@ -164,13 +168,29 @@ internal class InputService : IInputService if (provider == null) throw new ArgumentNullException(nameof(provider)); if (identifier == null) throw new ArgumentNullException(nameof(identifier)); + // We will almost always only have zero or one of each + if (type == InputDeviceType.Keyboard) + { + if (_keyboardCount == 0) + return null; + if (_keyboardCount == 1) + return _firstKeyboard; + } + + if (type == InputDeviceType.Mouse) + { + if (_mouseCount == 0) + return null; + if (_mouseCount == 1) + return _firstMouse; + } + // Try cache first ArtemisDevice? cacheMatch = GetDeviceFromCache(provider, identifier); if (cacheMatch != null) return cacheMatch; - string providerName = provider.GetType().FullName!; - ArtemisDevice? match = _devices.FirstOrDefault(m => m.InputIdentifiers.Any(i => Equals(i.InputProvider, providerName) && Equals(i.Identifier, identifier))); + ArtemisDevice? match = _devices.FirstOrDefault(m => m.InputIdentifiers.Any(i => Equals(i.InputProvider, provider.ProviderName) && Equals(i.Identifier, identifier))); // If a match was found cache it to speed up the next event and return the match if (match != null) @@ -179,34 +199,16 @@ internal class InputService : IInputService return match; } - // If there is no match, apply our fallback type - if (type == InputDeviceType.None) - return null; if (type == InputDeviceType.Keyboard) - { - if (_cachedFallbackKeyboard != null) - return _cachedFallbackKeyboard; - _cachedFallbackKeyboard = _rgbService.EnabledDevices.FirstOrDefault(d => d.DeviceType == RGBDeviceType.Keyboard); - return _cachedFallbackKeyboard; - } - + return _firstKeyboard; if (type == InputDeviceType.Mouse) - { - if (_cachedFallbackMouse != null) - return _cachedFallbackMouse; - _cachedFallbackMouse = _rgbService.EnabledDevices.FirstOrDefault(d => d.DeviceType == RGBDeviceType.Mouse); - return _cachedFallbackMouse; - } - + return _firstMouse; return null; } public void BustIdentifierCache() { _deviceCache.Clear(); - _cachedFallbackKeyboard = null; - _cachedFallbackMouse = null; - _devices = _rgbService.EnabledDevices.Where(d => d.InputIdentifiers.Any()).ToList(); } @@ -220,12 +222,7 @@ internal class InputService : IInputService _deviceCache.TryGetValue(new Tuple(provider, identifier), out ArtemisDevice? device); return device; } - - private void SurfaceConfigurationChanged(object? sender, SurfaceConfigurationEventArgs e) - { - BustIdentifierCache(); - } - + private void InputProviderOnIdentifierReceived(object? sender, InputProviderIdentifierEventArgs e) { // Don't match if there is no device or if the device type differs from the event device type @@ -236,16 +233,24 @@ internal class InputService : IInputService if (!(sender is InputProvider inputProvider)) return; - string providerName = inputProvider.GetType().FullName!; - // Remove existing identification - _identifyingDevice.InputIdentifiers.RemoveAll(i => i.InputProvider == providerName); - _identifyingDevice.InputIdentifiers.Add(new ArtemisDeviceInputIdentifier(providerName, e.Identifier)); + _identifyingDevice.InputIdentifiers.RemoveAll(i => i.InputProvider == inputProvider.ProviderName); + _identifyingDevice.InputIdentifiers.Add(new ArtemisDeviceInputIdentifier(inputProvider.ProviderName, e.Identifier)); StopIdentify(); OnDeviceIdentified(); } + private void RgbServiceOnDevicesModified(object? sender, DeviceEventArgs args) + { + _firstKeyboard = _rgbService.Devices.FirstOrDefault(d => d.DeviceType == RGBDeviceType.Keyboard); + _firstMouse = _rgbService.Devices.FirstOrDefault(d => d.DeviceType == RGBDeviceType.Mouse); + _keyboardCount = _rgbService.Devices.Count(d => d.DeviceType == RGBDeviceType.Keyboard); + _mouseCount = _rgbService.Devices.Count(d => d.DeviceType == RGBDeviceType.Mouse); + + BustIdentifierCache(); + } + #endregion #region Keyboard @@ -379,6 +384,7 @@ internal class InputService : IInputService private readonly HashSet _pressedButtons = new(); + private void InputProviderOnMouseButtonDataReceived(object? sender, InputProviderMouseButtonEventArgs e) { bool foundLedId = InputKeyUtilities.MouseButtonLedIdMap.TryGetValue(e.Button, out LedId ledId); diff --git a/src/Artemis.Core/VisualScripting/Internal/DefaultNode.cs b/src/Artemis.Core/VisualScripting/DefaultNode.cs similarity index 60% rename from src/Artemis.Core/VisualScripting/Internal/DefaultNode.cs rename to src/Artemis.Core/VisualScripting/DefaultNode.cs index 103aa4e12..733b4e97b 100644 --- a/src/Artemis.Core/VisualScripting/Internal/DefaultNode.cs +++ b/src/Artemis.Core/VisualScripting/DefaultNode.cs @@ -1,18 +1,11 @@ using System; -namespace Artemis.Core.Internal; - -/// -/// Represents a kind of node that cannot be deleted inside a . -/// -public interface IDefaultNode : INode -{ -} +namespace Artemis.Core; /// /// Represents a kind of node that cannot be deleted inside a . /// -public abstract class DefaultNode : Node, IDefaultNode +public abstract class DefaultNode : Node { #region Constructors @@ -20,8 +13,6 @@ public abstract class DefaultNode : Node, IDefaultNode protected DefaultNode(Guid id, string name, string description = "") : base(name, description) { Id = id; - Name = name; - Description = description; } #endregion diff --git a/src/Artemis.Core/VisualScripting/Internal/EventConditionEventStartNode.cs b/src/Artemis.Core/VisualScripting/Internal/EventConditionEventStartNode.cs index 21a264caa..f417e8871 100644 --- a/src/Artemis.Core/VisualScripting/Internal/EventConditionEventStartNode.cs +++ b/src/Artemis.Core/VisualScripting/Internal/EventConditionEventStartNode.cs @@ -1,54 +1,30 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using Artemis.Core.Modules; -using Artemis.Core.VisualScripting.Internal; -using Humanizer; namespace Artemis.Core.Internal; -internal class EventConditionEventStartNode : DefaultNode, IEventConditionNode +internal class EventConditionEventStartNode : DefaultNode { internal static readonly Guid NodeId = new("278735FE-69E9-4A73-A6B8-59E83EE19305"); - private readonly Dictionary, OutputPin> _propertyPins; + private readonly ObjectOutputPins _objectOutputPins; private IDataModelEvent? _dataModelEvent; public EventConditionEventStartNode() : base(NodeId, "Event Arguments", "Contains the event arguments that triggered the evaluation") { - _propertyPins = new Dictionary, OutputPin>(); + _objectOutputPins = new ObjectOutputPins(this); } + public void SetDataModelEvent(IDataModelEvent? dataModelEvent) + { + + } + public void CreatePins(IDataModelEvent? dataModelEvent) { if (_dataModelEvent == dataModelEvent) return; - while (Pins.Any()) - RemovePin((Pin) Pins.First()); - _propertyPins.Clear(); - _dataModelEvent = dataModelEvent; - if (dataModelEvent == null) - return; - - foreach (PropertyInfo propertyInfo in dataModelEvent.ArgumentsType.GetProperties(BindingFlags.Instance | BindingFlags.Public) - .Where(p => p.CustomAttributes.All(a => a.AttributeType != typeof(DataModelIgnoreAttribute)))) - { - // Expect an IDataModelEvent - ParameterExpression eventParameter = Expression.Parameter(typeof(DataModelEventArgs), "event"); - // Cast it to the actual event type - UnaryExpression eventCast = Expression.Convert(eventParameter, propertyInfo.DeclaringType!); - // Access the property - MemberExpression accessor = Expression.Property(eventCast, propertyInfo); - // Cast the property to an object (sadly boxing) - UnaryExpression objectCast = Expression.Convert(accessor, typeof(object)); - // Compile the resulting expression - Func expression = Expression.Lambda>(objectCast, eventParameter).Compile(); - - _propertyPins.Add(expression, CreateOrAddOutputPin(propertyInfo.PropertyType, propertyInfo.Name.Humanize())); - } + _objectOutputPins.ChangeType(dataModelEvent?.ArgumentsType); } public override void Evaluate() @@ -56,12 +32,6 @@ internal class EventConditionEventStartNode : DefaultNode, IEventConditionNode if (_dataModelEvent?.LastEventArgumentsUntyped == null) return; - foreach ((Func propertyAccessor, OutputPin outputPin) in _propertyPins) - { - if (!outputPin.ConnectedTo.Any()) - continue; - object value = _dataModelEvent.LastEventArgumentsUntyped != null ? propertyAccessor(_dataModelEvent.LastEventArgumentsUntyped) : outputPin.Type.GetDefault()!; - outputPin.Value = outputPin.IsNumeric ? new Numeric(value) : value; - } + _objectOutputPins.SetCurrentValue(_dataModelEvent.LastEventArgumentsUntyped); } } \ No newline at end of file diff --git a/src/Artemis.Core/VisualScripting/Internal/EventConditionValueChangedStartNode.cs b/src/Artemis.Core/VisualScripting/Internal/EventConditionValueChangedStartNode.cs index abf571979..c334681bc 100644 --- a/src/Artemis.Core/VisualScripting/Internal/EventConditionValueChangedStartNode.cs +++ b/src/Artemis.Core/VisualScripting/Internal/EventConditionValueChangedStartNode.cs @@ -1,9 +1,8 @@ using System; -using Artemis.Core.VisualScripting.Internal; namespace Artemis.Core.Internal; -internal class EventConditionValueChangedStartNode : DefaultNode, IEventConditionNode +internal class EventConditionValueChangedStartNode : DefaultNode { internal static readonly Guid NodeId = new("F9A270DB-A231-4800-BAB3-DC1F96856756"); private object? _newValue; diff --git a/src/Artemis.Core/VisualScripting/Internal/IEventConditionNode.cs b/src/Artemis.Core/VisualScripting/Internal/IEventConditionNode.cs deleted file mode 100644 index e47ad33a7..000000000 --- a/src/Artemis.Core/VisualScripting/Internal/IEventConditionNode.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Artemis.Core.VisualScripting.Internal; - -internal interface IEventConditionNode : INode -{ -} \ No newline at end of file diff --git a/src/Artemis.Core/VisualScripting/Node.cs b/src/Artemis.Core/VisualScripting/Node.cs index 7e2161580..f212524de 100644 --- a/src/Artemis.Core/VisualScripting/Node.cs +++ b/src/Artemis.Core/VisualScripting/Node.cs @@ -132,7 +132,7 @@ public abstract class Node : BreakableModel, INode /// The name of the pin /// The type of value the pin will hold /// The newly created pin - protected InputPin CreateInputPin(string name = "") + public InputPin CreateInputPin(string name = "") { InputPin pin = new(this, name); _pins.Add(pin); @@ -146,7 +146,7 @@ public abstract class Node : BreakableModel, INode /// The type of value the pin will hold /// The name of the pin /// The newly created pin - protected InputPin CreateInputPin(Type type, string name = "") + public InputPin CreateInputPin(Type type, string name = "") { InputPin pin = new(this, type, name); _pins.Add(pin); @@ -160,7 +160,7 @@ public abstract class Node : BreakableModel, INode /// The name of the pin /// The type of value the pin will hold /// The newly created pin - protected OutputPin CreateOutputPin(string name = "") + public OutputPin CreateOutputPin(string name = "") { OutputPin pin = new(this, name); _pins.Add(pin); @@ -174,7 +174,7 @@ public abstract class Node : BreakableModel, INode /// The type of value the pin will hold /// The name of the pin /// The newly created pin - protected OutputPin CreateOutputPin(Type type, string name = "") + public OutputPin CreateOutputPin(Type type, string name = "") { OutputPin pin = new(this, type, name); _pins.Add(pin); @@ -187,7 +187,7 @@ public abstract class Node : BreakableModel, INode /// The bucket might grow a bit over time as the user edits the node but pins won't get lost, enabling undo/redo in the /// editor. /// - protected OutputPin CreateOrAddOutputPin(Type valueType, string displayName) + public OutputPin CreateOrAddOutputPin(Type valueType, string displayName) { // Grab the first pin from the bucket that isn't on the node yet OutputPin? pin = _outputPinBucket.FirstOrDefault(p => !Pins.Contains(p)); @@ -217,7 +217,7 @@ public abstract class Node : BreakableModel, INode /// The bucket might grow a bit over time as the user edits the node but pins won't get lost, enabling undo/redo in the /// editor. /// - protected InputPin CreateOrAddInputPin(Type valueType, string displayName) + public InputPin CreateOrAddInputPin(Type valueType, string displayName) { // Grab the first pin from the bucket that isn't on the node yet InputPin? pin = _inputPinBucket.FirstOrDefault(p => !Pins.Contains(p)); @@ -247,7 +247,7 @@ public abstract class Node : BreakableModel, INode /// /// The pin to remove /// if the pin was removed; otherwise . - protected bool RemovePin(Pin pin) + public bool RemovePin(Pin pin) { bool isRemoved = _pins.Remove(pin); if (isRemoved) @@ -263,7 +263,7 @@ public abstract class Node : BreakableModel, INode /// Adds an existing to the collection. /// /// The pin to add - protected void AddPin(Pin pin) + public void AddPin(Pin pin) { if (pin.Node != this) throw new ArtemisCoreException("Can't add a pin to a node that belongs to a different node than the one it's being added to."); @@ -281,7 +281,7 @@ public abstract class Node : BreakableModel, INode /// The name of the pin collection /// The amount of pins to initially add to the collection /// The resulting input pin collection - protected InputPinCollection CreateInputPinCollection(string name = "", int initialCount = 1) + public InputPinCollection CreateInputPinCollection(string name = "", int initialCount = 1) { InputPinCollection pin = new(this, name, initialCount); _pinCollections.Add(pin); @@ -296,7 +296,7 @@ public abstract class Node : BreakableModel, INode /// The name of the pin collection /// The amount of pins to initially add to the collection /// The resulting input pin collection - protected InputPinCollection CreateInputPinCollection(Type type, string name = "", int initialCount = 1) + public InputPinCollection CreateInputPinCollection(Type type, string name = "", int initialCount = 1) { InputPinCollection pin = new(this, type, name, initialCount); _pinCollections.Add(pin); @@ -311,7 +311,7 @@ public abstract class Node : BreakableModel, INode /// The name of the pin collection /// The amount of pins to initially add to the collection /// The resulting output pin collection - protected OutputPinCollection CreateOutputPinCollection(string name = "", int initialCount = 1) + public OutputPinCollection CreateOutputPinCollection(string name = "", int initialCount = 1) { OutputPinCollection pin = new(this, name, initialCount); _pinCollections.Add(pin); @@ -325,7 +325,7 @@ public abstract class Node : BreakableModel, INode /// /// The pin collection to remove /// if the pin collection was removed; otherwise . - protected bool RemovePinCollection(PinCollection pinCollection) + public bool RemovePinCollection(PinCollection pinCollection) { bool isRemoved = _pinCollections.Remove(pinCollection); if (isRemoved) @@ -339,7 +339,7 @@ public abstract class Node : BreakableModel, INode } /// - /// Called when the node was loaded from storage or newly created + /// Called when the node was loaded from storage or newly created, at this point pin connections aren't reestablished yet. /// /// The script the node is contained in public virtual void Initialize(INodeScript script) diff --git a/src/Artemis.Core/VisualScripting/NodeScript.cs b/src/Artemis.Core/VisualScripting/NodeScript.cs index 1c77626f8..9bfb08011 100644 --- a/src/Artemis.Core/VisualScripting/NodeScript.cs +++ b/src/Artemis.Core/VisualScripting/NodeScript.cs @@ -35,7 +35,10 @@ public abstract class NodeScript : CorePropertyChanged, INodeScript #region Properties & Fields - internal NodeScriptEntity Entity { get; private set; } + /// + /// Gets the entity used to store this script. + /// + public NodeScriptEntity Entity { get; private set; } /// public string Name { get; } @@ -77,7 +80,8 @@ public abstract class NodeScript : CorePropertyChanged, INodeScript /// The context of the node script, usually a or /// /// - protected NodeScript(string name, string description, object? context = null) + /// A list of default nodes to add to the node script. + protected NodeScript(string name, string description, object? context = null, List? defaultNodes = null) { Name = name; Description = description; @@ -87,9 +91,15 @@ public abstract class NodeScript : CorePropertyChanged, INodeScript NodeTypeStore.NodeTypeAdded += NodeTypeStoreOnNodeTypeAdded; NodeTypeStore.NodeTypeRemoved += NodeTypeStoreOnNodeTypeRemoved; + + if (defaultNodes != null) + { + foreach (DefaultNode defaultNode in defaultNodes) + AddNode(defaultNode); + } } - internal NodeScript(string name, string description, NodeScriptEntity entity, object? context = null) + internal NodeScript(string name, string description, NodeScriptEntity entity, object? context = null, List? defaultNodes = null) { Name = name; Description = description; @@ -99,6 +109,12 @@ public abstract class NodeScript : CorePropertyChanged, INodeScript NodeTypeStore.NodeTypeAdded += NodeTypeStoreOnNodeTypeAdded; NodeTypeStore.NodeTypeRemoved += NodeTypeStoreOnNodeTypeRemoved; + + if (defaultNodes != null) + { + foreach (DefaultNode defaultNode in defaultNodes) + AddNode(defaultNode); + } } #endregion @@ -410,8 +426,9 @@ public class NodeScript : NodeScript, INodeScript #region Constructors - internal NodeScript(string name, string description, NodeScriptEntity entity, object? context = null) - : base(name, description, entity, context) + /// + public NodeScript(string name, string description, NodeScriptEntity entity, object? context = null, List? defaultNodes = null) + : base(name, description, entity, context, defaultNodes) { ExitNode = new ExitNode(name, description); AddNode(ExitNode); @@ -420,8 +437,8 @@ public class NodeScript : NodeScript, INodeScript } /// - public NodeScript(string name, string description, object? context = null) - : base(name, description, context) + public NodeScript(string name, string description, object? context = null, List? defaultNodes = null) + : base(name, description, context, defaultNodes) { ExitNode = new ExitNode(name, description); AddNode(ExitNode); diff --git a/src/Artemis.Core/VisualScripting/ObjectOutputPins.cs b/src/Artemis.Core/VisualScripting/ObjectOutputPins.cs new file mode 100644 index 000000000..fb9c874f7 --- /dev/null +++ b/src/Artemis.Core/VisualScripting/ObjectOutputPins.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using Artemis.Core.Modules; +using Humanizer; + +namespace Artemis.Core; + +/// +/// Represents a collection of output pins for a node capable of outputting the properties of an object or value type. +/// +public class ObjectOutputPins +{ + private readonly Dictionary, OutputPin> _propertyPins; + private OutputPin? _valueTypePin; + + /// + /// Creates an instance of the class. + /// + /// The node the object output was created for. + public ObjectOutputPins(Node node) + { + Node = node; + _propertyPins = new Dictionary, OutputPin>(); + } + + /// + /// Gets the node the object output was created for. + /// + public Node Node { get; } + + /// + /// Gets the current type the node's pins are set up for. + /// + public Type? CurrentType { get; private set; } + + /// + /// Gets a read only collection of the pins outputting the object of this object node. + /// + public ReadOnlyCollection Pins => _valueTypePin != null ? new ReadOnlyCollection(new List {_valueTypePin}) : _propertyPins.Values.ToList().AsReadOnly(); + + /// + /// Change the current type and create pins on the node to reflect this. + /// + /// The type to change the collection to. + public void ChangeType(Type? type) + { + if (type == CurrentType) + return; + CurrentType = type; + + // Remove current pins + foreach ((Func? _, OutputPin? pin) in _propertyPins) + Node.RemovePin(pin); + _propertyPins.Clear(); + if (_valueTypePin != null) + { + Node.RemovePin(_valueTypePin); + _valueTypePin = null; + } + + if (type == null) + return; + + // Create new pins + List nodeTypeColors = NodeTypeStore.GetColors(); + if (type.IsClass && type != typeof(string)) + foreach (PropertyInfo propertyInfo in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + Type propertyType = propertyInfo.PropertyType; + bool toNumeric = Numeric.IsTypeCompatible(propertyType); + + // Skip ignored properties + if (propertyInfo.CustomAttributes.Any(a => a.AttributeType == typeof(DataModelIgnoreAttribute))) + continue; + // Skip incompatible properties + if (!toNumeric && !nodeTypeColors.Any(c => c.Type.IsAssignableFrom(propertyType))) + continue; + + // Expect an object + ParameterExpression itemParameter = Expression.Parameter(typeof(object), "item"); + // Cast it to the actual item type + UnaryExpression itemCast = Expression.Convert(itemParameter, propertyInfo.DeclaringType!); + // Access the property + MemberExpression accessor = Expression.Property(itemCast, propertyInfo); + + // Turn into a numeric if needed or access directly + UnaryExpression objectExpression; + if (toNumeric) + { + propertyType = typeof(Numeric); + ConstructorInfo constructor = typeof(Numeric).GetConstructors().First(c => c.GetParameters().First().ParameterType == propertyInfo.PropertyType); + // Cast the property to an object (sadly boxing) + objectExpression = Expression.Convert(Expression.New(constructor, accessor), typeof(object)); + } + else + { + // Cast the property to an object (sadly boxing) + objectExpression = Expression.Convert(accessor, typeof(object)); + } + + // Compile the resulting expression + Func expression = Expression.Lambda>(objectExpression, itemParameter).Compile(); + _propertyPins.Add(expression, Node.CreateOrAddOutputPin(propertyType, propertyInfo.Name.Humanize())); + } + else + // Value types are applied directly to a single pin, however if the type is compatible with Numeric, we use a Numeric pin instead + // the value will then be turned into a numeric in SetCurrentValue + _valueTypePin = Node.CreateOrAddOutputPin(Numeric.IsTypeCompatible(type) ? typeof(Numeric) : type, "Item"); + } + + /// + /// Set the current value to be output onto connected pins. + /// + /// The value to output onto the connected pins. + /// + public void SetCurrentValue(object? value) + { + if (CurrentType == null) + throw new ArtemisCoreException("Cannot apply a value to an object output pins not yet configured for a type."); + if (value != null && CurrentType != value.GetType()) + throw new ArtemisCoreException($"Cannot apply a value of type {value.GetType().FullName} to an object output pins configured for type {CurrentType.FullName}"); + + // Apply the object to the pin, it must be connected if SetCurrentValue got called + if (_valueTypePin != null) + { + value ??= _valueTypePin.Type.GetDefault(); + _valueTypePin.Value = _valueTypePin.Type == typeof(Numeric) ? new Numeric(value) : value; + return; + } + + // Apply the properties of the object to each connected pin + foreach ((Func? propertyAccessor, OutputPin? outputPin) in _propertyPins) + { + if (outputPin.ConnectedTo.Any()) + outputPin.Value = value != null ? propertyAccessor(value) : outputPin.Type.GetDefault(); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Services/NodeEditor/NodeScriptWindowViewModelBase.cs b/src/Artemis.UI.Shared/Services/NodeEditor/NodeScriptWindowViewModelBase.cs new file mode 100644 index 000000000..5d621f796 --- /dev/null +++ b/src/Artemis.UI.Shared/Services/NodeEditor/NodeScriptWindowViewModelBase.cs @@ -0,0 +1,23 @@ +using Artemis.Core; + +namespace Artemis.UI.Shared.Services.NodeEditor; + +/// +/// Represents the base of the node script editor window view model. +/// +public abstract class NodeScriptWindowViewModelBase : DialogViewModelBase +{ + /// + /// Creates a new instance of the class. + /// + /// The node script being edited. + protected NodeScriptWindowViewModelBase(NodeScript nodeScript) + { + NodeScript = nodeScript; + } + + /// + /// Gets the node script being edited. + /// + public NodeScript NodeScript { get; init; } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Services/ProfileEditor/Commands/UpdateEventConditionPath.cs b/src/Artemis.UI.Shared/Services/ProfileEditor/Commands/UpdateEventConditionPath.cs index d424de4ae..d103772cd 100644 --- a/src/Artemis.UI.Shared/Services/ProfileEditor/Commands/UpdateEventConditionPath.cs +++ b/src/Artemis.UI.Shared/Services/ProfileEditor/Commands/UpdateEventConditionPath.cs @@ -49,7 +49,7 @@ public class UpdateEventConditionPath : IProfileEditorCommand, IDisposable // Change the end node _eventCondition.EventPath = _value; - _eventCondition.UpdateEventNode(); + _eventCondition.UpdateEventNode(true); _executed = true; } @@ -59,7 +59,7 @@ public class UpdateEventConditionPath : IProfileEditorCommand, IDisposable { // Change the end node _eventCondition.EventPath = _oldValue; - _eventCondition.UpdateEventNode(); + _eventCondition.UpdateEventNode(true); // Restore old connections _store?.Restore(); diff --git a/src/Artemis.UI.Shared/Styles/Condensed.axaml b/src/Artemis.UI.Shared/Styles/Condensed.axaml index 62a6c8fb9..9ceb0c926 100644 --- a/src/Artemis.UI.Shared/Styles/Condensed.axaml +++ b/src/Artemis.UI.Shared/Styles/Condensed.axaml @@ -103,4 +103,11 @@ + + + \ No newline at end of file diff --git a/src/Artemis.UI.Windows/Providers/Input/WindowsInputProvider.cs b/src/Artemis.UI.Windows/Providers/Input/WindowsInputProvider.cs index d834c3002..1091fca54 100644 --- a/src/Artemis.UI.Windows/Providers/Input/WindowsInputProvider.cs +++ b/src/Artemis.UI.Windows/Providers/Input/WindowsInputProvider.cs @@ -19,7 +19,6 @@ public class WindowsInputProvider : InputProvider private readonly ILogger _logger; private readonly SpongeWindow _sponge; private readonly Timer _taskManagerTimer; - private DateTime _lastMouseUpdate; private int _lastProcessId; public WindowsInputProvider(ILogger logger, IInputService inputService) @@ -160,8 +159,8 @@ public class WindowsInputProvider : InputProvider #region Mouse - private int _mouseDeltaX; - private int _mouseDeltaY; + private int _previousMouseX; + private int _previousMouseY; private void HandleMouseData(RawInputData data, RawInputMouseData mouseData) { @@ -169,10 +168,8 @@ public class WindowsInputProvider : InputProvider // This can create a small inaccuracy of course, but Artemis is not a shooter :') if (mouseData.Mouse.Buttons == RawMouseButtonFlags.None) { - _mouseDeltaX += mouseData.Mouse.LastX; - _mouseDeltaY += mouseData.Mouse.LastY; - if (DateTime.Now - _lastMouseUpdate < TimeSpan.FromMilliseconds(40)) - return; + _previousMouseX += mouseData.Mouse.LastX; + _previousMouseY += mouseData.Mouse.LastY; } ArtemisDevice? device = null; @@ -193,10 +190,7 @@ public class WindowsInputProvider : InputProvider if (mouseData.Mouse.Buttons == RawMouseButtonFlags.None) { Win32Point cursorPosition = GetCursorPosition(); - OnMouseMoveDataReceived(device, cursorPosition.X, cursorPosition.Y, _mouseDeltaX, _mouseDeltaY); - _mouseDeltaX = 0; - _mouseDeltaY = 0; - _lastMouseUpdate = DateTime.Now; + OnMouseMoveDataReceived(device, cursorPosition.X, cursorPosition.Y, cursorPosition.X - _previousMouseX, cursorPosition.Y - _previousMouseY); return; } diff --git a/src/Artemis.UI/Extensions/ProfileElementExtensions.cs b/src/Artemis.UI/Extensions/ProfileElementExtensions.cs index 70ff9acfb..08ca23e2c 100644 --- a/src/Artemis.UI/Extensions/ProfileElementExtensions.cs +++ b/src/Artemis.UI/Extensions/ProfileElementExtensions.cs @@ -3,6 +3,7 @@ using System.Text; using System.Threading.Tasks; using Artemis.Core; using Artemis.Storage.Entities.Profile; +using Artemis.UI.Models; using Avalonia; using Avalonia.Input; @@ -21,7 +22,7 @@ public static class ProfileElementExtensions return; DataObject dataObject = new(); - string copy = CoreJson.SerializeObject(folder.FolderEntity, true); + string copy = CoreJson.SerializeObject(new FolderClipboardModel(folder), true); dataObject.Set(ClipboardDataFormat, copy); await Application.Current.Clipboard.SetDataObjectAsync(dataObject); } @@ -50,9 +51,8 @@ public static class ProfileElementExtensions object? entity = CoreJson.DeserializeObject(Encoding.Unicode.GetString(bytes), true); switch (entity) { - case FolderEntity folderEntity: - folderEntity.Id = Guid.NewGuid(); - return new Folder(parent.Profile, parent, folderEntity); + case FolderClipboardModel folderClipboardModel: + return folderClipboardModel.Paste(parent.Profile, parent); case LayerEntity layerEntity: layerEntity.Id = Guid.NewGuid(); return new Layer(parent.Profile, parent, layerEntity); diff --git a/src/Artemis.UI/MainWindow.axaml b/src/Artemis.UI/MainWindow.axaml index 1d8017354..7be2a5e6e 100644 --- a/src/Artemis.UI/MainWindow.axaml +++ b/src/Artemis.UI/MainWindow.axaml @@ -7,7 +7,8 @@ x:Class="Artemis.UI.MainWindow" Icon="/Assets/Images/Logo/application.ico" Title="Artemis 2.0" - WindowStartupLocation="CenterScreen"> + MinWidth="600" + MinHeight="400"> diff --git a/src/Artemis.UI/MainWindow.axaml.cs b/src/Artemis.UI/MainWindow.axaml.cs index 14732e730..336bfb46a 100644 --- a/src/Artemis.UI/MainWindow.axaml.cs +++ b/src/Artemis.UI/MainWindow.axaml.cs @@ -1,10 +1,15 @@ using System; +using System.Reactive; +using System.Reactive.Linq; +using Artemis.UI.Models; using Artemis.UI.Screens.Root; using Artemis.UI.Shared; using Avalonia; using Avalonia.Controls; using Avalonia.Markup.Xaml; +using Avalonia.Threading; using FluentAvalonia.Core.ApplicationModel; +using ReactiveUI; namespace Artemis.UI; @@ -12,13 +17,17 @@ public class MainWindow : ReactiveCoreWindow { private readonly Panel _rootPanel; private readonly ContentControl _sidebarContentControl; + private bool _activated; public MainWindow() { Opened += OnOpened; Activated += OnActivated; Deactivated += OnDeactivated; + + ApplyWindowSize(); InitializeComponent(); + _rootPanel = this.Get("RootPanel"); _sidebarContentControl = this.Get("SidebarContentControl"); _rootPanel.LayoutUpdated += OnLayoutUpdated; @@ -26,6 +35,27 @@ public class MainWindow : ReactiveCoreWindow #if DEBUG this.AttachDevTools(); #endif + + Observable.FromEventPattern(x => PositionChanged += x, x => PositionChanged -= x) + .Select(_ => Unit.Default) + .Merge(this.WhenAnyValue(vm => vm.WindowState, vm => vm.Width, vm => vm.Width, vm => vm.Height).Select(_ => Unit.Default)) + .Throttle(TimeSpan.FromMilliseconds(200), AvaloniaScheduler.Instance) + .Subscribe(_ => SaveWindowSize()); + } + + private void ApplyWindowSize() + { + _activated = true; + RootViewModel.WindowSizeSetting?.Value?.ApplyToWindow(this); + } + + private void SaveWindowSize() + { + if (RootViewModel.WindowSizeSetting == null || !_activated) + return; + + RootViewModel.WindowSizeSetting.Value ??= new WindowSize(); + RootViewModel.WindowSizeSetting.Value.ApplyFromWindow(this); } // TODO: Replace with a media query once https://github.com/AvaloniaUI/Avalonia/pull/7938 is implemented diff --git a/src/Artemis.UI/Models/FolderClipboardModel.cs b/src/Artemis.UI/Models/FolderClipboardModel.cs new file mode 100644 index 000000000..98301d394 --- /dev/null +++ b/src/Artemis.UI/Models/FolderClipboardModel.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using Artemis.Core; +using Artemis.Storage.Entities.Profile; +using Artemis.Storage.Entities.Profile.Abstract; +using Artemis.UI.Exceptions; + +namespace Artemis.UI.Models; + +public class FolderClipboardModel +{ + public FolderClipboardModel(Folder folder) + { + FolderEntity = folder.FolderEntity; + Folders = new List(); + Layers = new List(); + foreach (Folder allFolder in folder.GetAllFolders()) + Folders.Add(allFolder.FolderEntity); + foreach (Layer allLayer in folder.GetAllLayers()) + Layers.Add(allLayer.LayerEntity); + } + + // ReSharper disable once UnusedMember.Global - For JSON.NET + public FolderClipboardModel() + { + FolderEntity = null; + Folders = new List(); + Layers = new List(); + } + + public FolderEntity? FolderEntity { get; set; } + public List Folders { get; set; } + public List Layers { get; set; } + public bool HasBeenPasted { get; set; } + + public Folder Paste(Profile profile, ProfileElement parent) + { + if (FolderEntity == null) + throw new ArtemisUIException("Couldn't paste folder because FolderEntity deserialized as null"); + if (HasBeenPasted) + throw new ArtemisUIException("Clipboard model can only be pasted once"); + + HasBeenPasted = true; + + // Generate new GUIDs + ReplaceGuid(FolderEntity); + foreach (FolderEntity folderEntity in Folders) + ReplaceGuid(folderEntity); + foreach (LayerEntity layerEntity in Layers) + ReplaceGuid(layerEntity); + + // Inject the pasted elements into the profile + profile.ProfileEntity.Folders.AddRange(Folders); + profile.ProfileEntity.Layers.AddRange(Layers); + + // Let the folder initialize and load as usual + FolderEntity.Name += " - copy"; + Folder folder = new(profile, parent, FolderEntity); + return folder; + } + + private void ReplaceGuid(RenderElementEntity parent) + { + Guid old = parent.Id; + parent.Id = Guid.NewGuid(); + + foreach (FolderEntity child in Folders) + { + if (child.ParentId == old) + child.ParentId = parent.Id; + } + + foreach (LayerEntity child in Layers) + { + if (child.ParentId == old) + child.ParentId = parent.Id; + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Models/WindowSize.cs b/src/Artemis.UI/Models/WindowSize.cs new file mode 100644 index 000000000..8879d8c33 --- /dev/null +++ b/src/Artemis.UI/Models/WindowSize.cs @@ -0,0 +1,72 @@ +using System; +using Avalonia; +using Avalonia.Controls; + +namespace Artemis.UI.Models; + +public class WindowSize +{ + private bool _applying; + public int Top { get; set; } + public int Left { get; set; } + public double Width { get; set; } + public double Height { get; set; } + public int MaximizedTop { get; set; } + public int MaximizedLeft { get; set; } + public double MaximizedWidth { get; set; } + public double MaximizedHeight { get; set; } + public bool IsMaximized { get; set; } + + public void ApplyFromWindow(Window window) + { + if (_applying) + return; + + if (double.IsNaN(window.Width) || double.IsNaN(window.Height)) + return; + + IsMaximized = window.WindowState == WindowState.Maximized; + if (IsMaximized) + { + MaximizedTop = window.Position.Y; + MaximizedLeft = window.Position.X; + MaximizedHeight = window.Height; + MaximizedWidth = window.Width; + } + else + { + Top = window.Position.Y; + Left = window.Position.X; + Height = window.Height; + Width = window.Width; + } + } + + public void ApplyToWindow(Window window) + { + if (_applying) + return; + + try + { + // The -8 seems to be a FluentAvalonia thing? + _applying = true; + if (IsMaximized) + { + window.Position = new PixelPoint(Math.Max(-8, MaximizedLeft), Math.Max(-8, MaximizedTop)); + window.WindowState = WindowState.Maximized; + } + else + { + window.Position = new PixelPoint(Math.Max(-8, Left), Math.Max(-8, Top)); + window.Height = Height; + window.Width = Width; + window.WindowState = WindowState.Normal; + } + } + finally + { + _applying = false; + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Ninject/UiModule.cs b/src/Artemis.UI/Ninject/UiModule.cs index 0c9816eb7..41a355fbb 100644 --- a/src/Artemis.UI/Ninject/UiModule.cs +++ b/src/Artemis.UI/Ninject/UiModule.cs @@ -2,8 +2,10 @@ using Artemis.UI.Ninject.Factories; using Artemis.UI.Ninject.InstanceProviders; using Artemis.UI.Screens; +using Artemis.UI.Screens.VisualScripting; using Artemis.UI.Services.Interfaces; using Artemis.UI.Shared; +using Artemis.UI.Shared.Services.NodeEditor; using Artemis.UI.Shared.Services.ProfileEditor; using Avalonia.Platform; using Avalonia.Shared.PlatformSupport; @@ -57,6 +59,7 @@ public class UIModule : NinjectModule .BindToFactory(); }); + Kernel.Bind().To(); Kernel.Bind().ToFactory(() => new LayerPropertyViewModelInstanceProvider()); // Bind all UI services as singletons diff --git a/src/Artemis.UI/Screens/Home/HomeView.axaml b/src/Artemis.UI/Screens/Home/HomeView.axaml index 650adab1d..a20f0df65 100644 --- a/src/Artemis.UI/Screens/Home/HomeView.axaml +++ b/src/Artemis.UI/Screens/Home/HomeView.axaml @@ -70,7 +70,7 @@ - + GitHub diff --git a/src/Artemis.UI/Screens/ProfileEditor/Panels/DisplayCondition/ConditionTypes/EventConditionViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/Panels/DisplayCondition/ConditionTypes/EventConditionViewModel.cs index 916cf53b7..570c2391c 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Panels/DisplayCondition/ConditionTypes/EventConditionViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/Panels/DisplayCondition/ConditionTypes/EventConditionViewModel.cs @@ -16,7 +16,6 @@ namespace Artemis.UI.Screens.ProfileEditor.DisplayCondition.ConditionTypes; public class EventConditionViewModel : ActivatableViewModelBase { private readonly EventCondition _eventCondition; - private readonly INodeService _nodeService; private readonly IProfileEditorService _profileEditorService; private readonly ISettingsService _settingsService; private readonly ObservableAsPropertyHelper _showOverlapOptions; @@ -27,11 +26,10 @@ public class EventConditionViewModel : ActivatableViewModelBase private ObservableAsPropertyHelper? _selectedToggleOffMode; private ObservableAsPropertyHelper? _selectedTriggerMode; - public EventConditionViewModel(EventCondition eventCondition, IProfileEditorService profileEditorService, INodeService nodeService, IWindowService windowService, ISettingsService settingsService) + public EventConditionViewModel(EventCondition eventCondition, IProfileEditorService profileEditorService, IWindowService windowService, ISettingsService settingsService) { _eventCondition = eventCondition; _profileEditorService = profileEditorService; - _nodeService = nodeService; _windowService = windowService; _settingsService = settingsService; _showOverlapOptions = this.WhenAnyValue(vm => vm.SelectedTriggerMode).Select(m => m == 0).ToProperty(this, vm => vm.ShowOverlapOptions); @@ -45,7 +43,7 @@ public class EventConditionViewModel : ActivatableViewModelBase _selectedToggleOffMode = eventCondition.WhenAnyValue(c => c.OverlapMode).Select(m => (int) m).ToProperty(this, vm => vm.SelectedOverlapMode).DisposeWith(d); }); - OpenEditor = ReactiveCommand.CreateFromTask(ExecuteOpenEditor); + OpenEditor = ReactiveCommand.CreateFromTask(ExecuteOpenEditor, this.WhenAnyValue(vm => vm.EventPath).Select(p => p != null)); } public ReactiveCommand OpenEditor { get; } diff --git a/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/PropertiesViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/PropertiesViewModel.cs index 0c172200c..6471d49c8 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/PropertiesViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/PropertiesViewModel.cs @@ -76,18 +76,23 @@ public class PropertiesViewModel : ActivatableViewModelBase }); // Subscribe to events of the latest selected profile element - borrowed from https://stackoverflow.com/a/63950940 - this.WhenAnyValue(vm => vm.ProfileElement) - .Select(p => p is Layer l - ? Observable.FromEventPattern(x => l.LayerBrushUpdated += x, x => l.LayerBrushUpdated -= x) - : Observable.Never>()) - .Switch() - .Subscribe(_ => UpdatePropertyGroups()); - this.WhenAnyValue(vm => vm.ProfileElement) - .Select(p => p != null - ? Observable.FromEventPattern(x => p.LayerEffectsUpdated += x, x => p.LayerEffectsUpdated -= x) - : Observable.Never>()) - .Switch() - .Subscribe(_ => UpdatePropertyGroups()); + this.WhenActivated(d => + { + this.WhenAnyValue(vm => vm.ProfileElement) + .Select(p => p is Layer l + ? Observable.FromEventPattern(x => l.LayerBrushUpdated += x, x => l.LayerBrushUpdated -= x) + : Observable.Never>()) + .Switch() + .Subscribe(_ => UpdatePropertyGroups()) + .DisposeWith(d); + this.WhenAnyValue(vm => vm.ProfileElement) + .Select(p => p != null + ? Observable.FromEventPattern(x => p.LayerEffectsUpdated += x, x => p.LayerEffectsUpdated -= x) + : Observable.Never>()) + .Switch() + .Subscribe(_ => UpdatePropertyGroups()) + .DisposeWith(d); + }); this.WhenAnyValue(vm => vm.ProfileElement).Subscribe(_ => UpdatePropertyGroups()); this.WhenAnyValue(vm => vm.LayerProperty).Subscribe(_ => UpdateTimelineViewModel()); } diff --git a/src/Artemis.UI/Screens/Root/RootViewModel.cs b/src/Artemis.UI/Screens/Root/RootViewModel.cs index f34d77f50..10481f25c 100644 --- a/src/Artemis.UI/Screens/Root/RootViewModel.cs +++ b/src/Artemis.UI/Screens/Root/RootViewModel.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using Artemis.Core; using Artemis.Core.Services; +using Artemis.UI.Models; using Artemis.UI.Ninject.Factories; using Artemis.UI.Screens.Sidebar; using Artemis.UI.Services.Interfaces; @@ -44,7 +45,8 @@ public class RootViewModel : ActivatableViewModelBase, IScreen, IMainWindowProvi ISidebarVmFactory sidebarVmFactory) { Router = new RoutingState(); - + WindowSizeSetting = settingsService.GetSetting("WindowSize"); + _coreService = coreService; _settingsService = settingsService; _windowService = windowService; @@ -54,7 +56,7 @@ public class RootViewModel : ActivatableViewModelBase, IScreen, IMainWindowProvi _defaultTitleBarViewModel = defaultTitleBarViewModel; _sidebarVmFactory = sidebarVmFactory; _lifeTime = (IClassicDesktopStyleApplicationLifetime) Application.Current!.ApplicationLifetime!; - + mainWindowService.ConfigureMainWindowProvider(this); DisplayAccordingToSettings(); @@ -90,6 +92,7 @@ public class RootViewModel : ActivatableViewModelBase, IScreen, IMainWindowProvi private void CurrentMainWindowOnClosing(object? sender, EventArgs e) { + WindowSizeSetting.Save(); _lifeTime.MainWindow = null; SidebarViewModel = null; Router.NavigateAndReset.Execute(new EmptyViewModel(this, "blank")).Subscribe(); @@ -121,6 +124,8 @@ public class RootViewModel : ActivatableViewModelBase, IScreen, IMainWindowProvi /// public RoutingState Router { get; } + public static PluginSetting? WindowSizeSetting { get; private set; } + #region Tray commands public void OpenScreen(string displayName) @@ -170,7 +175,6 @@ public class RootViewModel : ActivatableViewModelBase, IScreen, IMainWindowProvi _lifeTime.MainWindow.Closing += CurrentMainWindowOnClosing; } - _lifeTime.MainWindow.WindowState = WindowState.Normal; _lifeTime.MainWindow.Activate(); OnMainWindowOpened(); } @@ -226,6 +230,11 @@ public class RootViewModel : ActivatableViewModelBase, IScreen, IMainWindowProvi } #endregion + + public void SaveWindowBounds(int x, int y, int width, int height) + { + throw new NotImplementedException(); + } } internal class EmptyViewModel : MainScreenViewModel diff --git a/src/Artemis.UI/Screens/Sidebar/ContentDialogs/SidebarCategoryEditView.axaml b/src/Artemis.UI/Screens/Sidebar/ContentDialogs/SidebarCategoryEditView.axaml index ecc53c82e..351b50f79 100644 --- a/src/Artemis.UI/Screens/Sidebar/ContentDialogs/SidebarCategoryEditView.axaml +++ b/src/Artemis.UI/Screens/Sidebar/ContentDialogs/SidebarCategoryEditView.axaml @@ -2,12 +2,14 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:sidebar="clr-namespace:Artemis.UI.Screens.Sidebar" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" - x:Class="Artemis.UI.Screens.Sidebar.SidebarCategoryEditView"> + x:Class="Artemis.UI.Screens.Sidebar.SidebarCategoryEditView" + x:DataType="sidebar:SidebarCategoryEditViewModel"> - + - + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/ContentDialogs/SidebarCategoryEditViewModel.cs b/src/Artemis.UI/Screens/Sidebar/ContentDialogs/SidebarCategoryEditViewModel.cs index 0ea351049..f08a25c5e 100644 --- a/src/Artemis.UI/Screens/Sidebar/ContentDialogs/SidebarCategoryEditViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/ContentDialogs/SidebarCategoryEditViewModel.cs @@ -23,13 +23,9 @@ public class SidebarCategoryEditViewModel : ContentDialogViewModelBase _categoryName = _category.Name; Confirm = ReactiveCommand.Create(ExecuteConfirm, ValidationContext.Valid); - Delete = ReactiveCommand.Create(ExecuteDelete); - this.ValidationRule(vm => vm.CategoryName, categoryName => !string.IsNullOrWhiteSpace(categoryName), "You must specify a valid name"); } - public ReactiveCommand Delete { get; set; } - public string? CategoryName { get => _categoryName; @@ -52,10 +48,4 @@ public class SidebarCategoryEditViewModel : ContentDialogViewModelBase ContentDialog?.Hide(ContentDialogResult.Primary); } - - private void ExecuteDelete() - { - if (_category != null) - _profileService.DeleteProfileCategory(_category); - } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditView.axaml b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditView.axaml index 2a62a5741..fed01edac 100644 --- a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditView.axaml +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditView.axaml @@ -76,18 +76,20 @@ - - + + - + - + - - - - + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs index b0f21fef7..ed55d14c3 100644 --- a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs @@ -63,7 +63,7 @@ public class ProfileConfigurationEditViewModel : DialogViewModelBase( pluginManagementService.GetFeaturesOfType().Where(m => !m.IsAlwaysAvailable).Select(m => new ProfileModuleViewModel(m)) ); @@ -75,7 +75,6 @@ public class ProfileConfigurationEditViewModel : DialogViewModelBase Import { get; } public ReactiveCommand Delete { get; } public ReactiveCommand Cancel { get; } - - private async Task ExecuteImport() - { - if (!IsNew) - return; - - string[]? result = await _windowService.CreateOpenFileDialog() - .HavingFilter(f => f.WithExtension("json").WithName("Artemis profile")) - .ShowAsync(); - - if (result == null) - return; - - string json = await File.ReadAllTextAsync(result[0]); - ProfileConfigurationExportModel? profileConfigurationExportModel = null; - try - { - profileConfigurationExportModel = JsonConvert.DeserializeObject(json, IProfileService.ExportSettings); - } - catch (JsonException e) - { - _windowService.ShowExceptionDialog("Import profile failed", e); - } - - if (profileConfigurationExportModel == null) - { - await _windowService.ShowConfirmContentDialog("Import profile", "Failed to import this profile, make sure it is a valid Artemis profile.", "Confirm", null); - return; - } - - try - { - ProfileConfiguration profileConfiguration = _profileService.ImportProfile(_profileCategory, profileConfigurationExportModel); - - // Remove the temporary profile configuration - _profileService.RemoveProfileConfiguration(_profileConfiguration); - - Close(profileConfiguration); - } - catch (Exception e) - { - _windowService.ShowExceptionDialog("Import profile failed", e); - } - } - + private async Task ExecuteDelete() { if (IsNew) diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarCategoryView.axaml b/src/Artemis.UI/Screens/Sidebar/SidebarCategoryView.axaml index 774ecabbe..18af04054 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarCategoryView.axaml +++ b/src/Artemis.UI/Screens/Sidebar/SidebarCategoryView.axaml @@ -14,15 +14,61 @@ x:DataType="local:SidebarCategoryViewModel"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + @@ -89,33 +135,14 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - + + - - + - + Empty category + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs b/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs index 36f373bda..101ed0908 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.IO; using System.Linq; using System.Reactive; using System.Reactive.Disposables; @@ -15,6 +16,7 @@ using Artemis.UI.Shared.Services.Builders; using Artemis.UI.Shared.Services.ProfileEditor; using DynamicData; using DynamicData.Binding; +using Newtonsoft.Json; using ReactiveUI; namespace Artemis.UI.Screens.Sidebar; @@ -53,9 +55,11 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase ToggleCollapsed = ReactiveCommand.Create(ExecuteToggleCollapsed); ToggleSuspended = ReactiveCommand.Create(ExecuteToggleSuspended); AddProfile = ReactiveCommand.CreateFromTask(ExecuteAddProfile); - EditCategory = ReactiveCommand.CreateFromTask(ExecuteEditCategory); + ImportProfile = ReactiveCommand.CreateFromTask(ExecuteImportProfile); MoveUp = ReactiveCommand.Create(ExecuteMoveUp); MoveDown = ReactiveCommand.Create(ExecuteMoveDown); + RenameCategory = ReactiveCommand.CreateFromTask(ExecuteRenameCategory); + DeleteCategory = ReactiveCommand.CreateFromTask(ExecuteDeleteCategory); this.WhenActivated(d => { @@ -67,14 +71,33 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase .Subscribe(e => profileConfigurations.RemoveMany(profileConfigurations.Items.Where(c => c == e.EventArgs.ProfileConfiguration))) .DisposeWith(d); - profileEditorService.ProfileConfiguration.Subscribe(p => SelectedProfileConfiguration = ProfileConfigurations.FirstOrDefault(c => ReferenceEquals(c.ProfileConfiguration, p))) + profileEditorService.ProfileConfiguration + .Subscribe(p => SelectedProfileConfiguration = ProfileConfigurations.FirstOrDefault(c => ReferenceEquals(c.ProfileConfiguration, p))) .DisposeWith(d); _isCollapsed = ProfileCategory.WhenAnyValue(vm => vm.IsCollapsed).ToProperty(this, vm => vm.IsCollapsed).DisposeWith(d); _isSuspended = ProfileCategory.WhenAnyValue(vm => vm.IsSuspended).ToProperty(this, vm => vm.IsSuspended).DisposeWith(d); // Change the current profile configuration when a new one is selected - this.WhenAnyValue(vm => vm.SelectedProfileConfiguration).WhereNotNull().Subscribe(s => profileEditorService.ChangeCurrentProfileConfiguration(s.ProfileConfiguration)); + this.WhenAnyValue(vm => vm.SelectedProfileConfiguration) + .WhereNotNull() + .Subscribe(s => + { + try + { + profileEditorService.ChangeCurrentProfileConfiguration(s.ProfileConfiguration); + } + catch (Exception e) + { + if (s.ProfileConfiguration.BrokenState != null && s.ProfileConfiguration.BrokenStateException != null) + _windowService.ShowExceptionDialog(s.ProfileConfiguration.BrokenState, s.ProfileConfiguration.BrokenStateException); + else + _windowService.ShowExceptionDialog(e.Message, e); + + profileEditorService.ChangeCurrentProfileConfiguration(null); + SelectedProfileConfiguration = null; + } + }); }); profileConfigurations.Edit(updater => @@ -84,12 +107,14 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase }); } + public ReactiveCommand ImportProfile { get; } public ReactiveCommand ToggleCollapsed { get; } public ReactiveCommand ToggleSuspended { get; } public ReactiveCommand AddProfile { get; } - public ReactiveCommand EditCategory { get; } public ReactiveCommand MoveUp { get; } public ReactiveCommand MoveDown { get; } + public ReactiveCommand RenameCategory { get; } + public ReactiveCommand DeleteCategory { get; } public ProfileCategory ProfileCategory { get; } public ReadOnlyObservableCollection ProfileConfigurations { get; } @@ -114,13 +139,12 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase _profileService.SaveProfileCategory(oldCategory); } - private async Task ExecuteEditCategory() + private async Task ExecuteRenameCategory() { await _windowService.CreateContentDialog() .WithTitle("Edit category") .WithViewModel(out SidebarCategoryEditViewModel vm, ("category", ProfileCategory)) .HavingPrimaryButton(b => b.WithText("Confirm").WithCommand(vm.Confirm)) - .HavingSecondaryButton(b => b.WithText("Delete").WithCommand(vm.Delete)) .WithCloseButtonText("Cancel") .WithDefaultButton(ContentDialogButton.Primary) .ShowAsync(); @@ -128,6 +152,12 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase _sidebarViewModel.UpdateProfileCategories(); } + private async Task ExecuteDeleteCategory() + { + if (await _windowService.ShowConfirmContentDialog($"Delete {ProfileCategory.Name}", "Do you want to delete this category and all its profiles?")) + _profileService.DeleteProfileCategory(ProfileCategory); + } + private async Task ExecuteAddProfile() { ProfileConfiguration? result = await _windowService.ShowDialogAsync( @@ -141,6 +171,42 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase } } + private async Task ExecuteImportProfile() + { + string[]? result = await _windowService.CreateOpenFileDialog() + .HavingFilter(f => f.WithExtension("json").WithName("Artemis profile")) + .ShowAsync(); + + if (result == null) + return; + + string json = await File.ReadAllTextAsync(result[0]); + ProfileConfigurationExportModel? profileConfigurationExportModel = null; + try + { + profileConfigurationExportModel = JsonConvert.DeserializeObject(json, IProfileService.ExportSettings); + } + catch (JsonException e) + { + _windowService.ShowExceptionDialog("Import profile failed", e); + } + + if (profileConfigurationExportModel == null) + { + await _windowService.ShowConfirmContentDialog("Import profile", "Failed to import this profile, make sure it is a valid Artemis profile.", "Confirm", null); + return; + } + + try + { + _profileService.ImportProfile(ProfileCategory, profileConfigurationExportModel); + } + catch (Exception e) + { + _windowService.ShowExceptionDialog("Import profile failed", e); + } + } + private void ExecuteToggleCollapsed() { ProfileCategory.IsCollapsed = !ProfileCategory.IsCollapsed; diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationView.axaml b/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationView.axaml index 2abedf391..23d1a1221 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationView.axaml +++ b/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationView.axaml @@ -9,13 +9,14 @@ x:Class="Artemis.UI.Screens.Sidebar.SidebarProfileConfigurationView" x:DataType="sidebar:SidebarProfileConfigurationViewModel" Background="Transparent"> - - - + + + + @@ -53,8 +54,24 @@ - - + + + + + + + + - diff --git a/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowViewModel.cs b/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowViewModel.cs index 19eefefb8..911835000 100644 --- a/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowViewModel.cs +++ b/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowViewModel.cs @@ -8,7 +8,6 @@ using System.Threading.Tasks; using Artemis.Core; using Artemis.Core.Services; using Artemis.UI.Ninject.Factories; -using Artemis.UI.Shared; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.NodeEditor; using Artemis.UI.Shared.Services.NodeEditor.Commands; @@ -20,7 +19,7 @@ using ReactiveUI; namespace Artemis.UI.Screens.VisualScripting; -public class NodeScriptWindowViewModel : DialogViewModelBase +public class NodeScriptWindowViewModel : NodeScriptWindowViewModelBase { private readonly INodeEditorService _nodeEditorService; private readonly INodeService _nodeService; @@ -34,7 +33,7 @@ public class NodeScriptWindowViewModel : DialogViewModelBase INodeVmFactory vmFactory, ISettingsService settingsService, IProfileService profileService, - IWindowService windowService) + IWindowService windowService) : base(nodeScript) { NodeScript = nodeScript; NodeScriptViewModel = vmFactory.NodeScriptViewModel(NodeScript, false); @@ -77,7 +76,6 @@ public class NodeScriptWindowViewModel : DialogViewModelBase }); } - public NodeScript NodeScript { get; } public NodeScriptViewModel NodeScriptViewModel { get; set; } public NodeEditorHistory History { get; } diff --git a/src/Artemis.VisualScripting/Converters/NumericConverter.cs b/src/Artemis.VisualScripting/Converters/NumericConverter.cs index 320250b7e..397b0564c 100644 --- a/src/Artemis.VisualScripting/Converters/NumericConverter.cs +++ b/src/Artemis.VisualScripting/Converters/NumericConverter.cs @@ -15,7 +15,8 @@ public class NumericConverter : IValueConverter if (value is not Numeric numeric) return value; - return Numeric.IsTypeCompatible(targetType) ? numeric.ToType(targetType, NumberFormatInfo.InvariantInfo) : value; + object result = Numeric.IsTypeCompatible(targetType) ? numeric.ToType(targetType, NumberFormatInfo.InvariantInfo) : value; + return result; } /// diff --git a/src/Artemis.VisualScripting/Nodes/DataModel/DataModelEventNode.cs b/src/Artemis.VisualScripting/Nodes/DataModel/DataModelEventNode.cs index 054977e8b..9dec85f4c 100644 --- a/src/Artemis.VisualScripting/Nodes/DataModel/DataModelEventNode.cs +++ b/src/Artemis.VisualScripting/Nodes/DataModel/DataModelEventNode.cs @@ -1,29 +1,25 @@ -using System.Linq.Expressions; -using System.Reflection; -using Artemis.Core; -using Artemis.Core.Modules; +using Artemis.Core; using Artemis.Storage.Entities.Profile; using Artemis.VisualScripting.Nodes.DataModel.Screens; -using Humanizer; namespace Artemis.VisualScripting.Nodes.DataModel; [Node("Data Model-Event", "Outputs the latest values of a data model event.", "Data Model", OutputType = typeof(object))] public class DataModelEventNode : Node, IDisposable { - private readonly Dictionary, OutputPin> _propertyPins; - private DataModelPath? _dataModelPath; + private readonly ObjectOutputPins _objectOutputPins; private IDataModelEvent? _dataModelEvent; - private OutputPin? _oldValuePin; - private OutputPin? _newValuePin; + private DataModelPath? _dataModelPath; private DateTime _lastTrigger; private object? _lastValue; + private OutputPin? _newValuePin; + private OutputPin? _oldValuePin; private int _valueChangeCount; public DataModelEventNode() : base("Data Model-Event", "Outputs the latest values of a data model event.") { - _propertyPins = new Dictionary, OutputPin>(); - + _objectOutputPins = new ObjectOutputPins(this); + TimeSinceLastTrigger = CreateOutputPin("Time since trigger"); TriggerCount = CreateOutputPin("Trigger count"); @@ -48,20 +44,14 @@ public class DataModelEventNode : Node propertyAccessor, OutputPin outputPin) in _propertyPins) - { - if (!outputPin.ConnectedTo.Any()) - continue; - object value = dataModelEvent.LastEventArgumentsUntyped != null ? propertyAccessor(dataModelEvent.LastEventArgumentsUntyped) : outputPin.Type.GetDefault()!; - outputPin.Value = outputPin.IsNumeric ? new Numeric(value) : value; - } + _objectOutputPins.SetCurrentValue(dataModelEvent.LastEventArgumentsUntyped); } // If the path is a regular value, evaluate the current value else if (_oldValuePin != null && _newValuePin != null) @@ -71,13 +61,13 @@ public class DataModelEventNode : Node p.CustomAttributes.All(a => a.AttributeType != typeof(DataModelIgnoreAttribute)))) - { - // Expect an IDataModelEvent - ParameterExpression eventParameter = Expression.Parameter(typeof(DataModelEventArgs), "event"); - // Cast it to the actual event type - UnaryExpression eventCast = Expression.Convert(eventParameter, propertyInfo.DeclaringType!); - // Access the property - MemberExpression accessor = Expression.Property(eventCast, propertyInfo); - // Cast the property to an object (sadly boxing) - UnaryExpression objectCast = Expression.Convert(accessor, typeof(object)); - // Compile the resulting expression - Func expression = Expression.Lambda>(objectCast, eventParameter).Compile(); - - _propertyPins.Add(expression, CreateOrAddOutputPin(propertyInfo.PropertyType, propertyInfo.Name.Humanize())); - } + _objectOutputPins.ChangeType(dataModelEvent.ArgumentsType); } private void CreateValuePins() @@ -142,24 +118,31 @@ public class DataModelEventNode : Node pins = Pins.Skip(2).ToList(); - foreach (IPin pin in pins) - RemovePin((Pin) pin); - - _propertyPins.Clear(); - _oldValuePin = null; - _newValuePin = null; + // Clear the output pins by changing the type to null + _objectOutputPins.ChangeType(null); + + if (_oldValuePin != null) + { + RemovePin(_oldValuePin); + _oldValuePin = null; + } + + if (_newValuePin != null) + { + RemovePin(_newValuePin); + _newValuePin = null; + } } - + private void DataModelPathOnPathValidated(object? sender, EventArgs e) { // Update the output pin now that the type is known and attempt to restore the connection that was likely missing diff --git a/src/Artemis.VisualScripting/Nodes/List/ListOperatorEntity.cs b/src/Artemis.VisualScripting/Nodes/List/ListOperatorEntity.cs new file mode 100644 index 000000000..515f8be2e --- /dev/null +++ b/src/Artemis.VisualScripting/Nodes/List/ListOperatorEntity.cs @@ -0,0 +1,9 @@ +using Artemis.Storage.Entities.Profile.Nodes; + +namespace Artemis.VisualScripting.Nodes.List; + +public class ListOperatorEntity +{ + public NodeScriptEntity? Script { get; set; } + public ListOperator Operator { get; set; } +} \ No newline at end of file diff --git a/src/Artemis.VisualScripting/Nodes/List/ListOperatorNode.cs b/src/Artemis.VisualScripting/Nodes/List/ListOperatorNode.cs index 7a7d4e056..29ee2391a 100644 --- a/src/Artemis.VisualScripting/Nodes/List/ListOperatorNode.cs +++ b/src/Artemis.VisualScripting/Nodes/List/ListOperatorNode.cs @@ -4,37 +4,37 @@ using Artemis.VisualScripting.Nodes.List.Screens; namespace Artemis.VisualScripting.Nodes.List; -[Node("List Operator (Simple)", "Checks if any/all/no value in the input list matches the input value", "List", InputType = typeof(IEnumerable), OutputType = typeof(bool))] +[Node("List Operator (Simple)", "Checks if any/all/no values in the input list match the input value", "List", InputType = typeof(IEnumerable), OutputType = typeof(bool))] public class ListOperatorNode : Node { - public ListOperatorNode() : base("List Operator", "Checks if any/all/no value in the input list matches the input value") + public ListOperatorNode() : base("List Operator (Simple)", "Checks if any/all/no values in the input list match the input value") { InputList = CreateInputPin(); InputValue = CreateInputPin(); - Ouput = CreateOutputPin(); + Output = CreateOutputPin(); } public InputPin InputList { get; } public InputPin InputValue { get; } - public OutputPin Ouput { get; } + public OutputPin Output { get; } /// public override void Evaluate() { if (InputList.Value == null) { - Ouput.Value = Storage == ListOperator.None; + Output.Value = Storage == ListOperator.None; return; } object? input = InputValue.Value; if (Storage == ListOperator.Any) - Ouput.Value = InputList.Value.Cast().Any(v => v.Equals(input)); + Output.Value = InputList.Value.Cast().Any(v => v.Equals(input)); else if (Storage == ListOperator.All) - Ouput.Value = InputList.Value.Cast().All(v => v.Equals(input)); - else if (Storage == ListOperator.All) - Ouput.Value = InputList.Value.Cast().All(v => !v.Equals(input)); + Output.Value = InputList.Value.Cast().All(v => v.Equals(input)); + else if (Storage == ListOperator.None) + Output.Value = InputList.Value.Cast().All(v => !v.Equals(input)); } } diff --git a/src/Artemis.VisualScripting/Nodes/List/ListOperatorPredicateNode.cs b/src/Artemis.VisualScripting/Nodes/List/ListOperatorPredicateNode.cs new file mode 100644 index 000000000..e6d811e88 --- /dev/null +++ b/src/Artemis.VisualScripting/Nodes/List/ListOperatorPredicateNode.cs @@ -0,0 +1,107 @@ +using System.Collections; +using Artemis.Core; +using Artemis.Core.Events; +using Artemis.VisualScripting.Nodes.List.Screens; + +namespace Artemis.VisualScripting.Nodes.List; + +[Node("List Operator (Advanced)", "Checks if any/all/no values in the input list match a condition", "List", InputType = typeof(IEnumerable), OutputType = typeof(bool))] +public class ListOperatorPredicateNode : Node, IDisposable +{ + private readonly object _scriptLock = new(); + private ListOperatorPredicateStartNode _startNode; + + public ListOperatorPredicateNode() : base("List Operator (Advanced)", "Checks if any/all/no values in the input list match a condition") + { + _startNode = new ListOperatorPredicateStartNode {X = -200}; + + InputList = CreateInputPin(); + Output = CreateOutputPin(); + + InputList.PinConnected += InputListOnPinConnected; + } + + public InputPin InputList { get; } + public OutputPin Output { get; } + public NodeScript? Script { get; private set; } + + public override void Initialize(INodeScript script) + { + Storage ??= new ListOperatorEntity(); + + lock (_scriptLock) + { + Script = Storage?.Script != null + ? new NodeScript("Is match", "Determines whether the current list item is a match", Storage.Script, script.Context, new List {_startNode}) + : new NodeScript("Is match", "Determines whether the current list item is a match", script.Context, new List {_startNode}); + } + } + + /// + public override void Evaluate() + { + if (Storage == null) + return; + + if (InputList.Value == null) + { + Output.Value = Storage.Operator == ListOperator.None; + return; + } + + lock (_scriptLock) + { + if (Script == null) + return; + + if (Storage.Operator == ListOperator.Any) + Output.Value = InputList.Value.Cast().Any(EvaluateItem); + else if (Storage.Operator == ListOperator.All) + Output.Value = InputList.Value.Cast().All(EvaluateItem); + else if (Storage.Operator == ListOperator.None) + Output.Value = InputList.Value.Cast().All(v => !EvaluateItem(v)); + } + } + + private bool EvaluateItem(object item) + { + if (Script == null || _startNode == null) + return false; + + _startNode.Item = item; + Script.Run(); + return Script.Result; + } + + private void UpdateStartNode() + { + Type? type = InputList.ConnectedTo.FirstOrDefault()?.Type; + // List must be generic or there's no way to tell what objects it contains in advance, that's not supported for now + if (type is not {IsGenericType: true}) + return; + + Type listType = type.GetGenericArguments().Single(); + _startNode?.ChangeType(listType); + } + + private void InputListOnPinConnected(object? sender, SingleValueEventArgs e) + { + lock (_scriptLock) + { + UpdateStartNode(); + Script?.LoadConnections(); + } + } + + #region IDisposable + + /// + public void Dispose() + { + Script?.Dispose(); + Script = null; + _startNode = null; + } + + #endregion +} \ No newline at end of file diff --git a/src/Artemis.VisualScripting/Nodes/List/ListOperatorPredicateStartNode.cs b/src/Artemis.VisualScripting/Nodes/List/ListOperatorPredicateStartNode.cs new file mode 100644 index 000000000..ca37fb237 --- /dev/null +++ b/src/Artemis.VisualScripting/Nodes/List/ListOperatorPredicateStartNode.cs @@ -0,0 +1,27 @@ +using Artemis.Core; + +namespace Artemis.VisualScripting.Nodes.List; + +public class ListOperatorPredicateStartNode : DefaultNode +{ + internal static readonly Guid NodeId = new("9A714CF3-8D02-4CC3-A1AC-73833F82D7C6"); + private readonly ObjectOutputPins _objectOutputPins; + + public ListOperatorPredicateStartNode() : base(NodeId, "List item", "Contains the current list item") + { + _objectOutputPins = new ObjectOutputPins(this); + } + + public object? Item { get; set; } + + public override void Evaluate() + { + if (Item != null) + _objectOutputPins.SetCurrentValue(Item); + } + + public void ChangeType(Type? type) + { + _objectOutputPins.ChangeType(type); + } +} \ No newline at end of file diff --git a/src/Artemis.VisualScripting/Nodes/List/Screens/ListOperatorPredicateNodeCustomView.axaml b/src/Artemis.VisualScripting/Nodes/List/Screens/ListOperatorPredicateNodeCustomView.axaml new file mode 100644 index 000000000..dbc6728a7 --- /dev/null +++ b/src/Artemis.VisualScripting/Nodes/List/Screens/ListOperatorPredicateNodeCustomView.axaml @@ -0,0 +1,14 @@ + + + + + + diff --git a/src/Artemis.VisualScripting/Nodes/List/Screens/ListOperatorPredicateNodeCustomView.axaml.cs b/src/Artemis.VisualScripting/Nodes/List/Screens/ListOperatorPredicateNodeCustomView.axaml.cs new file mode 100644 index 000000000..ab950da39 --- /dev/null +++ b/src/Artemis.VisualScripting/Nodes/List/Screens/ListOperatorPredicateNodeCustomView.axaml.cs @@ -0,0 +1,19 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.VisualScripting.Nodes.List.Screens; + +public partial class ListOperatorPredicateNodeCustomView : ReactiveUserControl +{ + public ListOperatorPredicateNodeCustomView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.VisualScripting/Nodes/List/Screens/ListOperatorPredicateNodeCustomViewModel.cs b/src/Artemis.VisualScripting/Nodes/List/Screens/ListOperatorPredicateNodeCustomViewModel.cs new file mode 100644 index 000000000..b840e0e8f --- /dev/null +++ b/src/Artemis.VisualScripting/Nodes/List/Screens/ListOperatorPredicateNodeCustomViewModel.cs @@ -0,0 +1,75 @@ +using System.Reactive; +using System.Reactive.Disposables; +using Artemis.Core; +using Artemis.Core.Events; +using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Services.NodeEditor; +using Artemis.UI.Shared.VisualScripting; +using ReactiveUI; + +namespace Artemis.VisualScripting.Nodes.List.Screens; + +public class ListOperatorPredicateNodeCustomViewModel : CustomNodeViewModel +{ + private readonly ListOperatorPredicateNode _node; + private readonly IWindowService _windowService; + private ListOperator _operator; + private bool _canOpenEditor; + + public ListOperatorPredicateNodeCustomViewModel(ListOperatorPredicateNode node, INodeScript script, IWindowService windowService) : base(node, script) + { + _node = node; + _windowService = windowService; + + OpenEditor = ReactiveCommand.CreateFromTask(ExecuteOpenEditor, this.WhenAnyValue(vm => vm.CanOpenEditor)); + CanOpenEditor = node.InputList.ConnectedTo.Any(); + + this.WhenActivated(d => + { + node.InputList.PinConnected += InputListOnPinConnected; + node.InputList.PinDisconnected += InputListOnPinDisconnected; + + Disposable.Create(() => + { + node.InputList.PinConnected -= InputListOnPinConnected; + node.InputList.PinDisconnected -= InputListOnPinDisconnected; + }).DisposeWith(d); + }); + } + + public ReactiveCommand OpenEditor { get; } + + private bool CanOpenEditor + { + get => _canOpenEditor; + set => this.RaiseAndSetIfChanged(ref _canOpenEditor, value); + } + + public ListOperator Operator + { + get => _operator; + set => this.RaiseAndSetIfChanged(ref _operator, value); + } + + private async Task ExecuteOpenEditor() + { + if (_node.Script == null) + return; + + await _windowService.ShowDialogAsync(("nodeScript", _node.Script)); + _node.Script.Save(); + + _node.Storage ??= new ListOperatorEntity(); + _node.Storage.Script = _node.Script.Entity; + } + + private void InputListOnPinDisconnected(object? sender, SingleValueEventArgs e) + { + CanOpenEditor = false; + } + + private void InputListOnPinConnected(object? sender, SingleValueEventArgs e) + { + CanOpenEditor = true; + } +} \ No newline at end of file diff --git a/src/Artemis.VisualScripting/Nodes/Static/Screens/StaticNumericValueNodeCustomView.axaml b/src/Artemis.VisualScripting/Nodes/Static/Screens/StaticNumericValueNodeCustomView.axaml index 46395bfde..e420ebbc4 100644 --- a/src/Artemis.VisualScripting/Nodes/Static/Screens/StaticNumericValueNodeCustomView.axaml +++ b/src/Artemis.VisualScripting/Nodes/Static/Screens/StaticNumericValueNodeCustomView.axaml @@ -12,9 +12,11 @@ - - - - + \ No newline at end of file