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:
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add dropdown option
+
+
+
-
\ 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.