diff --git a/src/Artemis.Core/Models/Profile/DataModel/DataModelPath.cs b/src/Artemis.Core/Models/Profile/DataModel/DataModelPath.cs index 62ebd93bb..3bdb96137 100644 --- a/src/Artemis.Core/Models/Profile/DataModel/DataModelPath.cs +++ b/src/Artemis.Core/Models/Profile/DataModel/DataModelPath.cs @@ -191,7 +191,8 @@ public class DataModelPath : IStorageModel, IDisposable, IPluginFeatureDependent /// public IEnumerable GetFeatureDependencies() { - if (Target == null) + // Data models provided by Artemis itself are always available + if (Target == null || Target.Module.Plugin == Constants.CorePlugin) return []; return [Target.Module]; } diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationColorGradientItem.cs b/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationColorGradientItem.cs index 355d04cc8..b41d18e20 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationColorGradientItem.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationColorGradientItem.cs @@ -1,8 +1,71 @@ -namespace Artemis.Core; +using System; +using System.Collections.Specialized; + +namespace Artemis.Core; /// /// Represents a configuration item that accepts ColorGradient input from the user. /// public class ConfigurationColorGradientItem : ConfigurationInputItem { + /// + /// Creates a new instance of the class. + /// + public ConfigurationColorGradientItem() + { + // The gradient picker doesn't handle null values well + DefaultValue = ColorGradient.GetUnicornBarf(); + Value = ColorGradient.GetUnicornBarf(); + } + + /// + protected override ColorGradient DataModelFallbackValue => new(); + + /// + protected override bool IsSameValue(ColorGradient? a, ColorGradient? b) + { + // Gradients are mutable and compared by value, a different instance should always be treated as a change + return ReferenceEquals(a, b); + } + + /// + protected override ColorGradient? CopyValue(ColorGradient? value) + { + return value != null ? new ColorGradient(value) : null; + } + + /// + protected override void OnValueReplaced(ColorGradient? previous, ColorGradient? current) + { + // Gradients are edited in-place, forward those edits as a change of the property + if (previous != null) + { + previous.CollectionChanged -= GradientOnCollectionChanged; + previous.StopChanged -= GradientOnStopChanged; + } + + if (current != null) + { + current.CollectionChanged += GradientOnCollectionChanged; + current.StopChanged += GradientOnStopChanged; + } + } + + private void GradientOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + OnGradientEdited(sender); + } + + private void GradientOnStopChanged(object? sender, EventArgs e) + { + OnGradientEdited(sender); + } + + private void OnGradientEdited(object? sender) + { + if (ReferenceEquals(sender, Value)) + OnPropertyChanged(nameof(Value)); + if (ReferenceEquals(sender, DefaultValue)) + OnPropertyChanged(nameof(DefaultValue)); + } } \ No newline at end of file diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationDropdownValue.cs b/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationDropdownValue.cs index 59136d34b..9c58de080 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationDropdownValue.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationDropdownValue.cs @@ -1,10 +1,9 @@ -namespace Artemis.Core; +namespace Artemis.Core; /// /// Represents a single option in a configuration dropdown list. /// -/// The type of the value that this dropdown option represents. -public class ConfigurationDropdownValue : CorePropertyChanged +public abstract class ConfigurationDropdownValue : CorePropertyChanged { /// /// Gets or sets the display name shown to the user for this dropdown option. @@ -14,7 +13,14 @@ public class ConfigurationDropdownValue : CorePropertyChanged get; set => SetAndNotify(ref field, value); } +} +/// +/// Represents a single option in a configuration dropdown list. +/// +/// The type of the value that this dropdown option represents. +public class ConfigurationDropdownValue : ConfigurationDropdownValue +{ /// /// Gets or sets the actual value associated with this dropdown option. /// diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationInputItem.cs b/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationInputItem.cs index 5120e984d..b2e5eca79 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationInputItem.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationInputItem.cs @@ -1,13 +1,22 @@ -using System.Collections.ObjectModel; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; +using Artemis.Core.Modules; +using Artemis.Storage.Entities.Profile.Configuration; namespace Artemis.Core; /// -/// Represents a generic base class for configuration items that accept user input. +/// Represents a base class for configuration items that accept user input. /// -/// The type of the value that this configuration item holds. -public class ConfigurationInputItem : CorePropertyChanged, IConfigurationItem +public abstract class ConfigurationInputItem : CorePropertyChanged, IConfigurationItem { + /// + public Guid Id { get; init; } = Guid.NewGuid(); + /// /// Gets or sets the display name of the configuration item. /// @@ -27,36 +36,208 @@ public class ConfigurationInputItem : CorePropertyChanged, IConfigurationItem } /// - /// Gets or sets the current value of the configuration item. + /// Resets the value of the configuration item to a copy of its default value. + /// + public abstract void ResetToDefault(); + + internal abstract string? SerializeValue(); + internal abstract void DeserializeValue(string? json); + internal abstract void Load(ConfigurationInputItemEntity entity); + internal abstract void Save(ConfigurationInputItemEntity entity); + internal abstract void AddDynamicChild(DataModel dataModel, string key, DataModelPropertyAttribute attribute); + internal abstract void UpdateDynamicChild(DataModel dataModel, DynamicChild dynamicChild); +} + +/// +/// Represents a generic base class for configuration items that accept user input. +/// +/// The type of the value that this configuration item holds. +public abstract class ConfigurationInputItem : ConfigurationInputItem +{ + /// + /// Creates a new instance of the class. + /// + protected ConfigurationInputItem() + { + DropdownValues.CollectionChanged += DropdownValuesOnCollectionChanged; + } + + /// + /// Gets or sets the current value of the configuration item, as picked by the user. /// public T? Value { get; - set => SetAndNotify(ref field, value); + set + { + if (IsSameValue(field, value)) + return; + + T? previous = field; + field = value; + OnValueReplaced(previous, value); + OnPropertyChanged(); + } } /// - /// Gets or sets the default value of the configuration item. + /// Gets or sets the default value of the configuration item, as set by the author of the profile. /// public T? DefaultValue { get; - set => SetAndNotify(ref field, value); + set + { + if (IsSameValue(field, value)) + return; + + T? previous = field; + field = value; + OnValueReplaced(previous, value); + OnPropertyChanged(); + } } /// - /// Gets or sets the collection of dropdown values for this configuration item. + /// Gets the collection of dropdown values for this configuration item. /// When populated, the configuration item will be rendered as a dropdown/combo box. /// - public ObservableCollection>? DropdownValues + public ObservableCollection> DropdownValues { get; } = []; + + /// + /// Gets the value that is exposed to the data model when is . + /// + protected virtual T DataModelFallbackValue => default!; + + /// + public override void ResetToDefault() { - get; - set + Value = CopyValue(DefaultValue); + } + + /// + /// Determines whether two values are considered the same, if so no change notification is raised. + /// + protected virtual bool IsSameValue(T? a, T? b) + { + return EqualityComparer.Default.Equals(a, b); + } + + /// + /// Creates a copy of the provided value, override this for mutable reference types. + /// + protected virtual T? CopyValue(T? value) + { + return value; + } + + /// + /// Called when or is replaced by another value. + /// + protected virtual void OnValueReplaced(T? previous, T? current) + { + } + + // Forward changes to the dropdown values as a change of the property so that they are picked up like any other change + private void DropdownValuesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.OldItems != null) { - if (Equals(value, field)) - return; - field = value; - OnPropertyChanged(); + foreach (ConfigurationDropdownValue dropdownValue in e.OldItems) + dropdownValue.PropertyChanged -= DropdownValueOnPropertyChanged; } - } = []; -} \ No newline at end of file + + if (e.NewItems != null) + { + foreach (ConfigurationDropdownValue dropdownValue in e.NewItems) + dropdownValue.PropertyChanged += DropdownValueOnPropertyChanged; + } + + OnPropertyChanged(nameof(DropdownValues)); + } + + private void DropdownValueOnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + OnPropertyChanged(nameof(DropdownValues)); + } + + #region Storage + + internal override string? SerializeValue() + { + return SerializeValue(Value); + } + + internal override void DeserializeValue(string? json) + { + if (TryDeserializeValue(json, out T? value)) + Value = value; + else + ResetToDefault(); + } + + internal override void Load(ConfigurationInputItemEntity entity) + { + Name = entity.Name; + Description = entity.Description; + if (TryDeserializeValue(entity.DefaultValue, out T? defaultValue)) + DefaultValue = defaultValue; + + DropdownValues.Clear(); + foreach (ConfigurationDropdownValueEntity dropdownValueEntity in entity.DropdownValues) + { + if (TryDeserializeValue(dropdownValueEntity.Value, out T? dropdownValue) && dropdownValue != null) + DropdownValues.Add(new ConfigurationDropdownValue {DisplayName = dropdownValueEntity.DisplayName, Value = dropdownValue}); + } + } + + internal override void Save(ConfigurationInputItemEntity entity) + { + entity.Id = Id; + entity.Name = Name; + entity.Description = Description; + entity.DefaultValue = SerializeValue(DefaultValue); + entity.DropdownValues = DropdownValues + .Select(d => new ConfigurationDropdownValueEntity {DisplayName = d.DisplayName, Value = SerializeValue(d.Value)}) + .ToList(); + } + + private static string? SerializeValue(T? value) + { + return value == null ? null : CoreJson.Serialize(value); + } + + private static bool TryDeserializeValue(string? json, out T? value) + { + value = default; + if (json == null) + return false; + + try + { + value = CoreJson.Deserialize(json); + return true; + } + catch (Exception) + { + return false; + } + } + + #endregion + + #region Data model + + internal override void AddDynamicChild(DataModel dataModel, string key, DataModelPropertyAttribute attribute) + { + dataModel.AddDynamicChild(key, Value ?? DataModelFallbackValue, attribute); + } + + internal override void UpdateDynamicChild(DataModel dataModel, DynamicChild dynamicChild) + { + if (dynamicChild is DynamicChild typedDynamicChild) + typedDynamicChild.Value = Value ?? DataModelFallbackValue; + } + + #endregion +} diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationNumericItem.cs b/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationNumericItem.cs index 024008e79..e2ae6be6e 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationNumericItem.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationNumericItem.cs @@ -1,25 +1,48 @@ -namespace Artemis.Core; +using Artemis.Core.Modules; + +namespace Artemis.Core; /// /// Represents a configuration item that accepts numeric input from the user. /// public class ConfigurationNumericItem : ConfigurationInputItem { + /// + /// Gets or sets the minimum value the user can enter, or for no minimum. + /// public Numeric? Minimum { get; set => SetAndNotify(ref field, value); } + /// + /// Gets or sets the maximum value the user can enter, or for no maximum. + /// public Numeric? Maximum { get; set => SetAndNotify(ref field, value); } + /// + /// Gets or sets a boolean indicating whether the value is entered using a slider instead of a number box. + /// public bool Slider { get; set => SetAndNotify(ref field, value); } + + // Numerics are exposed to the data model as floats, like most other numeric data model values + internal override void AddDynamicChild(DataModel dataModel, string key, DataModelPropertyAttribute attribute) + { + dataModel.AddDynamicChild(key, (float) Value, attribute); + } + + internal override void UpdateDynamicChild(DataModel dataModel, DynamicChild dynamicChild) + { + if (dynamicChild is DynamicChild typedDynamicChild) + typedDynamicChild.Value = Value; + } } \ No newline at end of file diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationSection.cs b/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationSection.cs index 55db6faae..253c3108b 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationSection.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationSection.cs @@ -1,4 +1,6 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; +using System.Linq; +using Artemis.Storage.Entities.Profile.Configuration; namespace Artemis.Core; @@ -34,4 +36,68 @@ public class ConfigurationSection : CorePropertyChanged /// Gets the collection of configuration items in this section. /// public ObservableCollection Items { get; } = []; + + #region Storage + + internal static ConfigurationSection Load(ProfileConfiguration profileConfiguration, ConfigurationSectionEntity entity) + { + ConfigurationSection section = new(profileConfiguration) {Name = entity.Name}; + foreach (IConfigurationItemEntity itemEntity in entity.Items) + { + IConfigurationItem? item = LoadItem(itemEntity); + if (item != null) + section.Items.Add(item); + } + + return section; + } + + internal ConfigurationSectionEntity Save() + { + return new ConfigurationSectionEntity {Name = Name, Items = Items.Select(SaveItem).ToList()}; + } + + private static IConfigurationItem? LoadItem(IConfigurationItemEntity entity) + { + ConfigurationInputItem? inputItem = entity switch + { + ConfigurationStringItemEntity e => new ConfigurationStringItem {Id = e.Id, Name = e.Name}, + ConfigurationBooleanItemEntity e => new ConfigurationBooleanItem {Id = e.Id, Name = e.Name, TrueText = e.TrueText, FalseText = e.FalseText}, + ConfigurationNumericItemEntity e => new ConfigurationNumericItem {Id = e.Id, Name = e.Name, Minimum = e.Minimum, Maximum = e.Maximum, Slider = e.Slider}, + ConfigurationSKColorItemEntity e => new ConfigurationSKColorItem {Id = e.Id, Name = e.Name}, + ConfigurationColorGradientItemEntity e => new ConfigurationColorGradientItem {Id = e.Id, Name = e.Name}, + _ => null + }; + + if (inputItem != null) + { + inputItem.Load((ConfigurationInputItemEntity) entity); + return inputItem; + } + + if (entity is ConfigurationTextItemEntity textItemEntity) + return new ConfigurationTextItem {Id = textItemEntity.Id, Text = textItemEntity.Text}; + return null; + } + + private static IConfigurationItemEntity SaveItem(IConfigurationItem item) + { + if (item is ConfigurationTextItem textItem) + return new ConfigurationTextItemEntity {Id = textItem.Id, Text = textItem.Text}; + + ConfigurationInputItemEntity entity = item switch + { + ConfigurationStringItem => new ConfigurationStringItemEntity(), + ConfigurationBooleanItem booleanItem => new ConfigurationBooleanItemEntity {TrueText = booleanItem.TrueText, FalseText = booleanItem.FalseText}, + ConfigurationNumericItem numericItem => new ConfigurationNumericItemEntity {Minimum = numericItem.Minimum, Maximum = numericItem.Maximum, Slider = numericItem.Slider}, + ConfigurationSKColorItem => new ConfigurationSKColorItemEntity(), + ConfigurationColorGradientItem => new ConfigurationColorGradientItemEntity(), + _ => throw new ArtemisCoreException($"Unknown configuration item type: {item.GetType()}") + }; + + ((ConfigurationInputItem) item).Save(entity); + return entity; + } + + #endregion } \ No newline at end of file diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationStringItem.cs b/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationStringItem.cs index 11740f04d..c47549851 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationStringItem.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationStringItem.cs @@ -1,8 +1,10 @@ -namespace Artemis.Core; +namespace Artemis.Core; /// /// Represents a configuration item that accepts string input from the user. /// public class ConfigurationStringItem : ConfigurationInputItem { + /// + protected override string DataModelFallbackValue => string.Empty; } \ No newline at end of file diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationTextItem.cs b/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationTextItem.cs index 403cd5b5c..b0e1d7638 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationTextItem.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/ConfigurationTextItem.cs @@ -1,10 +1,15 @@ -namespace Artemis.Core; +using System; + +namespace Artemis.Core; /// /// Represents a configuration item that displays static text. /// public class ConfigurationTextItem : CorePropertyChanged, IConfigurationItem { + /// + public Guid Id { get; init; } = Guid.NewGuid(); + /// /// Gets or sets the text content to display. /// @@ -13,4 +18,4 @@ public class ConfigurationTextItem : CorePropertyChanged, IConfigurationItem get; set => SetAndNotify(ref field, value); } -} \ No newline at end of file +} diff --git a/src/Artemis.Core/Models/ProfileConfiguration/IConfigurationItem.cs b/src/Artemis.Core/Models/ProfileConfiguration/IConfigurationItem.cs index 2aa299640..33fcb4d07 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/IConfigurationItem.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/IConfigurationItem.cs @@ -1,8 +1,15 @@ -namespace Artemis.Core; +using System; +using System.ComponentModel; + +namespace Artemis.Core; /// /// Defines a contract for configuration items that can be added to a configuration section. /// -public interface IConfigurationItem +public interface IConfigurationItem : INotifyPropertyChanged { -} \ No newline at end of file + /// + /// Gets the unique ID of this configuration item, this ID remains stable between versions of a profile. + /// + Guid Id { get; } +} diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs index 847d671e1..8ee7efe2b 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; using System.Linq; using Artemis.Core.Modules; using Artemis.Storage.Entities.Profile; +using Artemis.Storage.Entities.Profile.Configuration; namespace Artemis.Core; @@ -17,7 +20,11 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable, /// public static readonly ProfileConfiguration Empty = new(ProfileCategory.Empty, "Empty", "Empty"); + private readonly ProfileConfigurationDataModel _configurationDataModel = new(); + private readonly HashSet _trackedSections = []; + private readonly HashSet _trackedItems = []; private bool _disposed; + private bool _configurationDataModelRegistered; internal ProfileConfiguration(ProfileCategory category, string name, string icon) { @@ -28,6 +35,7 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable, Icon = new ProfileConfigurationIcon(Entity); Icon.SetIconByName(icon); ActivationCondition = new NodeScript("Activate profile", "Whether or not the profile should be active", this); + ConfigurationSections.CollectionChanged += ConfigurationSectionsOnCollectionChanged; Entity.Profile.Id = Guid.NewGuid(); Entity.ProfileConfiguration.ProfileId = Entity.Profile.Id; @@ -42,6 +50,7 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable, Entity = entity; Icon = new ProfileConfigurationIcon(Entity); ActivationCondition = new NodeScript("Activate profile", "Whether or not the profile should be active", this); + ConfigurationSections.CollectionChanged += ConfigurationSectionsOnCollectionChanged; Load(); } @@ -52,7 +61,11 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable, public string Name { get; - set => SetAndNotify(ref field, value); + set + { + if (SetAndNotify(ref field, value)) + UpdateConfigurationDataModel(); + } } /// @@ -182,6 +195,16 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable, /// public ObservableCollection ConfigurationSections { get; } = []; + /// + /// Gets all configuration items in all that accept user input. + /// + public IEnumerable ConfigurationInputItems => ConfigurationSections.SelectMany(s => s.Items).OfType(); + + /// + /// Occurs when anything in the changed, including the values of items. + /// + public event EventHandler? ConfigurationChanged; + /// /// Gets the icon configuration /// @@ -245,12 +268,44 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable, return Module == null || Module.IsActivated; } + /// + /// Resets the value of every configuration item to its default value. + /// + public void ResetConfigurationToDefaults() + { + foreach (ConfigurationInputItem item in ConfigurationInputItems) + item.ResetToDefault(); + } + + /// + /// Copies the configured values of the provided profile configuration onto the matching configuration items of this + /// profile configuration. Items are matched by their ID, items without a match keep their current value. + /// + /// The profile configuration to copy the values from. + public void CopyConfigurationValues(ProfileConfiguration source) + { + Dictionary sourceItems = source.ConfigurationInputItems.ToDictionary(i => i.Id); + foreach (ConfigurationInputItem item in ConfigurationInputItems) + { + if (sourceItems.TryGetValue(item.Id, out ConfigurationInputItem? sourceItem) && sourceItem.GetType() == item.GetType()) + item.DeserializeValue(sourceItem.SerializeValue()); + } + } + /// public override string ToString() { return $"[ProfileConfiguration] {nameof(Name)}: {Name}"; } + /// + /// Invokes the event + /// + protected virtual void OnConfigurationChanged() + { + ConfigurationChanged?.Invoke(this, EventArgs.Empty); + } + internal void LoadModules(List enabledModules) { if (_disposed) @@ -263,10 +318,128 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable, /// public void Dispose() { + RemoveConfigurationDataModel(); _disposed = true; ActivationCondition.Dispose(); } + #region Configuration + + /// + /// Exposes the configuration values of this profile in the data model, done once the profile is part of the profile service. + /// + internal void AddConfigurationDataModel() + { + _configurationDataModelRegistered = true; + UpdateConfigurationDataModel(); + } + + /// + /// Removes the configuration values of this profile from the data model, done once the profile is removed from the profile service. + /// + internal void RemoveConfigurationDataModel() + { + _configurationDataModelRegistered = false; + UpdateConfigurationDataModel(); + } + + private void UpdateConfigurationDataModel() + { + if (_disposed || Entity == null) + return; + + List items = ConfigurationInputItems.ToList(); + + // Sync the values of this profile + HashSet keys = items.Select(i => i.Id.ToString("N")).ToHashSet(); + foreach (string key in _configurationDataModel.DynamicChildren.Keys.Where(k => !keys.Contains(k)).ToList()) + _configurationDataModel.RemoveDynamicChildByKey(key); + foreach (ConfigurationInputItem item in items) + { + string key = item.Id.ToString("N"); + if (_configurationDataModel.TryGetDynamicChild(key, out DynamicChild? dynamicChild)) + { + dynamicChild.Attribute.Name = item.Name; + dynamicChild.Attribute.Description = item.Description; + item.UpdateDynamicChild(_configurationDataModel, dynamicChild); + } + else + { + item.AddDynamicChild(_configurationDataModel, key, new DataModelPropertyAttribute {Name = item.Name, Description = item.Description}); + } + } + + // Only list this profile in the data model if there's actually something to show + ProfileConfigurationsDataModel root = ProfileConfigurationsDataModel.Instance; + string profileKey = ProfileId.ToString("N"); + bool shouldBeInRoot = _configurationDataModelRegistered && items.Count > 0; + bool isInRoot = root.TryGetDynamicChild(profileKey, out DynamicChild? profileChild) && profileChild.BaseValue == _configurationDataModel; + + if (shouldBeInRoot && !isInRoot) + { + root.RemoveDynamicChildByKey(profileKey); + root.AddDynamicChild(profileKey, _configurationDataModel, Name, "The configuration values of this profile"); + } + else if (!shouldBeInRoot && isInRoot) + { + root.RemoveDynamicChildByKey(profileKey); + } + else if (isInRoot) + { + profileChild!.Attribute.Name = Name; + } + } + + private void TrackConfiguration() + { + foreach (ConfigurationSection section in _trackedSections.Except(ConfigurationSections).ToList()) + { + section.PropertyChanged -= ConfigurationOnPropertyChanged; + section.Items.CollectionChanged -= ConfigurationOnCollectionChanged; + _trackedSections.Remove(section); + } + + foreach (ConfigurationSection section in ConfigurationSections.Except(_trackedSections).ToList()) + { + section.PropertyChanged += ConfigurationOnPropertyChanged; + section.Items.CollectionChanged += ConfigurationOnCollectionChanged; + _trackedSections.Add(section); + } + + List items = ConfigurationSections.SelectMany(s => s.Items).ToList(); + foreach (IConfigurationItem item in _trackedItems.Except(items).ToList()) + { + item.PropertyChanged -= ConfigurationOnPropertyChanged; + _trackedItems.Remove(item); + } + + foreach (IConfigurationItem item in items.Except(_trackedItems).ToList()) + { + item.PropertyChanged += ConfigurationOnPropertyChanged; + _trackedItems.Add(item); + } + } + + private void ConfigurationSectionsOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + ConfigurationOnCollectionChanged(sender, e); + } + + private void ConfigurationOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + TrackConfiguration(); + UpdateConfigurationDataModel(); + OnConfigurationChanged(); + } + + private void ConfigurationOnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + UpdateConfigurationDataModel(); + OnConfigurationChanged(); + } + + #endregion + /// public IEnumerable GetFeatureDependencies() { @@ -303,29 +476,19 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable, EnableHotkey = Entity.ProfileConfiguration.EnableHotkey != null ? new Hotkey(Entity.ProfileConfiguration.EnableHotkey) : null; DisableHotkey = Entity.ProfileConfiguration.DisableHotkey != null ? new Hotkey(Entity.ProfileConfiguration.DisableHotkey) : null; - // Placeholder configuration sections + IsConfigurable = Entity.ProfileConfiguration.IsConfigurable; ConfigurationSections.Clear(); - ConfigurationSections.Add(new ConfigurationSection(this) + foreach (ConfigurationSectionEntity sectionEntity in Entity.ProfileConfiguration.ConfigurationSections) + ConfigurationSections.Add(ConfigurationSection.Load(this, sectionEntity)); + + // Apply the values picked by the user, falling back to the defaults set by the author + foreach (ConfigurationInputItem item in ConfigurationInputItems) { - Name = "General" - }); - ConfigurationSections.Add(new ConfigurationSection(this) - { - Name = "Other" - }); - ConfigurationSections.Add(new ConfigurationSection(this) - { - Name = "Something else" - }); - ConfigurationSections[0].Items.Add(new ConfigurationTextItem {Text = "This is a placeholder text item in the General section."}); - ConfigurationSections[0].Items.Add(new ConfigurationNumericItem {Name = "Numeric item", Description = "This one also has a description"}); - ConfigurationSections[0].Items.Add(new ConfigurationBooleanItem {Name = "Do the thing?", TrueText = "Absolutely", FalseText = "Nope"}); - ConfigurationSections[1].Items.Add(new ConfigurationTextItem {Text = "This is a placeholder text item in the Other section."}); - ConfigurationSections[2].Items.Add(new ConfigurationTextItem {Text = "This is a placeholder text item in the Something else section."}); - ConfigurationSections[2].Items.Add(new ConfigurationTextItem {Text = "This is another placeholder text item in the Something else section."}); - ConfigurationSections[2].Items.Add(new ConfigurationNumericItem {Name = "Numeric item", Description = "This one uses a slider", Minimum = 0, Maximum = 10, Slider = true}); - ConfigurationSections[2].Items.Add(new ConfigurationColorGradientItem {Name = "Color gradient"}); - ConfigurationSections[2].Items.Add(new ConfigurationSKColorItem {Name = "A simple color", Description = "Again with a description"}); + if (Entity.ProfileConfiguration.ConfigurationValues.TryGetValue(item.Id, out string? value)) + item.DeserializeValue(value); + else + item.ResetToDefault(); + } } /// @@ -354,6 +517,16 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable, if (!IsMissingModule) Entity.ProfileConfiguration.ModuleId = Module?.Id; + + Entity.ProfileConfiguration.IsConfigurable = IsConfigurable; + Entity.ProfileConfiguration.ConfigurationSections = ConfigurationSections.Select(s => s.Save()).ToList(); + Entity.ProfileConfiguration.ConfigurationValues = []; + foreach (ConfigurationInputItem item in ConfigurationInputItems) + { + string? value = item.SerializeValue(); + if (value != null) + Entity.ProfileConfiguration.ConfigurationValues[item.Id] = value; + } } #endregion diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationDataModel.cs b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationDataModel.cs new file mode 100644 index 000000000..f8815ede3 --- /dev/null +++ b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationDataModel.cs @@ -0,0 +1,29 @@ +using Artemis.Core.Modules; + +namespace Artemis.Core; + +/// +/// The data model containing the configured values of all configurable profiles, each profile is a dynamic child +/// keyed by its profile ID. +/// +internal class ProfileConfigurationsDataModel : DataModel +{ + private ProfileConfigurationsDataModel() + { + Module = Constants.CorePluginFeature; + DataModelDescription = new DataModelPropertyAttribute + { + Name = "Profile configuration", + Description = "The values users configured on configurable profiles" + }; + IsExpansion = true; + } + + public static ProfileConfigurationsDataModel Instance { get; } = new(); +} + +/// +/// The data model containing the configured values of a single profile, each value is a dynamic child keyed by its +/// configuration item ID. +/// +internal class ProfileConfigurationDataModel : DataModel; \ No newline at end of file diff --git a/src/Artemis.Core/Services/Storage/ProfileService.cs b/src/Artemis.Core/Services/Storage/ProfileService.cs index a712c4ae7..89f85e285 100644 --- a/src/Artemis.Core/Services/Storage/ProfileService.cs +++ b/src/Artemis.Core/Services/Storage/ProfileService.cs @@ -54,6 +54,12 @@ internal class ProfileService : IProfileService if (!ProfileCategories.Any()) CreateDefaultProfileCategories(); UpdateModules(); + + // Expose the values of configurable profiles in the data model + if (DataModelStore.GetAll().All(r => r.DataModel != ProfileConfigurationsDataModel.Instance)) + DataModelStore.Add(ProfileConfigurationsDataModel.Instance); + foreach (ProfileConfiguration profileConfiguration in ProfileCategories.SelectMany(c => c.ProfileConfigurations)) + profileConfiguration.AddConfigurationDataModel(); } public ProfileConfiguration? FocusProfile { get; set; } @@ -258,6 +264,7 @@ internal class ProfileService : IProfileService ProfileConfiguration configuration = new(category, name, icon); category.AddProfileConfiguration(configuration, category.ProfileConfigurations.FirstOrDefault()); + configuration.AddConfigurationDataModel(); SaveProfileCategory(category); OnProfileAdded(new ProfileConfigurationEventArgs(configuration)); @@ -273,6 +280,7 @@ internal class ProfileService : IProfileService category.RemoveProfileConfiguration(profileConfiguration); category.Save(); + profileConfiguration.RemoveConfigurationDataModel(); _profileRepository.Remove(profileConfiguration.Entity); _profileCategoryRepository.Save(category.Entity); @@ -390,7 +398,17 @@ internal class ProfileService : IProfileService // Assign a new GUID to make sure it is unique in case of a previous import of the same content if (makeUnique) - profileEntity.UpdateGuid(Guid.NewGuid()); + { + Guid newGuid = Guid.NewGuid(); + + // Data model paths pointing to the configuration values of this profile contain the profile ID, point those to the new ID + string oldKey = profileEntity.Id.ToString("N"); + string newKey = newGuid.ToString("N"); + configurationEntity = ReplaceInEntity(configurationEntity, oldKey, newKey); + profileEntity = ReplaceInEntity(profileEntity, oldKey, newKey); + + profileEntity.UpdateGuid(newGuid); + } else { // If the profile already exists and this one is not to be made unique, return the existing profile @@ -424,6 +442,7 @@ internal class ProfileService : IProfileService profileConfiguration.Entity.ProfileConfiguration.ProfileId = profileEntity.Id; category.AddProfileConfiguration(profileConfiguration, target); + profileConfiguration.AddConfigurationDataModel(); List modules = _pluginManagementService.GetFeaturesOfType(); profileConfiguration.LoadModules(modules); @@ -432,6 +451,15 @@ internal class ProfileService : IProfileService return profileConfiguration; } + private static T ReplaceInEntity(T entity, string oldValue, string newValue) + { + string json = CoreJson.Serialize(entity); + if (!json.Contains(oldValue)) + return entity; + + return CoreJson.Deserialize(json.Replace(oldValue, newValue)) ?? entity; + } + /// public void AdaptProfile(Profile profile) { diff --git a/src/Artemis.Storage/Entities/Profile/Configuration/ConfigurationInputItemEntity.cs b/src/Artemis.Storage/Entities/Profile/Configuration/ConfigurationInputItemEntity.cs new file mode 100644 index 000000000..badcf157e --- /dev/null +++ b/src/Artemis.Storage/Entities/Profile/Configuration/ConfigurationInputItemEntity.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; + +namespace Artemis.Storage.Entities.Profile.Configuration; + +public abstract class ConfigurationInputItemEntity : IConfigurationItemEntity +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + + /// + /// The JSON-serialized default value as set by the author of the profile + /// + public string? DefaultValue { get; set; } + + public List DropdownValues { get; set; } = []; +} + +public class ConfigurationDropdownValueEntity +{ + public string DisplayName { get; set; } = string.Empty; + + /// + /// The JSON-serialized value of the dropdown option + /// + public string? Value { get; set; } +} + +public class ConfigurationStringItemEntity : ConfigurationInputItemEntity; + +public class ConfigurationSKColorItemEntity : ConfigurationInputItemEntity; + +public class ConfigurationColorGradientItemEntity : ConfigurationInputItemEntity; + +public class ConfigurationBooleanItemEntity : ConfigurationInputItemEntity +{ + public string TrueText { get; set; } = string.Empty; + public string FalseText { get; set; } = string.Empty; +} + +public class ConfigurationNumericItemEntity : ConfigurationInputItemEntity +{ + public float? Minimum { get; set; } + public float? Maximum { get; set; } + public bool Slider { get; set; } +} diff --git a/src/Artemis.Storage/Entities/Profile/Configuration/ConfigurationSectionEntity.cs b/src/Artemis.Storage/Entities/Profile/Configuration/ConfigurationSectionEntity.cs new file mode 100644 index 000000000..3867a7009 --- /dev/null +++ b/src/Artemis.Storage/Entities/Profile/Configuration/ConfigurationSectionEntity.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Artemis.Storage.Entities.Profile.Configuration; + +public class ConfigurationSectionEntity +{ + public string Name { get; set; } = string.Empty; + public List Items { get; set; } = []; +} diff --git a/src/Artemis.Storage/Entities/Profile/Configuration/ConfigurationTextItemEntity.cs b/src/Artemis.Storage/Entities/Profile/Configuration/ConfigurationTextItemEntity.cs new file mode 100644 index 000000000..433218f95 --- /dev/null +++ b/src/Artemis.Storage/Entities/Profile/Configuration/ConfigurationTextItemEntity.cs @@ -0,0 +1,9 @@ +using System; + +namespace Artemis.Storage.Entities.Profile.Configuration; + +public class ConfigurationTextItemEntity : IConfigurationItemEntity +{ + public Guid Id { get; set; } + public string Text { get; set; } = string.Empty; +} diff --git a/src/Artemis.Storage/Entities/Profile/Configuration/IConfigurationItemEntity.cs b/src/Artemis.Storage/Entities/Profile/Configuration/IConfigurationItemEntity.cs new file mode 100644 index 000000000..054d21030 --- /dev/null +++ b/src/Artemis.Storage/Entities/Profile/Configuration/IConfigurationItemEntity.cs @@ -0,0 +1,15 @@ +using System; +using System.Text.Json.Serialization; + +namespace Artemis.Storage.Entities.Profile.Configuration; + +[JsonDerivedType(typeof(ConfigurationTextItemEntity), "Text")] +[JsonDerivedType(typeof(ConfigurationStringItemEntity), "String")] +[JsonDerivedType(typeof(ConfigurationBooleanItemEntity), "Boolean")] +[JsonDerivedType(typeof(ConfigurationNumericItemEntity), "Numeric")] +[JsonDerivedType(typeof(ConfigurationSKColorItemEntity), "SKColor")] +[JsonDerivedType(typeof(ConfigurationColorGradientItemEntity), "ColorGradient")] +public interface IConfigurationItemEntity +{ + Guid Id { get; set; } +} diff --git a/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs b/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs index fb58e8d8a..af758e2a9 100644 --- a/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs +++ b/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using Artemis.Storage.Entities.Profile.Configuration; using Artemis.Storage.Entities.Profile.Nodes; namespace Artemis.Storage.Entities.Profile; @@ -25,5 +27,14 @@ public class ProfileConfigurationEntity public Guid ProfileId { get; set; } public bool FadeInAndOut { get; set; } + + public bool IsConfigurable { get; set; } + public List ConfigurationSections { get; set; } = []; + + /// + /// The JSON-serialized values the user picked for the configuration items, keyed by item ID + /// + public Dictionary ConfigurationValues { get; set; } = []; + public int Version { get; set; } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationBooleanItemViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationBooleanItemViewModel.cs index 6dd08dee9..db1d7af95 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationBooleanItemViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationBooleanItemViewModel.cs @@ -1,14 +1,5 @@ using Artemis.Core; -using Artemis.UI.Shared; namespace Artemis.UI.Screens.ProfileEditor.ConfigurationPanels.Items; -public class ConfigurationBooleanItemViewModel : ActivatableViewModelBase -{ - public ConfigurationBooleanItemViewModel(ConfigurationBooleanItem item) - { - Item = item; - } - - public ConfigurationBooleanItem Item { get; } -} \ No newline at end of file +public class ConfigurationBooleanItemViewModel(ConfigurationBooleanItem item) : ConfigurationInputItemViewModel(item); diff --git a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationColorGradientItemViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationColorGradientItemViewModel.cs index 17db44989..61e12da3f 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationColorGradientItemViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationColorGradientItemViewModel.cs @@ -1,14 +1,5 @@ using Artemis.Core; -using Artemis.UI.Shared; namespace Artemis.UI.Screens.ProfileEditor.ConfigurationPanels.Items; -public class ConfigurationColorGradientItemViewModel : ActivatableViewModelBase -{ - public ConfigurationColorGradientItemViewModel(ConfigurationColorGradientItem item) - { - Item = item; - } - - public ConfigurationColorGradientItem Item { get; } -} \ No newline at end of file +public class ConfigurationColorGradientItemViewModel(ConfigurationColorGradientItem item) : ConfigurationInputItemViewModel(item); diff --git a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationInputItemViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationInputItemViewModel.cs new file mode 100644 index 000000000..d52cb0d6f --- /dev/null +++ b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationInputItemViewModel.cs @@ -0,0 +1,48 @@ +using System; +using System.ComponentModel; +using System.Linq; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; +using Artemis.Core; +using Artemis.UI.Shared; +using ReactiveUI; + +namespace Artemis.UI.Screens.ProfileEditor.ConfigurationPanels.Items; + +public abstract class ConfigurationInputItemViewModel : ActivatableViewModelBase where TItem : ConfigurationInputItem +{ + protected ConfigurationInputItemViewModel(TItem item) + { + Item = item; + + this.WhenActivated(d => + { + Observable.FromEventPattern(h => Item.PropertyChanged += h, h => Item.PropertyChanged -= h) + .Select(e => e.EventArgs.PropertyName) + .Where(p => p is nameof(ConfigurationInputItem.Value) or nameof(ConfigurationInputItem.DropdownValues)) + .Subscribe(_ => + { + this.RaisePropertyChanged(nameof(HasDropdown)); + this.RaisePropertyChanged(nameof(SelectedDropdownValue)); + }) + .DisposeWith(d); + }); + } + + public TItem Item { get; } + + /// + /// Gets a boolean indicating whether the user picks the value from a dropdown instead of entering it. + /// + public bool HasDropdown => Item.DropdownValues.Count > 0; + + public ConfigurationDropdownValue? SelectedDropdownValue + { + get => Item.DropdownValues.FirstOrDefault(d => Equals(d.Value, Item.Value)); + set + { + if (value != null) + Item.Value = value.Value; + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationNumericItemView.axaml b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationNumericItemView.axaml index f523f0630..2f57bd157 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationNumericItemView.axaml +++ b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationNumericItemView.axaml @@ -5,6 +5,8 @@ xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:behaviors="clr-namespace:Artemis.UI.Shared.Behaviors;assembly=Artemis.UI.Shared" xmlns:items="clr-namespace:Artemis.UI.Screens.ProfileEditor.ConfigurationPanels.Items" + xmlns:core="clr-namespace:Artemis.Core;assembly=Artemis.Core" + xmlns:system="clr-namespace:System;assembly=System.Runtime" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.ProfileEditor.ConfigurationPanels.Items.ConfigurationNumericItemView" x:DataType="items:ConfigurationNumericItemViewModel"> @@ -16,17 +18,32 @@ Text="{CompiledBinding Item.Description}" IsVisible="{CompiledBinding Item.Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" /> - - - - - - - - + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationNumericItemViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationNumericItemViewModel.cs index 1267bd5a0..d8623703f 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationNumericItemViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationNumericItemViewModel.cs @@ -1,14 +1,5 @@ using Artemis.Core; -using Artemis.UI.Shared; namespace Artemis.UI.Screens.ProfileEditor.ConfigurationPanels.Items; -public class ConfigurationNumericItemViewModel : ActivatableViewModelBase -{ - public ConfigurationNumericItemViewModel(ConfigurationNumericItem item) - { - Item = item; - } - - public ConfigurationNumericItem Item { get; } -} \ No newline at end of file +public class ConfigurationNumericItemViewModel(ConfigurationNumericItem item) : ConfigurationInputItemViewModel(item); diff --git a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationSKColorItemViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationSKColorItemViewModel.cs index f07903e12..fcd864fe9 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationSKColorItemViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationSKColorItemViewModel.cs @@ -1,14 +1,6 @@ using Artemis.Core; -using Artemis.UI.Shared; +using SkiaSharp; namespace Artemis.UI.Screens.ProfileEditor.ConfigurationPanels.Items; -public class ConfigurationSKColorItemViewModel : ActivatableViewModelBase -{ - public ConfigurationSKColorItemViewModel(ConfigurationSKColorItem item) - { - Item = item; - } - - public ConfigurationSKColorItem Item { get; } -} \ No newline at end of file +public class ConfigurationSKColorItemViewModel(ConfigurationSKColorItem item) : ConfigurationInputItemViewModel(item); diff --git a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationStringItemView.axaml b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationStringItemView.axaml index e8cfcac38..1a146ba23 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationStringItemView.axaml +++ b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationStringItemView.axaml @@ -3,6 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:items="clr-namespace:Artemis.UI.Screens.ProfileEditor.ConfigurationPanels.Items" + xmlns:core="clr-namespace:Artemis.Core;assembly=Artemis.Core" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.ProfileEditor.ConfigurationPanels.Items.ConfigurationStringItemView" x:DataType="items:ConfigurationStringItemViewModel"> @@ -15,7 +16,17 @@ IsVisible="{CompiledBinding Item.Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" /> - + + + + + + + + - \ No newline at end of file + diff --git a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationStringItemViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationStringItemViewModel.cs index 3deee3542..051ae2367 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationStringItemViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Items/ConfigurationStringItemViewModel.cs @@ -1,14 +1,5 @@ using Artemis.Core; -using Artemis.UI.Shared; namespace Artemis.UI.Screens.ProfileEditor.ConfigurationPanels.Items; -public class ConfigurationStringItemViewModel : ActivatableViewModelBase -{ - public ConfigurationStringItemViewModel(ConfigurationStringItem item) - { - Item = item; - } - - public ConfigurationStringItem Item { get; } -} \ No newline at end of file +public class ConfigurationStringItemViewModel(ConfigurationStringItem item) : ConfigurationInputItemViewModel(item); diff --git a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Section/ConfigurationSectionViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Section/ConfigurationSectionViewModel.cs index 310c68804..5903b21ac 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Section/ConfigurationSectionViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/ConfigurationPanels/Section/ConfigurationSectionViewModel.cs @@ -1,33 +1,50 @@ -using System.Collections.ObjectModel; +using System; +using System.Collections.ObjectModel; +using System.Reactive.Disposables.Fluent; using Artemis.Core; using Artemis.UI.Screens.ProfileEditor.ConfigurationPanels.Items; using Artemis.UI.Shared; +using DynamicData; +using DynamicData.Binding; +using PropertyChanged.SourceGenerator; +using ReactiveUI; namespace Artemis.UI.Screens.ProfileEditor.ConfigurationPanels.Section; -public class ConfigurationSectionViewModel : ActivatableViewModelBase +public partial class ConfigurationSectionViewModel : ActivatableViewModelBase { - public ConfigurationSection ConfigurationSection { get; } - public ObservableCollection Items { get; } + [Notify] private ReadOnlyObservableCollection? _items; public ConfigurationSectionViewModel(ConfigurationSection configurationSection) { ConfigurationSection = configurationSection; - Items = []; - foreach (IConfigurationItem item in ConfigurationSection.Items) + + this.WhenActivated(d => { - if (item is ConfigurationTextItem textItem) - Items.Add(new ConfigurationTextItemViewModel(textItem)); - else if (item is ConfigurationStringItem stringItem) - Items.Add(new ConfigurationStringItemViewModel(stringItem)); - else if (item is ConfigurationBooleanItem booleanItem) - Items.Add(new ConfigurationBooleanItemViewModel(booleanItem)); - else if (item is ConfigurationNumericItem numberItem) - Items.Add(new ConfigurationNumericItemViewModel(numberItem)); - else if (item is ConfigurationSKColorItem colorItem) - Items.Add(new ConfigurationSKColorItemViewModel(colorItem)); - else if (item is ConfigurationColorGradientItem colorGradientItem) - Items.Add(new ConfigurationColorGradientItemViewModel(colorGradientItem)); - } + ConfigurationSection.Items + .ToObservableChangeSet() + .Transform(CreateViewModelForItem) + .Bind(out ReadOnlyObservableCollection items) + .Subscribe() + .DisposeWith(d); + + Items = items; + }); + } + + public ConfigurationSection ConfigurationSection { get; } + + private static ActivatableViewModelBase CreateViewModelForItem(IConfigurationItem item) + { + return item switch + { + ConfigurationTextItem textItem => new ConfigurationTextItemViewModel(textItem), + ConfigurationStringItem stringItem => new ConfigurationStringItemViewModel(stringItem), + ConfigurationBooleanItem booleanItem => new ConfigurationBooleanItemViewModel(booleanItem), + ConfigurationNumericItem numberItem => new ConfigurationNumericItemViewModel(numberItem), + ConfigurationSKColorItem colorItem => new ConfigurationSKColorItemViewModel(colorItem), + ConfigurationColorGradientItem colorGradientItem => new ConfigurationColorGradientItemViewModel(colorGradientItem), + _ => throw new ArgumentException($"Unknown configuration item type: {item.GetType()}") + }; } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/ProfileEditor/ConfigureProfileViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/ConfigureProfileViewModel.cs index c6b91cfbf..3cca92f82 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/ConfigureProfileViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/ConfigureProfileViewModel.cs @@ -1,6 +1,8 @@ -using System; +using System; using System.Collections.ObjectModel; using System.Linq; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; using Artemis.Core; @@ -10,7 +12,9 @@ using Artemis.UI.Screens.ProfileEditor.ConfigurationPanels.Section; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services.ProfileEditor; using DynamicData; +using DynamicData.Binding; using PropertyChanged.SourceGenerator; +using ReactiveUI; namespace Artemis.UI.Screens.ProfileEditor; @@ -18,9 +22,9 @@ public partial class ConfigureProfileViewModel : RoutableScreen _configurationSections; [Notify] private ProfileConfiguration? _profileConfiguration; + [Notify] private ReadOnlyObservableCollection? _sections; public ConfigureProfileViewModel(IProfileService profileService, IProfileEditorService profileEditorService, PreviewViewModel previewViewModel, Func getConfigurationSectionViewModel) @@ -31,17 +35,31 @@ public partial class ConfigureProfileViewModel : RoutableScreen(); - _configurationSections.Connect() - .Transform(getConfigurationSectionViewModel) - .Bind(out ReadOnlyObservableCollection sections) - .Subscribe(); - - Sections = sections; + this.WhenActivated(d => + { + this.WhenAnyValue(vm => vm.ProfileConfiguration) + .Select(p => p?.ConfigurationSections.ToObservableChangeSet() ?? Observable.Empty>()) + .Switch() + .Transform(getConfigurationSectionViewModel) + .Bind(out ReadOnlyObservableCollection sections) + .Subscribe() + .DisposeWith(d); + + Sections = sections; + + this.WhenAnyValue(vm => vm.ProfileConfiguration) + .Select(p => p != null + ? Observable.FromEventPattern(h => p.ConfigurationChanged += h, h => p.ConfigurationChanged -= h).Select(_ => p) + : Observable.Empty()) + .Switch() + .Throttle(TimeSpan.FromMilliseconds(500)) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(p => _profileService.SaveProfileCategory(p.Category)) + .DisposeWith(d); + }); } public PreviewViewModel PreviewViewModel { get; } - public ReadOnlyObservableCollection Sections { get; private set; } /// public override async Task OnNavigating(ProfileViewModelParameters parameters, NavigationArguments args, CancellationToken cancellationToken) @@ -57,16 +75,15 @@ public partial class ConfigureProfileViewModel : RoutableScreen - { - editableSections.Clear(); - editableSections.AddRange(profileConfiguration.ConfigurationSections); - }); } /// public override async Task OnClosing(NavigationArguments args) { + // Don't lose changes that are still waiting on the throttled save + if (ProfileConfiguration != null) + _profileService.SaveProfileCategory(ProfileConfiguration.Category); + if (!args.Path.StartsWith("profile")) { ProfileConfiguration = null; diff --git a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationBooleanItemDesignView.axaml b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationBooleanItemDesignView.axaml index 7cd8544ef..f57f36f2f 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationBooleanItemDesignView.axaml +++ b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationBooleanItemDesignView.axaml @@ -18,7 +18,7 @@ Default: - (item, configurationSection) { public ConfigurationBooleanItem BooleanItem { get; } = item; -} \ No newline at end of file +} diff --git a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationColorGradientItemDesignView.axaml b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationColorGradientItemDesignView.axaml index 4cc64891a..fd62bfebb 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationColorGradientItemDesignView.axaml +++ b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationColorGradientItemDesignView.axaml @@ -16,7 +16,7 @@ Default: + + + Dropdown options + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationNumericItemDesignViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationNumericItemDesignViewModel.cs index 057f48f6c..7d52cb775 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationNumericItemDesignViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationNumericItemDesignViewModel.cs @@ -1,8 +1,51 @@ -using Artemis.Core; +using System; +using System.ComponentModel; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; +using Artemis.Core; +using ReactiveUI; namespace Artemis.UI.Screens.ProfileEditor.DesignPanels.Items; -public class ConfigurationNumericItemDesignViewModel(ConfigurationNumericItem item, ConfigurationSection configurationSection) : ConfigurationItemDesignViewModel(item, configurationSection) +public class ConfigurationNumericItemDesignViewModel : ConfigurationInputItemDesignViewModel { - public ConfigurationNumericItem NumericItem { get; } = item; + public ConfigurationNumericItemDesignViewModel(ConfigurationNumericItem item, ConfigurationSection configurationSection) : base(item, configurationSection) + { + NumericItem = item; + + this.WhenActivated(d => + { + Observable.FromEventPattern(h => item.PropertyChanged += h, h => item.PropertyChanged -= h) + .Select(e => e.EventArgs.PropertyName) + .Subscribe(p => + { + if (p == nameof(ConfigurationNumericItem.Minimum)) + this.RaisePropertyChanged(nameof(Minimum)); + else if (p == nameof(ConfigurationNumericItem.Maximum)) + this.RaisePropertyChanged(nameof(Maximum)); + }) + .DisposeWith(d); + }); + } + + public ConfigurationNumericItem NumericItem { get; } + + // A number box shows NaN as empty, use that to represent no minimum/maximum + public double Minimum + { + get => NumericItem.Minimum ?? double.NaN; + set => NumericItem.Minimum = double.IsNaN(value) ? null : value; + } + + public double Maximum + { + get => NumericItem.Maximum ?? double.NaN; + set => NumericItem.Maximum = double.IsNaN(value) ? null : value; + } + + /// + protected override Numeric CreateDropdownValue(int number) + { + return number; + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationSKColorItemDesignView.axaml b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationSKColorItemDesignView.axaml index 206a94c92..bd2adcf49 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationSKColorItemDesignView.axaml +++ b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationSKColorItemDesignView.axaml @@ -15,7 +15,7 @@ Default: - diff --git a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationSKColorItemDesignViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationSKColorItemDesignViewModel.cs index 61de392e1..25ba4c0fe 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationSKColorItemDesignViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationSKColorItemDesignViewModel.cs @@ -1,8 +1,10 @@ using Artemis.Core; +using SkiaSharp; namespace Artemis.UI.Screens.ProfileEditor.DesignPanels.Items; -public class ConfigurationSKColorItemDesignViewModel(ConfigurationSKColorItem item, ConfigurationSection configurationSection) : ConfigurationItemDesignViewModel(item, configurationSection) +public class ConfigurationSKColorItemDesignViewModel(ConfigurationSKColorItem item, ConfigurationSection configurationSection) + : ConfigurationInputItemDesignViewModel(item, configurationSection) { public ConfigurationSKColorItem ColorItem { get; } = item; -} \ No newline at end of file +} diff --git a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationStringItemDesignView.axaml b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationStringItemDesignView.axaml index 413d5e276..9fc5d51e7 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationStringItemDesignView.axaml +++ b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationStringItemDesignView.axaml @@ -4,21 +4,64 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:items="clr-namespace:Artemis.UI.Screens.ProfileEditor.DesignPanels.Items" xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" + xmlns:core="clr-namespace:Artemis.Core;assembly=Artemis.Core" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.ProfileEditor.DesignPanels.Items.ConfigurationStringItemDesignView" x:DataType="items:ConfigurationStringItemDesignViewModel"> - + Default: - + + + + + + + + + + + Dropdown options + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationStringItemDesignViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationStringItemDesignViewModel.cs index 4af21c8f6..61dfae3c5 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationStringItemDesignViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationStringItemDesignViewModel.cs @@ -2,7 +2,14 @@ namespace Artemis.UI.Screens.ProfileEditor.DesignPanels.Items; -public class ConfigurationStringItemDesignViewModel(ConfigurationStringItem item, ConfigurationSection configurationSection) : ConfigurationItemDesignViewModel(item, configurationSection) +public class ConfigurationStringItemDesignViewModel(ConfigurationStringItem item, ConfigurationSection configurationSection) + : ConfigurationInputItemDesignViewModel(item, configurationSection) { public ConfigurationStringItem StringItem { get; } = item; -} \ No newline at end of file + + /// + protected override string CreateDropdownValue(int number) + { + return $"Option {number}"; + } +} diff --git a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationTextItemDesignViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationTextItemDesignViewModel.cs index 17a75a15e..456cf8a2a 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationTextItemDesignViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Items/ConfigurationTextItemDesignViewModel.cs @@ -1,4 +1,4 @@ -using Artemis.Core; +using Artemis.Core; using Artemis.UI.Shared; namespace Artemis.UI.Screens.ProfileEditor.DesignPanels.Items; diff --git a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Section/AddConfigurationItemDesignView.axaml.cs b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Section/AddConfigurationItemDesignView.axaml.cs index 1f84d3c4c..06a0523c5 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Section/AddConfigurationItemDesignView.axaml.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/DesignPanels/Section/AddConfigurationItemDesignView.axaml.cs @@ -13,7 +13,7 @@ public partial class AddConfigurationItemDesignView : ReactiveUserControl vm.ProfileConfiguration) + .Select(p => p != null + ? Observable.FromEventPattern(h => p.ConfigurationChanged += h, h => p.ConfigurationChanged -= h).Select(_ => p) + : Observable.Empty()) + .Switch() + .Throttle(TimeSpan.FromMilliseconds(500)) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(p => _profileService.SaveProfileCategory(p.Category)) + .DisposeWith(d); }); } @@ -70,6 +80,10 @@ public partial class DesignProfileViewModel : RoutableScreen public override async Task OnClosing(NavigationArguments args) { + // Don't lose changes that are still waiting on the throttled save + if (ProfileConfiguration != null) + _profileService.SaveProfileCategory(ProfileConfiguration.Category); + if (!args.Path.StartsWith("profile")) { ProfileConfiguration = null; @@ -77,7 +91,7 @@ public partial class DesignProfileViewModel : RoutableScreen c.Name == "Workshop") ?? _profileService.CreateProfileCategory("Workshop", true); ProfileConfiguration imported = await _profileService.ImportProfile(stream, category, true, true, null); + // The export contains the values the author had configured, start with the defaults the author picked instead + imported.ResetConfigurationToDefaults(); + _profileService.SaveProfileCategory(category); installedEntry.SetMetadata("ProfileId", imported.ProfileId); // Update the release and return the profile configuration diff --git a/src/Artemis.sln b/src/Artemis.sln index 64f0edfa7..acef28408 100644 --- a/src/Artemis.sln +++ b/src/Artemis.sln @@ -28,6 +28,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Directory.Packages.props = Directory.Packages.props Directory.Build.props = Directory.Build.props Directory.Build.targets = Directory.Build.targets + CLAUDE.md = CLAUDE.md EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Artemis.Storage.Legacy", "Artemis.Storage.Legacy\Artemis.Storage.Legacy.csproj", "{D7B0966D-774A-40E4-9455-00C1261ACB6A}" diff --git a/src/CLAUDE.md b/src/CLAUDE.md new file mode 100644 index 000000000..8ba7d716c --- /dev/null +++ b/src/CLAUDE.md @@ -0,0 +1,35 @@ +# Artemis + +Artemis is a desktop app that adds highly configurable RGB lighting support for games/peripherals (RGB.NET-based). Cross-platform via Avalonia, with OS-specific host projects for Windows/Linux/macOS. + +## Solution layout + +- `Artemis.Core` — domain/business logic: profiles, plugins, devices, services (DI-registered), color science, layouts. No UI dependency. +- `Artemis.Storage` — EF Core + SQLite persistence (`ArtemisDbContext`), entities and repositories. `Artemis.Storage.Legacy` handles migration from the old storage format. +- `Artemis.UI.Shared` — reusable MVVM building blocks shared by the main UI: routing framework (`Routing/`), `ActivatableViewModelBase`, shared controls/behaviors/converters/styles. +- `Artemis.UI` — the actual application screens (`Screens/...`), organized by feature area (ProfileEditor, Workshop, SurfaceEditor, Settings, etc.). This is where almost all feature work happens. +- `Artemis.UI.Windows` / `Artemis.UI.Linux` / `Artemis.UI.MacOS` — thin OS-specific host/entry-point projects (WinExe etc.) that reference `Artemis.UI`. On this machine, `Artemis.UI.Windows` is the one that actually runs. +- `Artemis.WebClient.Workshop` / `Artemis.WebClient.Updating` — GraphQL/HTTP clients for the workshop (plugin/profile marketplace) and update checks. +- Plugins themselves live in a separate sibling repo, `Artemis.Plugins` — not needed for typical work in this repo and not expected to be cloned alongside it. + +Target framework: `net10.0` (Windows host uses `net10.0-windows10.0.17763.0`). Nullable reference types enabled everywhere. + +## Build & verify + +- Build the whole solution to sanity-check changes: `dotnet build Artemis.sln` (or `Artemis.UI.csproj` while iterating, but always do a full solution build before considering a task done). +- There is no automated test suite in this solution (no `*.Tests` projects) — verification is: it builds, plus running the app manually for anything UI-visible (see `run` skill / launch `Artemis.UI.Windows`). +- Treat compiler warnings you introduce as worth fixing, but the codebase already has a long tail of pre-existing warnings (obsolete API usage, missing XML docs, etc.) — don't try to clean those up incidentally. + +## MVVM conventions (ReactiveUI + DynamicData + Avalonia) + +- View models use `PropertyChanged.SourceGenerator`: private backing fields annotated `[Notify]` (e.g. `[Notify] private ProfileConfiguration? _profileConfiguration;`) generate the public property — don't hand-write `INotifyPropertyChanged` boilerplate. +- Routable screens derive from `RoutableScreen` / `RoutableScreen` (in `Artemis.UI.Shared.Routing`), which itself derives from `ActivatableViewModelBase`. Override `OnNavigating`/`OnClosing` for route lifecycle, not the constructor, for anything that depends on navigation parameters. +- The router (`Artemis.UI.Shared/Routing/Router/Router.cs`) recycles screen instances by default (`RecycleScreens = true`): navigating between two routes that resolve to the same view model type reuses the existing instance rather than constructing a new one. Don't assume a fresh instance per navigation — state must be reset/updated in `OnNavigating`, not just the constructor. +- Any `Transform`/`Bind`/subscription wiring on observable collections (DynamicData) or services goes inside `this.WhenActivated(d => { ... }).DisposeWith(d)`, even when the source is just one of the view model's own properties. This is the established convention throughout the codebase (see `ConfigurationSectionDesignViewModel`, `StatusBarViewModel`, `PropertiesViewModel`) — follow it for consistency and so activation/deactivation cleanup is automatic if the subscription later grows an external dependency. +- To react to a *changing* collection-valued property (e.g. rebinding a child list when the parent object changes), use `this.WhenAnyValue(vm => vm.Parent).Select(p => p?.Children.ToObservableChangeSet() ?? Observable.Empty>()).Switch()`. DynamicData's `Switch()` on an `IObservable>>` clears the previously-bound results when the source switches — don't manually clear a `SourceList` in navigation code as a substitute for this. +- Prefer `SourceList`/`SourceCache` + `Connect()`/`ToObservableChangeSet()` + `.Transform(...).Bind(out ReadOnlyObservableCollection x)` over manually maintaining `ObservableCollection` in view models that wrap a model-level collection. +- DI is DryIoc (`Splat.DryIoc`); constructor injection throughout, view models are resolved via the container/`Func` factories for per-item view model creation in `Transform`. + +## Git + +- Current default branch: `master`. Commit message style seen in history is short and imperative, often prefixed with the feature area, e.g. `Workshop - Fix misalignment in EntryTypes causing plugins to not load`, `Surface editor - Show model instead of device name`. Follow that `Area - Change` style when the change is scoped to one area; a plain imperative summary is fine otherwise.