Finish initial implementation with shitty UI

This commit is contained in:
Robert committed 2026-10-17 21:14:12 +02:00
1 parent eb812cb5e1
commit 964c5b6599
45 files changed
+1231 -198

No files matched your search

@@ -191,7 +191,8 @@ public class DataModelPath : IStorageModel, IDisposable, IPluginFeatureDependent
/// <inheritdoc />
public IEnumerable<PluginFeature> 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];
}
@@ -1,8 +1,71 @@
namespace Artemis.Core;
using System;
using System.Collections.Specialized;
namespace Artemis.Core;
/// <summary>
/// Represents a configuration item that accepts ColorGradient input from the user.
/// </summary>
public class ConfigurationColorGradientItem : ConfigurationInputItem<ColorGradient>
{
/// <summary>
/// Creates a new instance of the <see cref="ConfigurationColorGradientItem" /> class.
/// </summary>
public ConfigurationColorGradientItem()
{
// The gradient picker doesn't handle null values well
DefaultValue = ColorGradient.GetUnicornBarf();
Value = ColorGradient.GetUnicornBarf();
}
/// <inheritdoc />
protected override ColorGradient DataModelFallbackValue => new();
/// <inheritdoc />
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);
}
/// <inheritdoc />
protected override ColorGradient? CopyValue(ColorGradient? value)
{
return value != null ? new ColorGradient(value) : null;
}
/// <inheritdoc />
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));
}
}
@@ -1,10 +1,9 @@
namespace Artemis.Core;
namespace Artemis.Core;
/// <summary>
/// Represents a single option in a configuration dropdown list.
/// </summary>
/// <typeparam name="T">The type of the value that this dropdown option represents.</typeparam>
public class ConfigurationDropdownValue<T> : CorePropertyChanged
public abstract class ConfigurationDropdownValue : CorePropertyChanged
{
/// <summary>
/// Gets or sets the display name shown to the user for this dropdown option.
@@ -14,7 +13,14 @@ public class ConfigurationDropdownValue<T> : CorePropertyChanged
get;
set => SetAndNotify(ref field, value);
}
}
/// <summary>
/// Represents a single option in a configuration dropdown list.
/// </summary>
/// <typeparam name="T">The type of the value that this dropdown option represents.</typeparam>
public class ConfigurationDropdownValue<T> : ConfigurationDropdownValue
{
/// <summary>
/// Gets or sets the actual value associated with this dropdown option.
/// </summary>
@@ -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;
/// <summary>
/// Represents a generic base class for configuration items that accept user input.
/// Represents a base class for configuration items that accept user input.
/// </summary>
/// <typeparam name="T">The type of the value that this configuration item holds.</typeparam>
public class ConfigurationInputItem<T> : CorePropertyChanged, IConfigurationItem
public abstract class ConfigurationInputItem : CorePropertyChanged, IConfigurationItem
{
/// <inheritdoc />
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>
/// Gets or sets the display name of the configuration item.
/// </summary>
@@ -27,36 +36,208 @@ public class ConfigurationInputItem<T> : CorePropertyChanged, IConfigurationItem
}
/// <summary>
/// Gets or sets the current value of the configuration item.
/// Resets the value of the configuration item to a copy of its default value.
/// </summary>
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);
}
/// <summary>
/// Represents a generic base class for configuration items that accept user input.
/// </summary>
/// <typeparam name="T">The type of the value that this configuration item holds.</typeparam>
public abstract class ConfigurationInputItem<T> : ConfigurationInputItem
{
/// <summary>
/// Creates a new instance of the <see cref="ConfigurationInputItem{T}" /> class.
/// </summary>
protected ConfigurationInputItem()
{
DropdownValues.CollectionChanged += DropdownValuesOnCollectionChanged;
}
/// <summary>
/// Gets or sets the current value of the configuration item, as picked by the user.
/// </summary>
public T? Value
{
get;
set => SetAndNotify(ref field, value);
set
{
if (IsSameValue(field, value))
return;
T? previous = field;
field = value;
OnValueReplaced(previous, value);
OnPropertyChanged();
}
}
/// <summary>
/// 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.
/// </summary>
public T? DefaultValue
{
get;
set => SetAndNotify(ref field, value);
set
{
if (IsSameValue(field, value))
return;
T? previous = field;
field = value;
OnValueReplaced(previous, value);
OnPropertyChanged();
}
}
/// <summary>
/// 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.
/// </summary>
public ObservableCollection<ConfigurationDropdownValue<T>>? DropdownValues
public ObservableCollection<ConfigurationDropdownValue<T>> DropdownValues { get; } = [];
/// <summary>
/// Gets the value that is exposed to the data model when <see cref="Value" /> is <see langword="null" />.
/// </summary>
protected virtual T DataModelFallbackValue => default!;
/// <inheritdoc />
public override void ResetToDefault()
{
get;
set
Value = CopyValue(DefaultValue);
}
/// <summary>
/// Determines whether two values are considered the same, if so no change notification is raised.
/// </summary>
protected virtual bool IsSameValue(T? a, T? b)
{
return EqualityComparer<T?>.Default.Equals(a, b);
}
/// <summary>
/// Creates a copy of the provided value, override this for mutable reference types.
/// </summary>
protected virtual T? CopyValue(T? value)
{
return value;
}
/// <summary>
/// Called when <see cref="Value" /> or <see cref="DefaultValue" /> is replaced by another value.
/// </summary>
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<T> dropdownValue in e.OldItems)
dropdownValue.PropertyChanged -= DropdownValueOnPropertyChanged;
}
} = [];
}
if (e.NewItems != null)
{
foreach (ConfigurationDropdownValue<T> 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<T> {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<T>(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<T> typedDynamicChild)
typedDynamicChild.Value = Value ?? DataModelFallbackValue;
}
#endregion
}
@@ -1,25 +1,48 @@
namespace Artemis.Core;
using Artemis.Core.Modules;
namespace Artemis.Core;
/// <summary>
/// Represents a configuration item that accepts numeric input from the user.
/// </summary>
public class ConfigurationNumericItem : ConfigurationInputItem<Numeric>
{
/// <summary>
/// Gets or sets the minimum value the user can enter, or <see langword="null" /> for no minimum.
/// </summary>
public Numeric? Minimum
{
get;
set => SetAndNotify(ref field, value);
}
/// <summary>
/// Gets or sets the maximum value the user can enter, or <see langword="null" /> for no maximum.
/// </summary>
public Numeric? Maximum
{
get;
set => SetAndNotify(ref field, value);
}
/// <summary>
/// Gets or sets a boolean indicating whether the value is entered using a slider instead of a number box.
/// </summary>
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<float> typedDynamicChild)
typedDynamicChild.Value = Value;
}
}
@@ -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.
/// </summary>
public ObservableCollection<IConfigurationItem> 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
}
@@ -1,8 +1,10 @@
namespace Artemis.Core;
namespace Artemis.Core;
/// <summary>
/// Represents a configuration item that accepts string input from the user.
/// </summary>
public class ConfigurationStringItem : ConfigurationInputItem<string>
{
/// <inheritdoc />
protected override string DataModelFallbackValue => string.Empty;
}
@@ -1,10 +1,15 @@
namespace Artemis.Core;
using System;
namespace Artemis.Core;
/// <summary>
/// Represents a configuration item that displays static text.
/// </summary>
public class ConfigurationTextItem : CorePropertyChanged, IConfigurationItem
{
/// <inheritdoc />
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>
/// Gets or sets the text content to display.
/// </summary>
@@ -13,4 +18,4 @@ public class ConfigurationTextItem : CorePropertyChanged, IConfigurationItem
get;
set => SetAndNotify(ref field, value);
}
}
}
@@ -1,8 +1,15 @@
namespace Artemis.Core;
using System;
using System.ComponentModel;
namespace Artemis.Core;
/// <summary>
/// Defines a contract for configuration items that can be added to a configuration section.
/// </summary>
public interface IConfigurationItem
public interface IConfigurationItem : INotifyPropertyChanged
{
}
/// <summary>
/// Gets the unique ID of this configuration item, this ID remains stable between versions of a profile.
/// </summary>
Guid Id { get; }
}
@@ -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,
/// </summary>
public static readonly ProfileConfiguration Empty = new(ProfileCategory.Empty, "Empty", "Empty");
private readonly ProfileConfigurationDataModel _configurationDataModel = new();
private readonly HashSet<ConfigurationSection> _trackedSections = [];
private readonly HashSet<IConfigurationItem> _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<bool>("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<bool>("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();
}
}
/// <summary>
@@ -182,6 +195,16 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable,
/// </summary>
public ObservableCollection<ConfigurationSection> ConfigurationSections { get; } = [];
/// <summary>
/// Gets all configuration items in all <see cref="ConfigurationSections" /> that accept user input.
/// </summary>
public IEnumerable<ConfigurationInputItem> ConfigurationInputItems => ConfigurationSections.SelectMany(s => s.Items).OfType<ConfigurationInputItem>();
/// <summary>
/// Occurs when anything in the <see cref="ConfigurationSections" /> changed, including the values of items.
/// </summary>
public event EventHandler? ConfigurationChanged;
/// <summary>
/// Gets the icon configuration
/// </summary>
@@ -245,12 +268,44 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable,
return Module == null || Module.IsActivated;
}
/// <summary>
/// Resets the value of every configuration item to its default value.
/// </summary>
public void ResetConfigurationToDefaults()
{
foreach (ConfigurationInputItem item in ConfigurationInputItems)
item.ResetToDefault();
}
/// <summary>
/// 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.
/// </summary>
/// <param name="source">The profile configuration to copy the values from.</param>
public void CopyConfigurationValues(ProfileConfiguration source)
{
Dictionary<Guid, ConfigurationInputItem> 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());
}
}
/// <inheritdoc />
public override string ToString()
{
return $"[ProfileConfiguration] {nameof(Name)}: {Name}";
}
/// <summary>
/// Invokes the <see cref="ConfigurationChanged" /> event
/// </summary>
protected virtual void OnConfigurationChanged()
{
ConfigurationChanged?.Invoke(this, EventArgs.Empty);
}
internal void LoadModules(List<Module> enabledModules)
{
if (_disposed)
@@ -263,10 +318,128 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable,
/// <inheritdoc />
public void Dispose()
{
RemoveConfigurationDataModel();
_disposed = true;
ActivationCondition.Dispose();
}
#region Configuration
/// <summary>
/// Exposes the configuration values of this profile in the data model, done once the profile is part of the profile service.
/// </summary>
internal void AddConfigurationDataModel()
{
_configurationDataModelRegistered = true;
UpdateConfigurationDataModel();
}
/// <summary>
/// Removes the configuration values of this profile from the data model, done once the profile is removed from the profile service.
/// </summary>
internal void RemoveConfigurationDataModel()
{
_configurationDataModelRegistered = false;
UpdateConfigurationDataModel();
}
private void UpdateConfigurationDataModel()
{
if (_disposed || Entity == null)
return;
List<ConfigurationInputItem> items = ConfigurationInputItems.ToList();
// Sync the values of this profile
HashSet<string> 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<IConfigurationItem> 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
/// <inheritdoc />
public IEnumerable<PluginFeature> 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();
}
}
/// <inheritdoc />
@@ -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
@@ -0,0 +1,29 @@
using Artemis.Core.Modules;
namespace Artemis.Core;
/// <summary>
/// The data model containing the configured values of all configurable profiles, each profile is a dynamic child
/// keyed by its profile ID.
/// </summary>
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();
}
/// <summary>
/// The data model containing the configured values of a single profile, each value is a dynamic child keyed by its
/// configuration item ID.
/// </summary>
internal class ProfileConfigurationDataModel : DataModel;
@@ -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<Module> modules = _pluginManagementService.GetFeaturesOfType<Module>();
profileConfiguration.LoadModules(modules);
@@ -432,6 +451,15 @@ internal class ProfileService : IProfileService
return profileConfiguration;
}
private static T ReplaceInEntity<T>(T entity, string oldValue, string newValue)
{
string json = CoreJson.Serialize(entity);
if (!json.Contains(oldValue))
return entity;
return CoreJson.Deserialize<T>(json.Replace(oldValue, newValue)) ?? entity;
}
/// <inheritdoc />
public void AdaptProfile(Profile profile)
{
@@ -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; }
/// <summary>
/// The JSON-serialized default value as set by the author of the profile
/// </summary>
public string? DefaultValue { get; set; }
public List<ConfigurationDropdownValueEntity> DropdownValues { get; set; } = [];
}
public class ConfigurationDropdownValueEntity
{
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// The JSON-serialized value of the dropdown option
/// </summary>
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; }
}
@@ -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<IConfigurationItemEntity> Items { get; set; } = [];
}
@@ -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;
}
@@ -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; }
}
@@ -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<ConfigurationSectionEntity> ConfigurationSections { get; set; } = [];
/// <summary>
/// The JSON-serialized values the user picked for the configuration items, keyed by item ID
/// </summary>
public Dictionary<Guid, string> ConfigurationValues { get; set; } = [];
public int Version { get; set; }
}
@@ -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; }
}
public class ConfigurationBooleanItemViewModel(ConfigurationBooleanItem item) : ConfigurationInputItemViewModel<ConfigurationBooleanItem, bool>(item);
@@ -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; }
}
public class ConfigurationColorGradientItemViewModel(ConfigurationColorGradientItem item) : ConfigurationInputItemViewModel<ConfigurationColorGradientItem, ColorGradient>(item);
@@ -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<TItem, T> : ActivatableViewModelBase where TItem : ConfigurationInputItem<T>
{
protected ConfigurationInputItemViewModel(TItem item)
{
Item = item;
this.WhenActivated(d =>
{
Observable.FromEventPattern<PropertyChangedEventHandler, PropertyChangedEventArgs>(h => Item.PropertyChanged += h, h => Item.PropertyChanged -= h)
.Select(e => e.EventArgs.PropertyName)
.Where(p => p is nameof(ConfigurationInputItem<T>.Value) or nameof(ConfigurationInputItem<T>.DropdownValues))
.Subscribe(_ =>
{
this.RaisePropertyChanged(nameof(HasDropdown));
this.RaisePropertyChanged(nameof(SelectedDropdownValue));
})
.DisposeWith(d);
});
}
public TItem Item { get; }
/// <summary>
/// Gets a boolean indicating whether the user picks the value from a dropdown instead of entering it.
/// </summary>
public bool HasDropdown => Item.DropdownValues.Count > 0;
public ConfigurationDropdownValue<T>? SelectedDropdownValue
{
get => Item.DropdownValues.FirstOrDefault(d => Equals(d.Value, Item.Value));
set
{
if (value != null)
Item.Value = value.Value;
}
}
}
@@ -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}}" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center">
<controls:NumberBox Width="150" IsVisible="{CompiledBinding !Item.Slider}">
<Interaction.Behaviors>
<behaviors:LostFocusNumberBoxBindingBehavior Value="{CompiledBinding Item.Value}" />
</Interaction.Behaviors>
</controls:NumberBox>
<Slider Minimum="{CompiledBinding Item.Minimum}"
Maximum="{CompiledBinding Item.Maximum}"
Width="150"
IsVisible="{CompiledBinding Item.Slider}"
Value="{CompiledBinding Item.Value, Mode=TwoWay}" />
</StackPanel>
<Panel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center">
<Panel IsVisible="{CompiledBinding !HasDropdown}">
<controls:NumberBox Width="150"
Minimum="{CompiledBinding Item.Minimum, TargetNullValue={x:Static system:Double.MinValue}}"
Maximum="{CompiledBinding Item.Maximum, TargetNullValue={x:Static system:Double.MaxValue}}"
IsVisible="{CompiledBinding !Item.Slider}">
<Interaction.Behaviors>
<behaviors:LostFocusNumberBoxBindingBehavior Value="{CompiledBinding Item.Value}" />
</Interaction.Behaviors>
</controls:NumberBox>
<Slider Minimum="{CompiledBinding Item.Minimum, TargetNullValue=0}"
Maximum="{CompiledBinding Item.Maximum, TargetNullValue=100}"
Width="150"
IsVisible="{CompiledBinding Item.Slider}"
Value="{CompiledBinding Item.Value, Mode=TwoWay}" />
</Panel>
<ComboBox ItemsSource="{CompiledBinding Item.DropdownValues}"
SelectedItem="{CompiledBinding SelectedDropdownValue}"
IsVisible="{CompiledBinding HasDropdown}"
Width="150">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="core:ConfigurationDropdownValue">
<TextBlock Text="{CompiledBinding DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</Panel>
</Grid>
</UserControl>
</UserControl>
@@ -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; }
}
public class ConfigurationNumericItemViewModel(ConfigurationNumericItem item) : ConfigurationInputItemViewModel<ConfigurationNumericItem, Numeric>(item);
@@ -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; }
}
public class ConfigurationSKColorItemViewModel(ConfigurationSKColorItem item) : ConfigurationInputItemViewModel<ConfigurationSKColorItem, SKColor>(item);
@@ -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}}" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center">
<TextBox Text="{CompiledBinding Item.Value, Mode=TwoWay}" MinWidth="150" />
<TextBox Text="{CompiledBinding Item.Value, Mode=TwoWay}" MinWidth="150" IsVisible="{CompiledBinding !HasDropdown}" />
<ComboBox ItemsSource="{CompiledBinding Item.DropdownValues}"
SelectedItem="{CompiledBinding SelectedDropdownValue}"
IsVisible="{CompiledBinding HasDropdown}"
MinWidth="150">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="core:ConfigurationDropdownValue">
<TextBlock Text="{CompiledBinding DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Grid>
</UserControl>
</UserControl>
@@ -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; }
}
public class ConfigurationStringItemViewModel(ConfigurationStringItem item) : ConfigurationInputItemViewModel<ConfigurationStringItem, string>(item);
@@ -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<ActivatableViewModelBase> Items { get; }
[Notify] private ReadOnlyObservableCollection<ActivatableViewModelBase>? _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<ActivatableViewModelBase> 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()}")
};
}
}
@@ -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<ProfileViewModel
{
private readonly IProfileService _profileService;
private readonly IProfileEditorService _profileEditorService;
private readonly SourceList<ConfigurationSection> _configurationSections;
[Notify] private ProfileConfiguration? _profileConfiguration;
[Notify] private ReadOnlyObservableCollection<ConfigurationSectionViewModel>? _sections;
public ConfigureProfileViewModel(IProfileService profileService, IProfileEditorService profileEditorService, PreviewViewModel previewViewModel,
Func<ConfigurationSection, ConfigurationSectionViewModel> getConfigurationSectionViewModel)
@@ -31,17 +35,31 @@ public partial class ConfigureProfileViewModel : RoutableScreen<ProfileViewModel
PreviewViewModel = previewViewModel;
_configurationSections = new SourceList<ConfigurationSection>();
_configurationSections.Connect()
.Transform(getConfigurationSectionViewModel)
.Bind(out ReadOnlyObservableCollection<ConfigurationSectionViewModel> sections)
.Subscribe();
Sections = sections;
this.WhenActivated(d =>
{
this.WhenAnyValue(vm => vm.ProfileConfiguration)
.Select(p => p?.ConfigurationSections.ToObservableChangeSet() ?? Observable.Empty<IChangeSet<ConfigurationSection>>())
.Switch()
.Transform(getConfigurationSectionViewModel)
.Bind(out ReadOnlyObservableCollection<ConfigurationSectionViewModel> 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<ProfileConfiguration>())
.Switch()
.Throttle(TimeSpan.FromMilliseconds(500))
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(p => _profileService.SaveProfileCategory(p.Category))
.DisposeWith(d);
});
}
public PreviewViewModel PreviewViewModel { get; }
public ReadOnlyObservableCollection<ConfigurationSectionViewModel> Sections { get; private set; }
/// <inheritdoc />
public override async Task OnNavigating(ProfileViewModelParameters parameters, NavigationArguments args, CancellationToken cancellationToken)
@@ -57,16 +75,15 @@ public partial class ConfigureProfileViewModel : RoutableScreen<ProfileViewModel
await _profileEditorService.ChangeCurrentProfileConfiguration(profileConfiguration);
ProfileConfiguration = profileConfiguration;
_configurationSections.Edit(editableSections =>
{
editableSections.Clear();
editableSections.AddRange(profileConfiguration.ConfigurationSections);
});
}
/// <inheritdoc />
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;
@@ -18,7 +18,7 @@
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1">
<TextBlock Margin="0 14 0 4">Default:</TextBlock>
<ToggleSwitch IsChecked="{CompiledBinding BooleanItem.Value}"
<ToggleSwitch IsChecked="{CompiledBinding BooleanItem.DefaultValue}"
OnContent="{CompiledBinding BooleanItem.TrueText}"
OffContent="{CompiledBinding BooleanItem.FalseText}"
Width="150"
@@ -2,7 +2,8 @@
namespace Artemis.UI.Screens.ProfileEditor.DesignPanels.Items;
public class ConfigurationBooleanItemDesignViewModel(ConfigurationBooleanItem item, ConfigurationSection configurationSection) : ConfigurationItemDesignViewModel(item, configurationSection)
public class ConfigurationBooleanItemDesignViewModel(ConfigurationBooleanItem item, ConfigurationSection configurationSection)
: ConfigurationInputItemDesignViewModel<ConfigurationBooleanItem, bool>(item, configurationSection)
{
public ConfigurationBooleanItem BooleanItem { get; } = item;
}
}
@@ -16,7 +16,7 @@
<StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center">
<TextBlock Margin="0 14 0 4">Default:</TextBlock>
<gradientPicker:GradientPickerButton Width="150"
ColorGradient="{CompiledBinding GradientItem.Value}"
ColorGradient="{CompiledBinding GradientItem.DefaultValue}"
VerticalAlignment="Center" />
</StackPanel>
<Button Grid.Row="0" Grid.Column="1" ToolTip.Tip="Remove" HorizontalAlignment="Right" VerticalAlignment="Top" Classes="icon-button-outlined" Command="{CompiledBinding RemoveItem}">
@@ -2,16 +2,8 @@
namespace Artemis.UI.Screens.ProfileEditor.DesignPanels.Items;
public class ConfigurationColorGradientItemDesignViewModel : ConfigurationItemDesignViewModel
public class ConfigurationColorGradientItemDesignViewModel(ConfigurationColorGradientItem item, ConfigurationSection configurationSection)
: ConfigurationInputItemDesignViewModel<ConfigurationColorGradientItem, ColorGradient>(item, configurationSection)
{
public ConfigurationColorGradientItemDesignViewModel(ConfigurationColorGradientItem item, ConfigurationSection configurationSection) : base(item, configurationSection)
{
// The gradient picker doesn't handle null values well
item.DefaultValue ??= ColorGradient.GetUnicornBarf();
item.Value ??= ColorGradient.GetUnicornBarf();
GradientItem = item;
}
public ConfigurationColorGradientItem GradientItem { get; }
}
public ConfigurationColorGradientItem GradientItem { get; } = item;
}
@@ -0,0 +1,90 @@
using System;
using System.ComponentModel;
using System.Linq;
using System.Reactive;
using System.Reactive.Disposables.Fluent;
using System.Reactive.Linq;
using Artemis.Core;
using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.DesignPanels.Items;
public abstract class ConfigurationInputItemDesignViewModel<TItem, T> : ConfigurationItemDesignViewModel where TItem : ConfigurationInputItem<T>
{
protected ConfigurationInputItemDesignViewModel(TItem item, ConfigurationSection configurationSection) : base(item, configurationSection)
{
InputItem = item;
RemoveDropdownValue = ReactiveCommand.Create<ConfigurationDropdownValue<T>>(ExecuteRemoveDropdownValue);
this.WhenActivated(d =>
{
IObservable<string?> propertyChanged = Observable.FromEventPattern<PropertyChangedEventHandler, PropertyChangedEventArgs>(
h => InputItem.PropertyChanged += h,
h => InputItem.PropertyChanged -= h)
.Select(e => e.EventArgs.PropertyName);
// While designing, the value follows the default so that the preview shows what users will initially get
propertyChanged.Where(p => p == nameof(ConfigurationInputItem<T>.DefaultValue))
.Subscribe(_ =>
{
InputItem.ResetToDefault();
this.RaisePropertyChanged(nameof(SelectedDefaultDropdownValue));
})
.DisposeWith(d);
propertyChanged.Where(p => p == nameof(ConfigurationInputItem<T>.DropdownValues))
.Subscribe(_ =>
{
this.RaisePropertyChanged(nameof(HasDropdown));
this.RaisePropertyChanged(nameof(SelectedDefaultDropdownValue));
})
.DisposeWith(d);
});
}
public TItem InputItem { get; }
/// <summary>
/// Gets a boolean indicating whether users pick the value from a dropdown instead of entering it.
/// </summary>
public bool HasDropdown => InputItem.DropdownValues.Count > 0;
public ConfigurationDropdownValue<T>? SelectedDefaultDropdownValue
{
get => InputItem.DropdownValues.FirstOrDefault(d => Equals(d.Value, InputItem.DefaultValue));
set
{
if (value != null)
InputItem.DefaultValue = value.Value;
}
}
public ReactiveCommand<ConfigurationDropdownValue<T>, Unit> RemoveDropdownValue { get; }
public void AddDropdownValue()
{
int number = InputItem.DropdownValues.Count + 1;
ConfigurationDropdownValue<T> dropdownValue = new() {DisplayName = $"Option {number}", Value = CreateDropdownValue(number)};
InputItem.DropdownValues.Add(dropdownValue);
// The default should always be one of the options
if (SelectedDefaultDropdownValue == null)
InputItem.DefaultValue = dropdownValue.Value;
}
/// <summary>
/// Creates the value of a newly added dropdown option.
/// </summary>
/// <param name="number">The number of the new option, starting at 1.</param>
protected virtual T CreateDropdownValue(int number)
{
throw new NotSupportedException($"{GetType().Name} does not support dropdown values");
}
private void ExecuteRemoveDropdownValue(ConfigurationDropdownValue<T> dropdownValue)
{
InputItem.DropdownValues.Remove(dropdownValue);
if (HasDropdown && SelectedDefaultDropdownValue == null)
InputItem.DefaultValue = InputItem.DropdownValues[0].Value;
}
}
@@ -6,14 +6,21 @@
xmlns:behaviors="clr-namespace:Artemis.UI.Shared.Behaviors;assembly=Artemis.UI.Shared"
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"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.DesignPanels.Items.ConfigurationNumericItemDesignView"
x:DataType="items:ConfigurationNumericItemDesignViewModel">
<Grid RowDefinitions="*,*" ColumnDefinitions="*,Auto" ColumnSpacing="5">
<Grid RowDefinitions="*,Auto" ColumnDefinitions="*,Auto" ColumnSpacing="5">
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="5">
<TextBox Text="{CompiledBinding NumericItem.Name}" Watermark="Name" />
<TextBox Text="{CompiledBinding NumericItem.Description}" Watermark="Description" />
<Grid ColumnDefinitions="*,*" ColumnSpacing="5" IsVisible="{CompiledBinding !HasDropdown}">
<controls:NumberBox Grid.Column="0" Value="{CompiledBinding Minimum}" PlaceholderText="No minimum" ToolTip.Tip="Minimum" />
<controls:NumberBox Grid.Column="1" Value="{CompiledBinding Maximum}" PlaceholderText="No maximum" ToolTip.Tip="Maximum" />
</Grid>
<ToggleSwitch IsChecked="{CompiledBinding NumericItem.Slider}"
IsVisible="{CompiledBinding !HasDropdown}"
OnContent="Slider"
OffContent="Number box"
MinWidth="0"
@@ -21,20 +28,71 @@
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Top">
<TextBlock Margin="0 14 0 4">Default:</TextBlock>
<controls:NumberBox Width="150" IsVisible="{CompiledBinding !NumericItem.Slider}">
<Interaction.Behaviors>
<behaviors:LostFocusNumberBoxBindingBehavior Value="{CompiledBinding NumericItem.Value}" />
</Interaction.Behaviors>
</controls:NumberBox>
<Slider Minimum="{CompiledBinding NumericItem.Minimum}"
Maximum="{CompiledBinding NumericItem.Maximum}"
Width="150"
Margin="0 -5 0 0"
IsVisible="{CompiledBinding NumericItem.Slider}"
Value="{CompiledBinding NumericItem.Value, Mode=TwoWay}" />
<Panel IsVisible="{CompiledBinding !HasDropdown}">
<controls:NumberBox Width="150"
Minimum="{CompiledBinding NumericItem.Minimum, TargetNullValue={x:Static system:Double.MinValue}}"
Maximum="{CompiledBinding NumericItem.Maximum, TargetNullValue={x:Static system:Double.MaxValue}}"
IsVisible="{CompiledBinding !NumericItem.Slider}">
<Interaction.Behaviors>
<behaviors:LostFocusNumberBoxBindingBehavior Value="{CompiledBinding NumericItem.DefaultValue}" />
</Interaction.Behaviors>
</controls:NumberBox>
<Slider Minimum="{CompiledBinding NumericItem.Minimum, TargetNullValue=0}"
Maximum="{CompiledBinding NumericItem.Maximum, TargetNullValue=100}"
Width="150"
Margin="0 -5 0 0"
IsVisible="{CompiledBinding NumericItem.Slider}"
Value="{CompiledBinding NumericItem.DefaultValue, Mode=TwoWay}" />
</Panel>
<ComboBox ItemsSource="{CompiledBinding NumericItem.DropdownValues}"
SelectedItem="{CompiledBinding SelectedDefaultDropdownValue}"
IsVisible="{CompiledBinding HasDropdown}"
Width="150">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="core:ConfigurationDropdownValue">
<TextBlock Text="{CompiledBinding DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<Button Grid.Row="0" Grid.Column="1" ToolTip.Tip="Remove" HorizontalAlignment="Right" VerticalAlignment="Top" Classes="icon-button-outlined" Command="{CompiledBinding RemoveItem}">
<avalonia:MaterialIcon Kind="Trash" />
</Button>
<StackPanel Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" Margin="0 8 0 0" Spacing="5">
<TextBlock Classes="subtitle" IsVisible="{CompiledBinding HasDropdown}">Dropdown options</TextBlock>
<ItemsControl ItemsSource="{CompiledBinding NumericItem.DropdownValues}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="5" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="core:ConfigurationDropdownValue">
<Grid ColumnDefinitions="*,*,Auto" ColumnSpacing="5">
<TextBox Grid.Column="0" Text="{CompiledBinding DisplayName}" Watermark="Display name" />
<controls:NumberBox Grid.Column="1" ToolTip.Tip="Value">
<Interaction.Behaviors>
<behaviors:LostFocusNumberBoxBindingBehavior Value="{Binding Value}" />
</Interaction.Behaviors>
</controls:NumberBox>
<Button Grid.Column="2"
ToolTip.Tip="Remove option"
Classes="icon-button"
Command="{CompiledBinding $parent[ItemsControl].((items:ConfigurationNumericItemDesignViewModel)DataContext).RemoveDropdownValue}"
CommandParameter="{CompiledBinding}">
<avalonia:MaterialIcon Kind="Close" />
</Button>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Command="{CompiledBinding AddDropdownValue}" ToolTip.Tip="Let users pick the value from a dropdown instead of entering a number">
<StackPanel Orientation="Horizontal" Spacing="5">
<avalonia:MaterialIcon Kind="FormatListBulleted" />
<TextBlock>Add dropdown option</TextBlock>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</UserControl>
</UserControl>
@@ -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<ConfigurationNumericItem, Numeric>
{
public ConfigurationNumericItem NumericItem { get; } = item;
public ConfigurationNumericItemDesignViewModel(ConfigurationNumericItem item, ConfigurationSection configurationSection) : base(item, configurationSection)
{
NumericItem = item;
this.WhenActivated(d =>
{
Observable.FromEventPattern<PropertyChangedEventHandler, PropertyChangedEventArgs>(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;
}
/// <inheritdoc />
protected override Numeric CreateDropdownValue(int number)
{
return number;
}
}
@@ -15,7 +15,7 @@
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center">
<TextBlock Margin="0 14 0 4">Default:</TextBlock>
<controls:ColorPickerButton Color="{CompiledBinding ColorItem.Value, Converter={StaticResource SKColorToColorConverter}}"
<controls:ColorPickerButton Color="{CompiledBinding ColorItem.DefaultValue, Converter={StaticResource SKColorToColorConverter}}"
Width="150"
ShowAcceptDismissButtons="False"
FlyoutPlacement="Right" />
@@ -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<ConfigurationSKColorItem, SKColor>(item, configurationSection)
{
public ConfigurationSKColorItem ColorItem { get; } = item;
}
}
@@ -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">
<Grid RowDefinitions="*,*" ColumnDefinitions="*,Auto" ColumnSpacing="5">
<Grid RowDefinitions="*,Auto" ColumnDefinitions="*,Auto" ColumnSpacing="5">
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="5">
<TextBox Text="{CompiledBinding StringItem.Name}" Watermark="Name" />
<TextBox Text="{CompiledBinding StringItem.Description}" Watermark="Description" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center">
<TextBlock Margin="0 14 0 4">Default:</TextBlock>
<TextBox Text="{CompiledBinding StringItem.Value, Mode=TwoWay}" MinWidth="150" />
<TextBox Text="{CompiledBinding StringItem.DefaultValue, Mode=TwoWay}" MinWidth="150" IsVisible="{CompiledBinding !HasDropdown}" />
<ComboBox ItemsSource="{CompiledBinding StringItem.DropdownValues}"
SelectedItem="{CompiledBinding SelectedDefaultDropdownValue}"
IsVisible="{CompiledBinding HasDropdown}"
MinWidth="150">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="core:ConfigurationDropdownValue">
<TextBlock Text="{CompiledBinding DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<Button Grid.Row="0" Grid.Column="1" ToolTip.Tip="Remove" HorizontalAlignment="Right" VerticalAlignment="Top" Classes="icon-button-outlined" Command="{CompiledBinding RemoveItem}">
<avalonia:MaterialIcon Kind="Trash" />
</Button>
<StackPanel Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" Margin="0 8 0 0" Spacing="5">
<TextBlock Classes="subtitle" IsVisible="{CompiledBinding HasDropdown}">Dropdown options</TextBlock>
<ItemsControl ItemsSource="{CompiledBinding StringItem.DropdownValues}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="5" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="core:ConfigurationDropdownValue">
<Grid ColumnDefinitions="*,*,Auto" ColumnSpacing="5">
<TextBox Grid.Column="0" Text="{CompiledBinding DisplayName}" Watermark="Display name" />
<TextBox Grid.Column="1" Text="{Binding Value}" Watermark="Value" />
<Button Grid.Column="2"
ToolTip.Tip="Remove option"
Classes="icon-button"
Command="{CompiledBinding $parent[ItemsControl].((items:ConfigurationStringItemDesignViewModel)DataContext).RemoveDropdownValue}"
CommandParameter="{CompiledBinding}">
<avalonia:MaterialIcon Kind="Close" />
</Button>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Command="{CompiledBinding AddDropdownValue}" ToolTip.Tip="Let users pick the value from a dropdown instead of typing it">
<StackPanel Orientation="Horizontal" Spacing="5">
<avalonia:MaterialIcon Kind="FormatListBulleted" />
<TextBlock>Add dropdown option</TextBlock>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</UserControl>
</UserControl>
@@ -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<ConfigurationStringItem, string>(item, configurationSection)
{
public ConfigurationStringItem StringItem { get; } = item;
}
/// <inheritdoc />
protected override string CreateDropdownValue(int number)
{
return $"Option {number}";
}
}
@@ -1,4 +1,4 @@
using Artemis.Core;
using Artemis.Core;
using Artemis.UI.Shared;
namespace Artemis.UI.Screens.ProfileEditor.DesignPanels.Items;
@@ -13,7 +13,7 @@ public partial class AddConfigurationItemDesignView : ReactiveUserControl<AddCon
private void SelectingItemsControl_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (e.AddedItems[0] is not ListBoxItem item) return;
if (e.AddedItems.Count == 0 || e.AddedItems[0] is not ListBoxItem item) return;
if (item == BooleanInputItem)
ViewModel?.AddItem(new ConfigurationBooleanItem {Name = "New Boolean"});
@@ -38,7 +38,7 @@ public partial class ConfigurationSectionDesignViewModel : ActivatableViewModelB
public ConfigurationSection ConfigurationSection { get; }
public async Task RemoveSection()
public void RemoveSection()
{
ConfigurationSection.ProfileConfiguration.ConfigurationSections.Remove(ConfigurationSection);
}
@@ -46,6 +46,16 @@ public partial class DesignProfileViewModel : RoutableScreen<ProfileViewModelPar
.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<ProfileConfiguration>())
.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<ProfileViewModelPar
/// <inheritdoc />
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<ProfileViewModelPar
}
}
public async Task AddSection()
public void AddSection()
{
ProfileConfiguration?.ConfigurationSections.Add(new ConfigurationSection(ProfileConfiguration)
{
@@ -44,7 +44,10 @@ public class ProfileEntryInstallationHandler : IEntryInstallationHandler
{
ProfileConfiguration overwritten = await _profileService.ImportProfile(stream, existing.Category, true, false, null, existing);
overwritten.Name = existing.Name;
// Keep whatever the user configured in the previous version
overwritten.CopyConfigurationValues(existing);
_profileService.SaveProfileCategory(overwritten.Category);
// Update the release
installedEntry.SetMetadata("ProfileId", overwritten.ProfileId);
UpdateRelease(installedEntry, release);
@@ -62,6 +65,9 @@ public class ProfileEntryInstallationHandler : IEntryInstallationHandler
// Add the profile as a fresh import
ProfileCategory category = _profileService.ProfileCategories.FirstOrDefault(c => 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
+1
View File
@@ -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}"
+35
View File
@@ -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<TParam>` (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<IChangeSet<T>>()).Switch()`. DynamicData's `Switch()` on an `IObservable<IObservable<IChangeSet<T>>>` 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<T>`/`SourceCache<T>` + `Connect()`/`ToObservableChangeSet()` + `.Transform(...).Bind(out ReadOnlyObservableCollection<TViewModel> x)` over manually maintaining `ObservableCollection<TViewModel>` 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<TModel, TViewModel>` 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.