diff --git a/EvoSC.sln b/EvoSC.sln index 63b041ee4..e5e0c1fab 100644 --- a/EvoSC.sln +++ b/EvoSC.sln @@ -46,7 +46,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EvoSC.Manialinks.Tests", "t EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SetName", "src\Modules\SetName\SetName.csproj", "{568D81FE-858A-4052-B59B-9381E0FE604C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FastestCp", "src\Modules\FastestCp\FastestCp.csproj", "{9E1335F9-6C39-4B3F-9CEB-A65EEDDF798D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FastestCpModule", "src\Modules\FastestCpModule\FastestCpModule.csproj", "{9E1335F9-6C39-4B3F-9CEB-A65EEDDF798D}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{6D75D6A2-6ECD-4DE4-96C5-CAD7D134407A}" EndProject @@ -104,6 +104,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModuleManagerModule", "src\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatchTrackerModule.Tests", "tests\Modules\MatchTrackerModule.Tests\MatchTrackerModule.Tests.csproj", "{9EF4D340-0C49-4A15-9BCF-6CD9508AA7DE}" EndProject + + + Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/src/EvoSC.Commands/Parser/ChatCommandParser.cs b/src/EvoSC.Commands/Parser/ChatCommandParser.cs index 910ffe70d..5234b0fa8 100644 --- a/src/EvoSC.Commands/Parser/ChatCommandParser.cs +++ b/src/EvoSC.Commands/Parser/ChatCommandParser.cs @@ -1,6 +1,5 @@ using EvoSC.Commands.Exceptions; using EvoSC.Commands.Interfaces; -using EvoSC.Common.Interfaces.Parsing; namespace EvoSC.Commands.Parser; diff --git a/src/EvoSC.Common/Application/AppFeature.cs b/src/EvoSC.Common/Application/AppFeature.cs index cc04675d2..20e8a45fc 100644 --- a/src/EvoSC.Common/Application/AppFeature.cs +++ b/src/EvoSC.Common/Application/AppFeature.cs @@ -115,5 +115,11 @@ public enum AppFeature /// Manage, add, remove, display manialinks and respond to actions. The manialink framework. /// [Identifier(NoPrefix = true)] - Manialinks + Manialinks, + + /// + /// Add, remove and manage themes using the theme manager. + /// + [Identifier(NoPrefix = true)] + Themes } diff --git a/src/EvoSC.Common/Config/Configuration.cs b/src/EvoSC.Common/Config/Configuration.cs index d396fa270..dc834026c 100644 --- a/src/EvoSC.Common/Config/Configuration.cs +++ b/src/EvoSC.Common/Config/Configuration.cs @@ -1,8 +1,8 @@ using Config.Net; -using EvoSC.Common.Config.Models; -using EvoSC.Common.Config.Stores; using EvoSC.Common.Config.Mapping; using EvoSC.Common.Config.Mapping.Toml; +using EvoSC.Common.Config.Models; +using EvoSC.Common.Config.Stores; namespace EvoSC.Common.Config; @@ -23,6 +23,7 @@ public static IEvoScBaseConfig GetBaseConfig(string configFile, Dictionary(); + + foreach (var entry in doc.Entries) + { + GetEntriesRecursive(entry.Key, entry.Value, options); + } + + result = new DynamicThemeOptions(options); + return true; + } + + public string? ToRawString(object? value) + { + return ""; + } + + public IEnumerable SupportedTypes => new[] { typeof(DynamicThemeOptions) }; + + private void GetEntriesRecursive(string name, TomlValue tomlValue, Dictionary options) + { + if (tomlValue is TomlTable table) + { + foreach (var entry in table.Entries) + { + GetEntriesRecursive($"{name}.{entry.Key}", entry.Value, options); + } + } + else + { + options[name] = tomlValue.StringValue; + } + } +} diff --git a/src/EvoSC.Common/Config/Mapping/Toml/ConfigMapper.cs b/src/EvoSC.Common/Config/Mapping/Toml/ConfigMapper.cs index db005754d..2b6e46f2d 100644 --- a/src/EvoSC.Common/Config/Mapping/Toml/ConfigMapper.cs +++ b/src/EvoSC.Common/Config/Mapping/Toml/ConfigMapper.cs @@ -11,5 +11,6 @@ public static void AddMapper(ITomlTypeMapper mapper) => public static void SetupDefaultMappers() { AddMapper(new TextColorTomlMapper()); + AddMapper(new ThemeConfigOptionsMapper()); } } diff --git a/src/EvoSC.Common/Config/Mapping/Toml/ThemeConfigOptionsMapper.cs b/src/EvoSC.Common/Config/Mapping/Toml/ThemeConfigOptionsMapper.cs new file mode 100644 index 000000000..288d72b64 --- /dev/null +++ b/src/EvoSC.Common/Config/Mapping/Toml/ThemeConfigOptionsMapper.cs @@ -0,0 +1,82 @@ +using EvoSC.Common.Interfaces.Config.Mapping; +using EvoSC.Common.Themes; +using Tomlet; +using Tomlet.Models; + +namespace EvoSC.Common.Config.Mapping.Toml; + +public class ThemeConfigOptionsMapper : ITomlTypeMapper +{ + public TomlValue Serialize(DynamicThemeOptions? typeValue) + { + var doc = TomlDocument.CreateEmpty(); + + foreach (var option in typeValue) + { + BuildTomlDocument(doc, option.Key.Split('.'), option.Value); + } + + return doc; + } + + public DynamicThemeOptions Deserialize(TomlValue tomlValue) + { + var doc = tomlValue as TomlDocument; + + if (doc == null) + { + throw new InvalidOperationException("Value is not a document"); + } + + var options = new DynamicThemeOptions(); + + foreach (var entry in doc.Entries) + { + BuildOptionsObject(options, entry.Key, entry.Value); + } + + return options; + } + + private void BuildTomlDocument(TomlDocument doc, IEnumerable optionParts, object value) + { + var parts = optionParts as string[] ?? optionParts.ToArray(); + + if (parts.Length == 1) + { + var tomlValue = TomletMain.ValueFrom(value); + doc.Entries[parts.First()] = tomlValue; + return; + } + + foreach (var part in parts) + { + if (doc.ContainsKey(part)) + { + BuildTomlDocument((TomlDocument)doc.Entries[part], parts.Skip(1), value); + } + else + { + var newDoc = TomlDocument.CreateEmpty(); + doc.Entries[part] = newDoc; + BuildTomlDocument(newDoc, parts.Skip(1), value); + } + } + } + + private void BuildOptionsObject(DynamicThemeOptions options, string key, TomlValue tomlValue) + { + var doc = tomlValue as TomlDocument; + + if (doc == null) + { + options[key] = tomlValue.StringValue; + return; + } + + foreach (var entry in doc.Entries) + { + BuildOptionsObject(options, $"{key}.{entry.Key}", entry.Value); + } + } +} diff --git a/src/EvoSC.Common/Config/Models/IEvoScBaseConfig.cs b/src/EvoSC.Common/Config/Models/IEvoScBaseConfig.cs index 84ef9f67a..9008fb823 100644 --- a/src/EvoSC.Common/Config/Models/IEvoScBaseConfig.cs +++ b/src/EvoSC.Common/Config/Models/IEvoScBaseConfig.cs @@ -1,4 +1,6 @@ -namespace EvoSC.Common.Config.Models; +using EvoSC.Common.Themes; + +namespace EvoSC.Common.Config.Models; public interface IEvoScBaseConfig { @@ -6,7 +8,9 @@ public interface IEvoScBaseConfig public ILoggingConfig Logging { get; set; } public IServerConfig Server { get; set; } public IPathConfig Path { get; set; } - public IThemeConfig Theme { get; set; } + public IModuleConfig Modules { get; set; } public ILocaleConfig Locale { get; set; } + + public DynamicThemeOptions Theme { get; set; } } diff --git a/src/EvoSC.Common/Config/Stores/TomlConfigStore.cs b/src/EvoSC.Common/Config/Stores/TomlConfigStore.cs index b8973c62c..6c70f313f 100644 --- a/src/EvoSC.Common/Config/Stores/TomlConfigStore.cs +++ b/src/EvoSC.Common/Config/Stores/TomlConfigStore.cs @@ -1,12 +1,11 @@ using System.ComponentModel; -using System.Drawing; -using System.IO; +using System.Globalization; using System.Reflection; using Config.Net; +using EvoSC.Common.Themes; using EvoSC.Common.Util; using EvoSC.Common.Util.TextFormatting; using Tomlet; -using Tomlet.Exceptions; using Tomlet.Models; namespace EvoSC.Common.Config.Stores; @@ -53,6 +52,11 @@ private TomlDocument BuildSubDocument(TomlDocument document, Type type, string n { foreach (var property in type.GetProperties()) { + if (property.PropertyType == typeof(DynamicThemeOptions)) + { + continue; + } + if (property.PropertyType.IsInterface) { document = BuildSubDocument(document, property.PropertyType, name == "" ? property.Name : $"{name}.{property.Name}"); @@ -68,7 +72,9 @@ private TomlDocument BuildSubDocument(TomlDocument document, Type type, string n var tomlValue = optionAttr?.DefaultValue ?? property.PropertyType.GetDefaultTypeValue(); if (property.PropertyType == typeof(TextColor)) + { tomlValue = new TextColor(tomlValue.ToString()); + } // get property value var value = TomletMain.ValueFrom(property.PropertyType, @@ -100,13 +106,13 @@ public void Dispose() if (lastDotIndex > 0 && key.Length > lastDotIndex + 1 && !char.IsAsciiLetterOrDigit(key[lastDotIndex + 1])) { var value = _document.GetValue(key[..lastDotIndex]) as TomlArray; - return value.Count.ToString(); + return value.Count.ToString(CultureInfo.InvariantCulture); } if (key.EndsWith("]", StringComparison.Ordinal)) { var indexStart = key.IndexOf("[", StringComparison.Ordinal); - var index = int.Parse(key[(indexStart + 1)..^1]); + var index = int.Parse(key[(indexStart + 1)..^1], CultureInfo.InvariantCulture); var value = _document.GetValue(key[..indexStart]) as TomlArray; return value?.Skip(index)?.FirstOrDefault()?.StringValue; @@ -118,6 +124,11 @@ public void Dispose() { return string.Join(" ", arrayValue.Select(v => v.StringValue)); } + + if (keyValue is TomlTable tableValue) + { + return tableValue.SerializeNonInlineTable("Theme", false); + } return keyValue.StringValue; } diff --git a/src/EvoSC.Common/Database/Repository/DbRepository.cs b/src/EvoSC.Common/Database/Repository/DbRepository.cs index 7d5901b23..9b9206d11 100644 --- a/src/EvoSC.Common/Database/Repository/DbRepository.cs +++ b/src/EvoSC.Common/Database/Repository/DbRepository.cs @@ -1,7 +1,6 @@  using EvoSC.Common.Interfaces.Database; using LinqToDB; -using LinqToDB.Data; namespace EvoSC.Common.Database.Repository; diff --git a/src/EvoSC.Common/EvoSC.Common.csproj b/src/EvoSC.Common/EvoSC.Common.csproj index 782964c9f..9e99a4cd1 100644 --- a/src/EvoSC.Common/EvoSC.Common.csproj +++ b/src/EvoSC.Common/EvoSC.Common.csproj @@ -13,6 +13,7 @@ + diff --git a/src/EvoSC.Common/Interfaces/Database/IDbConnectionFactory.cs b/src/EvoSC.Common/Interfaces/Database/IDbConnectionFactory.cs index 2a7eb8039..2ebe1cbcb 100644 --- a/src/EvoSC.Common/Interfaces/Database/IDbConnectionFactory.cs +++ b/src/EvoSC.Common/Interfaces/Database/IDbConnectionFactory.cs @@ -1,5 +1,4 @@ using LinqToDB; -using LinqToDB.Data; namespace EvoSC.Common.Interfaces.Database; diff --git a/src/EvoSC.Common/Interfaces/Services/IServiceContainerManager.cs b/src/EvoSC.Common/Interfaces/Services/IServiceContainerManager.cs index f98c373d5..d750bc7e9 100644 --- a/src/EvoSC.Common/Interfaces/Services/IServiceContainerManager.cs +++ b/src/EvoSC.Common/Interfaces/Services/IServiceContainerManager.cs @@ -33,4 +33,6 @@ public interface IServiceContainerManager /// The ID of the module that requires the dependency. /// The ID of the dependency. public void RegisterDependency(Guid moduleId, Guid dependencyId); + + public Container GetContainer(Guid moduleId); } diff --git a/src/EvoSC.Common/Interfaces/Themes/Builders/IReplaceComponentBuilder.cs b/src/EvoSC.Common/Interfaces/Themes/Builders/IReplaceComponentBuilder.cs new file mode 100644 index 000000000..884baf86d --- /dev/null +++ b/src/EvoSC.Common/Interfaces/Themes/Builders/IReplaceComponentBuilder.cs @@ -0,0 +1,14 @@ +using EvoSC.Common.Themes; + +namespace EvoSC.Common.Interfaces.Themes.Builders; + +public interface IReplaceComponentBuilder +where TTheme : Theme +{ + /// + /// Replace a component with the provided component. + /// + /// Name of the new component that will replace the old component. + /// + public TTheme With(string newComponent); +} diff --git a/src/EvoSC.Common/Interfaces/Themes/Builders/ISetThemeOptionBuilder.cs b/src/EvoSC.Common/Interfaces/Themes/Builders/ISetThemeOptionBuilder.cs new file mode 100644 index 000000000..b3517a24d --- /dev/null +++ b/src/EvoSC.Common/Interfaces/Themes/Builders/ISetThemeOptionBuilder.cs @@ -0,0 +1,13 @@ +using EvoSC.Common.Themes; + +namespace EvoSC.Common.Interfaces.Themes.Builders; + +public interface ISetThemeOptionBuilder where TTheme : Theme +{ + /// + /// Set a theme option value. + /// + /// Value of the theme option. + /// + public TTheme To(object value); +} diff --git a/src/EvoSC.Common/Interfaces/Themes/DefaultThemeOptions.cs b/src/EvoSC.Common/Interfaces/Themes/DefaultThemeOptions.cs new file mode 100644 index 000000000..2eda4ea08 --- /dev/null +++ b/src/EvoSC.Common/Interfaces/Themes/DefaultThemeOptions.cs @@ -0,0 +1,120 @@ +namespace EvoSC.Common.Interfaces.Themes; + +public interface DefaultThemeOptions +{ + /// + /// The default UI Font. + /// + public static readonly string UIFont = "UI.Font"; + + /// + /// The default font size. + /// + public static readonly string UIFontSize = "UI.FontSize"; + + /// + /// Primary text color. + /// + public static readonly string UITextPrimary = "UI.TextPrimary"; + + /// + /// Secondary text color. + /// + public static readonly string UITextSecondary = "UI.TextSecondary"; + + /// + /// Primary background color. + /// + public static readonly string UIBgPrimary = "UI.BgPrimary"; + + /// + /// Secondary background color. + /// + public static readonly string UIBgSecondary = "UI.BgSecondary"; + + /// + /// Primary border color. + /// + public static readonly string UIBorderPrimary = "UI.BorderPrimary"; + + /// + /// Secondary border color. + /// + public static readonly string UIBorderSecondary = "UI.BorderSecondary"; + + /// + /// Dark version of the logo. + /// + public static readonly string UILogoDark = "UI.LogoDark"; + + /// + /// Light version of the logo. + /// + public static readonly string UILogoLight = "UI.LogoLight"; + + /// + /// Primary color for chat messages. + /// + public static readonly string ChatPrimary = "Chat.Primary"; + + /// + /// Secondary color for chat messages. + /// + public static readonly string ChatSecondary = "Chat.Secondary"; + + /// + /// Color of info chat messages. + /// + public static readonly string ChatInfo = "Chat.Info"; + + /// + /// Color of success chat messages. + /// + public static readonly string ChatSuccess = "Chat.Success"; + + /// + /// Color of warning chat messages. + /// + public static readonly string ChatWarning = "Chat.Warning"; + + /// + /// Color of error/danger chat messages. + /// + public static readonly string ChatDanger = "Chat.Danger"; + + public static readonly string Red = "Red"; + public static readonly string Green = "Green"; + public static readonly string Blue = "Blue"; + public static readonly string Yellow = "Yellow"; + public static readonly string Teal = "Teal"; + public static readonly string Purple = "Purple"; + public static readonly string Gold = "Gold"; + public static readonly string Silver = "Silver"; + public static readonly string Bronze = "Bronze"; + public static readonly string Grass = "Grass"; + public static readonly string Orange = "Orange"; + public static readonly string Gray = "Gray"; + public static readonly string Black = "Black"; + public static readonly string White = "White"; + public static readonly string Pink = "Pink"; + + /// + /// Default info UI color. + /// + public static readonly string Info = "Info"; + + /// + /// Default success UI color. + /// + public static readonly string Success = "Success"; + + /// + /// Default warning UI color. + /// + public static readonly string Warning = "Warning"; + + /// + /// Default error/danger UI color. + /// + public static readonly string Danger = "Danger"; +} diff --git a/src/EvoSC.Common/Interfaces/Themes/ITheme.cs b/src/EvoSC.Common/Interfaces/Themes/ITheme.cs new file mode 100644 index 000000000..2da756c27 --- /dev/null +++ b/src/EvoSC.Common/Interfaces/Themes/ITheme.cs @@ -0,0 +1,21 @@ +namespace EvoSC.Common.Interfaces.Themes; + +public interface ITheme +{ + /// + /// Configures the theme. + /// + /// + public Task ConfigureAsync(); + + /// + /// Options set by this theme. + /// + public Dictionary ThemeOptions { get; } + + /// + /// Component replacements defined by this theme. + /// + public Dictionary ComponentReplacements { get; } +} + diff --git a/src/EvoSC.Common/Interfaces/Themes/IThemeExpressions.cs b/src/EvoSC.Common/Interfaces/Themes/IThemeExpressions.cs new file mode 100644 index 000000000..1f68689d1 --- /dev/null +++ b/src/EvoSC.Common/Interfaces/Themes/IThemeExpressions.cs @@ -0,0 +1,22 @@ +using EvoSC.Common.Interfaces.Themes.Builders; +using EvoSC.Common.Themes; + +namespace EvoSC.Common.Interfaces.Themes; + +public interface IThemeExpressions +where TTheme : Theme +{ + /// + /// Set a theme option. + /// + /// Name of the theme option. + /// + public ISetThemeOptionBuilder Set(string key); + + /// + /// Replace a component. + /// + /// Name of the component to replace. + /// + public IReplaceComponentBuilder Replace(string component); +} diff --git a/src/EvoSC.Common/Interfaces/Themes/IThemeInfo.cs b/src/EvoSC.Common/Interfaces/Themes/IThemeInfo.cs new file mode 100644 index 000000000..7aae9220c --- /dev/null +++ b/src/EvoSC.Common/Interfaces/Themes/IThemeInfo.cs @@ -0,0 +1,34 @@ +namespace EvoSC.Common.Interfaces.Themes; + +public interface IThemeInfo +{ + /// + /// The class type of the theme. + /// + public Type ThemeType { get; } + + /// + /// Unique name of the theme. + /// + public string Name { get; } + + /// + /// Short summary describing the theme. + /// + public string Description { get; } + + /// + /// Class of the theme which this theme overrides. + /// + public Type? OverrideTheme { get; } + + /// + /// ID of the module this theme is part of. + /// + public Guid ModuleId { get; } + + /// + /// The effective theme class that is used. + /// + public Type EffectiveThemeType { get; } +} diff --git a/src/EvoSC.Common/Interfaces/Themes/IThemeManager.cs b/src/EvoSC.Common/Interfaces/Themes/IThemeManager.cs new file mode 100644 index 000000000..74db964f2 --- /dev/null +++ b/src/EvoSC.Common/Interfaces/Themes/IThemeManager.cs @@ -0,0 +1,80 @@ +using EvoSC.Common.Themes; + +namespace EvoSC.Common.Interfaces.Themes; + +public interface IThemeManager +{ + /// + /// All available themes. + /// + public IEnumerable AvailableThemes { get; } + + /// + /// Options for the current theme. + /// + public dynamic Theme { get; } + + /// + /// All available component replacements. + /// + public Dictionary ComponentReplacements { get; } + + /// + /// Add a new theme. + /// + /// Class type of the theme. + /// ID of the module providing the theme. + /// + public Task AddThemeAsync(Type themeType, Guid moduleId); + + /// + /// Add a new theme. + /// + /// Class type of the theme. + /// + public Task AddThemeAsync(Type themeType); + + /// + /// Add a new theme. + /// + /// Type of the theme class. + /// + public Task AddThemeAsync() where TTheme : Theme + => AddThemeAsync(typeof(TTheme)); + + /// + /// Add a new theme. + /// + /// Id of the module providing the theme. + /// Type of the theme class. + /// + public Task AddThemeAsync(Guid moduleId) where TTheme : Theme + => AddThemeAsync(typeof(TTheme), moduleId); + + /// + /// Remove a theme. + /// + /// Name of the theme to remove. + /// + public Task RemoveThemeAsync(string name); + + /// + /// Remove all themes from a module. + /// + /// ID of the module to remove themes from. + /// + public Task RemoveThemesForModuleAsync(Guid moduleId); + + /// + /// Activate a theme. This will replace existing themes which this theme will + /// potentially override. + /// + /// Name of the theme to activate. + /// + public Task ActivateThemeAsync(string name); + + /// + /// Invalidate the theme options cache and renew option values. + /// + public void InvalidateCache(); +} diff --git a/src/EvoSC.Common/Remote/ChatRouter/RemoteChatRouter.cs b/src/EvoSC.Common/Remote/ChatRouter/RemoteChatRouter.cs index 29b0d966b..46e6b3904 100644 --- a/src/EvoSC.Common/Remote/ChatRouter/RemoteChatRouter.cs +++ b/src/EvoSC.Common/Remote/ChatRouter/RemoteChatRouter.cs @@ -5,7 +5,6 @@ using EvoSC.Common.Middleware; using EvoSC.Common.Util; using EvoSC.Common.Util.ServerUtils; -using EvoSC.Common.Util.TextFormatting; using GbxRemoteNet.Events; using Microsoft.Extensions.Logging; diff --git a/src/EvoSC.Common/Remote/ServerClient.cs b/src/EvoSC.Common/Remote/ServerClient.cs index ac0eddcb9..b76f3a77b 100644 --- a/src/EvoSC.Common/Remote/ServerClient.cs +++ b/src/EvoSC.Common/Remote/ServerClient.cs @@ -1,6 +1,7 @@ using EvoSC.Common.Config.Models; using EvoSC.Common.Exceptions; using EvoSC.Common.Interfaces; +using EvoSC.Common.Interfaces.Themes; using GbxRemoteNet; using GbxRemoteNet.Interfaces; using Microsoft.Extensions.Logging; @@ -13,17 +14,20 @@ public partial class ServerClient : IServerClient private readonly IEvoScBaseConfig _config; private readonly ILogger _logger; private readonly IEvoSCApplication _app; + private readonly IThemeManager _themes; private bool _connected; public IGbxRemoteClient Remote => _gbxRemote; public bool Connected => _connected; - public ServerClient(IEvoScBaseConfig config, ILogger logger, IEvoSCApplication app) + public ServerClient(IEvoScBaseConfig config, ILogger logger, IEvoSCApplication app, IThemeManager themes) { _config = config; _logger = logger; _app = app; + _themes = themes; + _connected = false; _gbxRemote = new GbxRemoteClient(config.Server.Host, config.Server.Port, logger); diff --git a/src/EvoSC.Common/Remote/ServerClient_Responses.cs b/src/EvoSC.Common/Remote/ServerClient_Responses.cs index cd4062050..77ce020dc 100644 --- a/src/EvoSC.Common/Remote/ServerClient_Responses.cs +++ b/src/EvoSC.Common/Remote/ServerClient_Responses.cs @@ -8,25 +8,25 @@ public partial class ServerClient { private TextFormatter MakeInfoMessage(string text) => new TextFormatter() - .AddText("", styling => styling.WithColor(_config.Theme.Chat.InfoColor)) + .AddText("", styling => styling.WithColor(new TextColor(_themes.Theme.Chat_Info))) .AddText(" ") .AddText(text); private TextFormatter MakeSuccessMessage(string text) => new TextFormatter() - .AddText("", styling => styling.WithColor(_config.Theme.Chat.SuccessColor)) + .AddText("", styling => styling.WithColor(new TextColor(_themes.Theme.Chat_Success))) .AddText(" ") .AddText(text); private TextFormatter MakeWarningMessage(string text) => new TextFormatter() - .AddText("", styling => styling.WithColor(_config.Theme.Chat.WarningColor)) + .AddText("", styling => styling.WithColor(new TextColor(_themes.Theme.Chat_Warning))) .AddText(" ") .AddText(text); private TextFormatter MakeErrorMessage(string text) => new TextFormatter() - .AddText("", styling => styling.WithColor(_config.Theme.Chat.ErrorColor)) + .AddText("", styling => styling.WithColor(new TextColor(_themes.Theme.Chat_Danger))) .AddText(" ") .AddText(text); diff --git a/src/EvoSC.Common/Services/ServiceContainerManager.cs b/src/EvoSC.Common/Services/ServiceContainerManager.cs index 23b91bdc7..647b39c03 100644 --- a/src/EvoSC.Common/Services/ServiceContainerManager.cs +++ b/src/EvoSC.Common/Services/ServiceContainerManager.cs @@ -143,7 +143,17 @@ public void RegisterDependency(Guid moduleId, Guid dependencyId) _dependencyServices[moduleId].Add(dependencyId); _logger.LogDebug("Registered dependency '{DepId}' for '{ContainerId}'", dependencyId, moduleId); } - + + public Container GetContainer(Guid moduleId) + { + if (!_containers.ContainsKey(moduleId)) + { + throw new InvalidOperationException($"Container '{moduleId}' was not found to have a container."); + } + + return _containers[moduleId]; + } + private void ResolveCoreService(UnregisteredTypeEventArgs e, Guid containerId) { try @@ -173,20 +183,12 @@ private void ResolveCoreService(UnregisteredTypeEventArgs e, Guid containerId) } } - try - { - _logger.LogTrace( - "Dependencies does not have service '{Service}' for {Container}. Will try core services", - e.UnregisteredServiceType, - containerId); - - return _app.Services.GetInstance(e.UnregisteredServiceType); - } - catch (ActivationException ex) - { - // _logger.LogError(ex, "Failed to get EvoSC core service"); - throw; - } + _logger.LogTrace( + "Dependencies does not have service '{Service}' for {Container}. Will try core services", + e.UnregisteredServiceType, + containerId); + + return _app.Services.GetInstance(e.UnregisteredServiceType); }); } catch (Exception ex) diff --git a/src/EvoSC.Common/Themes/Attributes/ThemeAttribute.cs b/src/EvoSC.Common/Themes/Attributes/ThemeAttribute.cs new file mode 100644 index 000000000..021f8ad85 --- /dev/null +++ b/src/EvoSC.Common/Themes/Attributes/ThemeAttribute.cs @@ -0,0 +1,20 @@ +namespace EvoSC.Common.Themes.Attributes; + +[AttributeUsage(AttributeTargets.Class)] +public class ThemeAttribute : Attribute +{ + /// + /// Unique name of the theme. + /// + public required string Name { get; init; } + + /// + /// Short summary describing the theme. + /// + public required string Description { get; init; } + + /// + /// The class of the theme which this theme will override. + /// + public Type? OverrideTheme { get; init; } +} diff --git a/src/EvoSC.Common/Themes/BaseEvoScTheme.cs b/src/EvoSC.Common/Themes/BaseEvoScTheme.cs new file mode 100644 index 000000000..b35b278cb --- /dev/null +++ b/src/EvoSC.Common/Themes/BaseEvoScTheme.cs @@ -0,0 +1,95 @@ +using EvoSC.Common.Interfaces.Themes; +using EvoSC.Common.Themes.Attributes; +using EvoSC.Common.Util; + +namespace EvoSC.Common.Themes; + +[Theme(Name = "Default", Description = "The default theme as defined in the EvoSC# config.")] +public class BaseEvoScTheme : Theme +{ + public async override Task ConfigureAsync() + { + SetDefaultUtilityColors(); + GenerateUtilityColorShades(); + + SetDefaultChatColors(); + SetDefaultThemeOptions(); + } + + protected void SetDefaultChatColors() + { + Set(DefaultThemeOptions.ChatPrimary).To("fff"); + Set(DefaultThemeOptions.ChatSecondary).To("eee"); + Set(DefaultThemeOptions.ChatInfo).To("29b"); + Set(DefaultThemeOptions.ChatDanger).To("c44"); + Set(DefaultThemeOptions.ChatWarning).To("e83"); + Set(DefaultThemeOptions.ChatSuccess).To("5b6"); + } + + protected void SetDefaultThemeOptions() + { + Set(DefaultThemeOptions.UIFont).To("GameFontExtraBold"); + Set(DefaultThemeOptions.UIFontSize).To(1); + Set(DefaultThemeOptions.UITextPrimary).To("FFFFFF"); + Set(DefaultThemeOptions.UITextSecondary).To("EDEDEF"); + Set(DefaultThemeOptions.UIBgPrimary).To("FF0058"); + Set(DefaultThemeOptions.UIBgSecondary).To("47495A"); + Set(DefaultThemeOptions.UIBorderPrimary).To("FF0058"); + Set(DefaultThemeOptions.UIBorderSecondary).To("FFFFFF"); + Set(DefaultThemeOptions.UILogoDark).To(""); + Set(DefaultThemeOptions.UILogoLight).To(""); + } + + protected void SetDefaultUtilityColors() + { + Set(DefaultThemeOptions.Red).To("E22000"); + Set(DefaultThemeOptions.Green).To("00D909"); + Set(DefaultThemeOptions.Blue).To("3491FA"); + Set(DefaultThemeOptions.Yellow).To("FCE100"); + Set(DefaultThemeOptions.Teal).To("0FC6C2"); + Set(DefaultThemeOptions.Purple).To("722ED1"); + Set(DefaultThemeOptions.Gold).To("FFD000"); + Set(DefaultThemeOptions.Silver).To("9e9e9e"); + Set(DefaultThemeOptions.Bronze).To("915d29"); + Set(DefaultThemeOptions.Grass).To("9FDB1D"); + Set(DefaultThemeOptions.Orange).To("F77234"); + Set(DefaultThemeOptions.Gray).To("191A21"); + Set(DefaultThemeOptions.Black).To("000000"); + Set(DefaultThemeOptions.White).To("FFFFFF"); + Set(DefaultThemeOptions.Pink).To("FF0058"); + + Set(DefaultThemeOptions.Info).To("29b"); + Set(DefaultThemeOptions.Success).To("c44"); + Set(DefaultThemeOptions.Warning).To("e83"); + Set(DefaultThemeOptions.Danger).To("5b6"); + } + + protected void GenerateUtilityColorShades() + { + GenerateShades(DefaultThemeOptions.Red); + GenerateShades(DefaultThemeOptions.Green); + GenerateShades(DefaultThemeOptions.Blue); + GenerateShades(DefaultThemeOptions.Yellow); + GenerateShades(DefaultThemeOptions.Teal); + GenerateShades(DefaultThemeOptions.Purple); + GenerateShades(DefaultThemeOptions.Gold); + GenerateShades(DefaultThemeOptions.Silver); + GenerateShades(DefaultThemeOptions.Bronze); + GenerateShades(DefaultThemeOptions.Grass); + GenerateShades(DefaultThemeOptions.Orange); + GenerateShades(DefaultThemeOptions.Gray); + GenerateShades(DefaultThemeOptions.Pink); + } + + private void GenerateShades(string key) + { + var color = (string)ThemeOptions[key]; + + for (var i = 1; i < 10; i++) + { + var lightness = i * 10f; + var shade = ColorUtils.SetLightness(color, lightness); + Set($"{key}{lightness}").To(shade); + } + } +} diff --git a/src/EvoSC.Common/Themes/Builders/ReplaceComponentBuilder.cs b/src/EvoSC.Common/Themes/Builders/ReplaceComponentBuilder.cs new file mode 100644 index 000000000..881344c89 --- /dev/null +++ b/src/EvoSC.Common/Themes/Builders/ReplaceComponentBuilder.cs @@ -0,0 +1,24 @@ +using EvoSC.Common.Interfaces.Themes.Builders; + +namespace EvoSC.Common.Themes.Builders; + +public class ReplaceComponentBuilder : IReplaceComponentBuilder +where TTheme : Theme +{ + private readonly string _component; + private readonly Dictionary _componentReplacements; + private readonly TTheme _theme; + + internal ReplaceComponentBuilder(string component, Dictionary componentReplacements, TTheme theme) + { + _component = component; + _componentReplacements = componentReplacements; + _theme = theme; + } + + public TTheme With(string newComponent) + { + _componentReplacements[_component] = newComponent; + return _theme; + } +} diff --git a/src/EvoSC.Common/Themes/Builders/SetThemeOptionBuilder.cs b/src/EvoSC.Common/Themes/Builders/SetThemeOptionBuilder.cs new file mode 100644 index 000000000..7ed913027 --- /dev/null +++ b/src/EvoSC.Common/Themes/Builders/SetThemeOptionBuilder.cs @@ -0,0 +1,24 @@ +using EvoSC.Common.Interfaces.Themes.Builders; + +namespace EvoSC.Common.Themes.Builders; + +public class SetThemeOptionBuilder : ISetThemeOptionBuilder +where TTheme : Theme +{ + private readonly string _key; + private readonly Dictionary _themeOptions; + private readonly TTheme _theme; + + internal SetThemeOptionBuilder(string key, Dictionary themeOptions, TTheme theme) + { + _key = key; + _themeOptions = themeOptions; + _theme = theme; + } + + public TTheme To(object value) + { + _themeOptions[_key] = value; + return _theme; + } +} diff --git a/src/EvoSC.Common/Themes/DynamicThemeOptions.cs b/src/EvoSC.Common/Themes/DynamicThemeOptions.cs new file mode 100644 index 000000000..39e523602 --- /dev/null +++ b/src/EvoSC.Common/Themes/DynamicThemeOptions.cs @@ -0,0 +1,56 @@ +using System.Collections; +using System.Dynamic; + +namespace EvoSC.Common.Themes; + +public class DynamicThemeOptions : DynamicObject, IDictionary +{ + private readonly Dictionary _options; + + public DynamicThemeOptions() => _options = new Dictionary(); + + public DynamicThemeOptions(Dictionary options) => + _options = new Dictionary(options); + + public override bool TryGetMember(GetMemberBinder binder, out object? result) + { + return _options.TryGetValue(RealKey(binder.Name), out result); + } + + public object this[string key] + { + get => _options[RealKey(key)]; + set => _options[RealKey(key)] = value; + } + + public ICollection Keys => _options.Keys; + + public ICollection Values => _options.Values; + + public bool ContainsKey(string key) => _options.ContainsKey(RealKey(key)); + + public void Add(string key, object value) => _options.Add(RealKey(key), value); + + public bool Remove(string key) => _options.Remove(RealKey(key)); + + public bool TryGetValue(string key, out object value) => _options.TryGetValue(key, out value!); + + public IEnumerator> GetEnumerator() => _options.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public void Add(KeyValuePair item) => throw new NotSupportedException(); + + public void Clear() => _options.Clear(); + + public bool Contains(KeyValuePair item) => throw new NotSupportedException(); + + public void CopyTo(KeyValuePair[] array, int arrayIndex) => throw new NotSupportedException(); + + public bool Remove(KeyValuePair item) => throw new NotSupportedException(); + + public int Count => _options.Count; + public bool IsReadOnly => false; + + private static string RealKey(string key) => key.Replace('_', '.'); +} diff --git a/src/EvoSC.Common/Themes/Events/Args/ThemeUpdatedEventArgs.cs b/src/EvoSC.Common/Themes/Events/Args/ThemeUpdatedEventArgs.cs new file mode 100644 index 000000000..78012a7b1 --- /dev/null +++ b/src/EvoSC.Common/Themes/Events/Args/ThemeUpdatedEventArgs.cs @@ -0,0 +1,5 @@ +namespace EvoSC.Common.Themes.Events.Args; + +public class ThemeUpdatedEventArgs : EventArgs +{ +} diff --git a/src/EvoSC.Common/Themes/Events/ThemeEvents.cs b/src/EvoSC.Common/Themes/Events/ThemeEvents.cs new file mode 100644 index 000000000..6f1a2ceed --- /dev/null +++ b/src/EvoSC.Common/Themes/Events/ThemeEvents.cs @@ -0,0 +1,9 @@ +namespace EvoSC.Common.Themes.Events; + +public enum ThemeEvents +{ + /// + /// Triggered when a theme was changed or activated. + /// + CurrentThemeChanged +} diff --git a/src/EvoSC.Common/Themes/Exceptions/ThemeDoesNotExistException.cs b/src/EvoSC.Common/Themes/Exceptions/ThemeDoesNotExistException.cs new file mode 100644 index 000000000..b8404937a --- /dev/null +++ b/src/EvoSC.Common/Themes/Exceptions/ThemeDoesNotExistException.cs @@ -0,0 +1,8 @@ +namespace EvoSC.Common.Themes.Exceptions; + +public class ThemeDoesNotExistException : ThemeException +{ + public ThemeDoesNotExistException(string name) : base($"The theme with name '{name}' does not exist.") + { + } +} diff --git a/src/EvoSC.Common/Themes/Exceptions/ThemeException.cs b/src/EvoSC.Common/Themes/Exceptions/ThemeException.cs new file mode 100644 index 000000000..c2cea16cd --- /dev/null +++ b/src/EvoSC.Common/Themes/Exceptions/ThemeException.cs @@ -0,0 +1,22 @@ +using System.Runtime.Serialization; + +namespace EvoSC.Common.Themes.Exceptions; + +public class ThemeException : Exception +{ + public ThemeException() + { + } + + protected ThemeException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } + + public ThemeException(string? message) : base(message) + { + } + + public ThemeException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/src/EvoSC.Common/Themes/Theme.cs b/src/EvoSC.Common/Themes/Theme.cs new file mode 100644 index 000000000..731257f00 --- /dev/null +++ b/src/EvoSC.Common/Themes/Theme.cs @@ -0,0 +1,20 @@ +using EvoSC.Common.Interfaces.Themes; +using EvoSC.Common.Interfaces.Themes.Builders; +using EvoSC.Common.Themes.Builders; + +namespace EvoSC.Common.Themes; + +public abstract class Theme : ITheme, IThemeExpressions where TTheme : Theme +{ + public abstract Task ConfigureAsync(); + + public Dictionary ThemeOptions { get; } = new(); + + public Dictionary ComponentReplacements { get; } = new(); + + public ISetThemeOptionBuilder Set(string key) => + new SetThemeOptionBuilder(key, ThemeOptions, (TTheme)this); + + public IReplaceComponentBuilder Replace(string component) => + new ReplaceComponentBuilder(component, ComponentReplacements, (TTheme)this); +} diff --git a/src/EvoSC.Common/Themes/ThemeInfo.cs b/src/EvoSC.Common/Themes/ThemeInfo.cs new file mode 100644 index 000000000..2533454e7 --- /dev/null +++ b/src/EvoSC.Common/Themes/ThemeInfo.cs @@ -0,0 +1,18 @@ +using EvoSC.Common.Interfaces.Themes; + +namespace EvoSC.Common.Themes; + +public class ThemeInfo : IThemeInfo +{ + public required Type ThemeType { get; init; } + + public required string Name { get; init; } + + public required string Description { get; init; } + + public required Type? OverrideTheme { get; init; } + + public required Guid ModuleId { get; init; } + + public Type EffectiveThemeType => OverrideTheme ?? ThemeType; +} diff --git a/src/EvoSC.Common/Themes/ThemeManager.cs b/src/EvoSC.Common/Themes/ThemeManager.cs new file mode 100644 index 000000000..227cdd912 --- /dev/null +++ b/src/EvoSC.Common/Themes/ThemeManager.cs @@ -0,0 +1,190 @@ +using System.Reflection; +using EvoSC.Common.Config.Models; +using EvoSC.Common.Interfaces; +using EvoSC.Common.Interfaces.Services; +using EvoSC.Common.Interfaces.Themes; +using EvoSC.Common.Themes.Attributes; +using EvoSC.Common.Themes.Events; +using EvoSC.Common.Themes.Events.Args; +using EvoSC.Common.Themes.Exceptions; +using Microsoft.Extensions.DependencyInjection; + +namespace EvoSC.Common.Themes; + +public class ThemeManager : IThemeManager +{ + private readonly IServiceContainerManager _serviceManager; + private readonly IEvoSCApplication _evoscApp; + private readonly IEventManager _events; + private readonly IEvoScBaseConfig _evoscConfig; + + private dynamic? _themeOptionsCache; + private Dictionary? _componentReplacementsCache; + + private readonly Dictionary _availableThemes = new(); + private readonly Dictionary _activeThemes = new(); + + public IEnumerable AvailableThemes => _availableThemes.Values; + public dynamic Theme => _themeOptionsCache ?? GetCurrentThemeOptions(); + + public Dictionary ComponentReplacements => + _componentReplacementsCache ?? GetCurrentComponentReplacements(); + + public ThemeManager(IServiceContainerManager serviceManager, IEvoSCApplication evoscApp, IEventManager events, IEvoScBaseConfig evoscConfig) + { + _serviceManager = serviceManager; + _evoscApp = evoscApp; + _events = events; + _evoscConfig = evoscConfig; + } + + public async Task AddThemeAsync(Type themeType, Guid moduleId) + { + var attr = themeType.GetCustomAttribute(); + + if (attr == null) + { + throw new InvalidOperationException($"The provided theme type {themeType} does not annotate the Theme attribute."); + } + + if (_availableThemes.ContainsKey(attr.Name)) + { + throw new ThemeException($"A theme with the name '{attr.Name}' already exists."); + } + + var themeInfo = new ThemeInfo + { + ThemeType = themeType, + Name = attr.Name, + Description = attr.Description, + ModuleId = moduleId, + OverrideTheme = attr.OverrideTheme + }; + + _availableThemes[attr.Name] = themeInfo; + + if (!_activeThemes.ContainsKey(themeInfo.EffectiveThemeType)) + { + await ActivateThemeAsync(attr.Name); + } + } + + public Task AddThemeAsync(Type themeType) => AddThemeAsync(themeType, Guid.Empty); + + public async Task RemoveThemeAsync(string name) + { + ThrowIfNotExists(name); + + var themeInfo = _availableThemes[name]; + + _availableThemes.Remove(name); + + if (_activeThemes.Remove(themeInfo.EffectiveThemeType)) + { + InvalidateCache(); + await _events.RaiseAsync(ThemeEvents.CurrentThemeChanged, new ThemeUpdatedEventArgs()); + } + } + + public async Task RemoveThemesForModuleAsync(Guid moduleId) + { + foreach (var (name, theme) in _availableThemes) + { + if (theme.ModuleId.Equals(moduleId)) + { + await RemoveThemeAsync(name); + } + } + } + + public async Task ActivateThemeAsync(string name) + { + ThrowIfNotExists(name); + + var themeInfo = _availableThemes[name]; + var services = _evoscApp.Services; + + if (!themeInfo.ModuleId.Equals(Guid.Empty)) + { + services = _serviceManager.GetContainer(themeInfo.ModuleId); + } + + var theme = ActivatorUtilities.CreateInstance(services, themeInfo.ThemeType) as ITheme; + + if (theme == null) + { + throw new ThemeException($"Failed to activate theme '{name}'."); + } + + _activeThemes[themeInfo.EffectiveThemeType] = theme; + + await theme.ConfigureAsync(); + InvalidateCache(); + + await _events.RaiseAsync(ThemeEvents.CurrentThemeChanged, new ThemeUpdatedEventArgs()); + + return theme; + } + + public void InvalidateCache() + { + _themeOptionsCache = null; + _componentReplacementsCache = null; + } + + private dynamic GetCurrentThemeOptions() + { + var configOverride = GetConfigOverrideOptions(); + var themeOptions = new DynamicThemeOptions(configOverride); + + foreach (var option in _activeThemes.Values.SelectMany(theme => theme.ThemeOptions)) + { + themeOptions[option.Key] = option.Value; + } + + // override options with whatever user has defined in the config + foreach (var option in configOverride) + { + themeOptions[option.Key] = option.Value; + } + + _themeOptionsCache = themeOptions; + return themeOptions; + } + + private void ThrowIfNotExists(string name) + { + if (!_availableThemes.ContainsKey(name)) + { + throw new ThemeDoesNotExistException(name); + } + } + + private Dictionary GetConfigOverrideOptions() + { + var themeOptions = new Dictionary(); + + foreach (var defaultOption in _evoscConfig.Theme) + { + var key = defaultOption.Key.StartsWith("Theme.", StringComparison.Ordinal) + ? defaultOption.Key[6..] + : defaultOption.Key; + + themeOptions[key] = defaultOption.Value; + } + + return themeOptions; + } + + private Dictionary GetCurrentComponentReplacements() + { + var replacements = new Dictionary(); + + foreach (var (component, replacement) in _activeThemes.Values.SelectMany(t => t.ComponentReplacements)) + { + replacements[component] = replacement; + } + + return replacements; + } +} diff --git a/src/EvoSC.Common/Themes/ThemeServiceExtensions.cs b/src/EvoSC.Common/Themes/ThemeServiceExtensions.cs new file mode 100644 index 000000000..ba03390ff --- /dev/null +++ b/src/EvoSC.Common/Themes/ThemeServiceExtensions.cs @@ -0,0 +1,14 @@ +using EvoSC.Common.Interfaces.Themes; +using SimpleInjector; + +namespace EvoSC.Common.Themes; + +public static class ThemesServiceExtensions +{ + public static Container AddEvoScThemes(this Container services) + { + services.RegisterSingleton(); + + return services; + } +} diff --git a/src/EvoSC.Common/Util/ColorUtils.cs b/src/EvoSC.Common/Util/ColorUtils.cs new file mode 100644 index 000000000..648a8834a --- /dev/null +++ b/src/EvoSC.Common/Util/ColorUtils.cs @@ -0,0 +1,174 @@ +using ColorMine.ColorSpaces; +using EvoSC.Common.Util.TextFormatting; + +namespace EvoSC.Common.Util; + +public static class ColorUtils +{ + /// + /// Set the lightness for a color. + /// + /// Hex color to set lightness for. + /// Lightness in percentage from 0-100. + /// + public static string SetLightness(string hexColor, float lightness) + { + var hsl = new Hex(hexColor).To(); + hsl.L = lightness / 100f; + return hsl.To().ToString().Substring(1); + } + + /// + /// Set the lightness for a color. + /// + /// Text color ot set lightness for. + /// Lightness in percentage from 0-100. + /// + public static string SetLightness(TextColor color, float lightness) + { + var rgb = new Rgb { R = color.R, G = color.G, B = color.B }; + var hsl = rgb.To(); + hsl.L = lightness / 100f; + return hsl.To().ToString().Substring(1); + } + + /// + /// Lighten a hex color by a set amount. + /// + /// Hex color to lighten. + /// Amount to increase the lightness to in percentage (0-100). + /// + public static string Lighten(string hexColor, float amount) => + AddLightness( + new Hex(hexColor).To(), + amount + ) + .To() + .ToString() + .Substring(1); + + /// + /// Lighten a text color by a set amount. + /// + /// Text color to lighten. + /// Amount to increase the lightness to in percentage (0-100). + /// + public static string Lighten(TextColor color, float amount) => + AddLightness( + new Rgb { R = color.R, G = color.G, B = color.B }.To(), + amount + ) + .To() + .ToString() + .Substring(1); + + /// + /// Lighten a hex color by 10%. + /// + /// Hex color to lighten. + /// + public static string Lighten(string hexColor) => Lighten(hexColor, 10); + + /// + /// Lighten a text color by 10%. + /// + /// Text color to lighten. + /// + public static string Lighten(TextColor color) => Lighten(color, 10); + + /// + /// Darken a hex color by a set amount. + /// + /// Hex color to darken. + /// Amount to darken in percentage (0-100). + /// + public static string Darken(string hexColor, float amount) => Lighten(hexColor, -amount); + + /// + /// Darken a text color by a set amount. + /// + /// Text color to darken. + /// Amount to darken in percentage (0-100). + /// + public static string Darken(TextColor color, float amount) => Lighten(color, -amount); + + /// + /// Darken a hex color by 10%. + /// + /// Hex color to darken. + /// + public static string Darken(string hexColor) => Darken(hexColor, 10); + + /// + /// Darken a text color by 10%. + /// + /// Text color to darken. + /// + public static string Darken(TextColor color) => Darken(color, 10); + + /// + /// Get the luma of an RGB color. + /// + /// Color to calculate luma from. + /// The following method uses the BT. 709 coefficients to calculate the luma. + /// + public static double Luma(IRgb color) => Math.Round(color.R * 0.2126 + color.B * 0.7152 + color.B * 0.0722); + + /// + /// Get the luma of a color. + /// + /// Hex color to calculate luma from. + /// + public static double Luma(string hexColor) => Luma(new Hex(hexColor).ToRgb()); + + /// + /// Get the luma of a color. + /// + /// Text color to calculate luma from. + /// + public static double Luma(TextColor color) => Luma(new Rgb { R = color.R, G = color.G, B = color.B }); + + /// + /// Convert a hex color to it's grayscale representation. + /// + /// Hex color to convert. + /// + public static string GrayScale(string hexColor) + { + var luma = Luma(hexColor); + return new Rgb { R = luma, G = luma, B = luma } + .To() + .ToString() + .Substring(1); + } + + /// + /// Convert a text color to it's grayscale representation. + /// + /// text color to convert. + /// + public static string GrayScale(TextColor color) + { + var luma = Luma(color); + return new Rgb { R = luma, G = luma, B = luma } + .To() + .ToString() + .Substring(1); + } + + private static Hsl AddLightness(Hsl hsl, float increase) + { + var newL = hsl.L + increase / 100f; + + newL = newL switch + { + > 1 => 1f, + < 0 => 0, + _ => newL + }; + + hsl.L = newL; + + return hsl; + } +} diff --git a/src/EvoSC.Common/Util/FormattingUtils.cs b/src/EvoSC.Common/Util/FormattingUtils.cs index a1419a272..095abd297 100644 --- a/src/EvoSC.Common/Util/FormattingUtils.cs +++ b/src/EvoSC.Common/Util/FormattingUtils.cs @@ -1,5 +1,4 @@ using System.Text.RegularExpressions; -using EvoSC.Common.Interfaces.Models; using EvoSC.Common.Util.TextFormatting; namespace EvoSC.Common.Util; diff --git a/src/EvoSC.Common/Util/TextFormatting/TextColor.cs b/src/EvoSC.Common/Util/TextFormatting/TextColor.cs index aae55550d..a5c5973d0 100644 --- a/src/EvoSC.Common/Util/TextFormatting/TextColor.cs +++ b/src/EvoSC.Common/Util/TextFormatting/TextColor.cs @@ -1,5 +1,5 @@ -using System.Diagnostics; -using System.Drawing; +using System.Drawing; +using System.Globalization; namespace EvoSC.Common.Util.TextFormatting; @@ -12,6 +12,10 @@ public class TextColor private readonly byte _g; private readonly byte _b; + public byte R => _r; + public byte G => _g; + public byte B => _b; + /// /// Create a text color using a System.Color object. /// @@ -43,7 +47,7 @@ public TextColor(byte r, byte g, byte b) { if (r is < 0 or > 0xf || g is < 0 or > 0xf || b is < 0 or > 0xf) { - throw new ArgumentOutOfRangeException("Invalid RGB colors, must be between 0 and 15."); + throw new InvalidOperationException("Invalid RGB colors, must be between 0 and 15."); } _r = r; @@ -58,7 +62,7 @@ public TextColor(byte r, byte g, byte b) /// private static string ToHex(byte n) => n switch { - >= 0 and <= 9 => n.ToString(), + >= 0 and <= 9 => n.ToString(CultureInfo.InvariantCulture), 10 => "a", 11 => "b", 12 => "c", diff --git a/src/EvoSC.Manialinks/EvoSC.Manialinks.csproj b/src/EvoSC.Manialinks/EvoSC.Manialinks.csproj index 45ea7008f..94f46630a 100644 --- a/src/EvoSC.Manialinks/EvoSC.Manialinks.csproj +++ b/src/EvoSC.Manialinks/EvoSC.Manialinks.csproj @@ -13,7 +13,19 @@ - + + + + + + + + + + + + + diff --git a/src/EvoSC.Manialinks/Interfaces/IManialinkManager.cs b/src/EvoSC.Manialinks/Interfaces/IManialinkManager.cs index 3448e571e..fc6a33586 100644 --- a/src/EvoSC.Manialinks/Interfaces/IManialinkManager.cs +++ b/src/EvoSC.Manialinks/Interfaces/IManialinkManager.cs @@ -200,4 +200,8 @@ public Task SendManialinkAsync(IEnumerable players, string name) => /// /// public Task PreprocessAllAsync(); + + public void AddGlobalVariable(string name, T value); + public void RemoveGlobalVariable(string name); + public void ClearGlobalVariables(); } diff --git a/src/EvoSC.Manialinks/ManialinkController.cs b/src/EvoSC.Manialinks/ManialinkController.cs index 9ee568afa..ac67a0459 100644 --- a/src/EvoSC.Manialinks/ManialinkController.cs +++ b/src/EvoSC.Manialinks/ManialinkController.cs @@ -6,7 +6,6 @@ using EvoSC.Manialinks.Interfaces; using EvoSC.Manialinks.Interfaces.Validation; using EvoSC.Manialinks.Validation; -using ValidationResult = System.ComponentModel.DataAnnotations.ValidationResult; namespace EvoSC.Manialinks; @@ -177,7 +176,7 @@ private void AddModelValidationResult(ValidationResult? validationResult) { Name = validationResult.MemberNames.FirstOrDefault() ?? "Invalid Value.", IsInvalid = false, - Message = validationResult?.ErrorMessage ?? "" + Message = validationResult.ErrorMessage ?? "" }); } else @@ -186,7 +185,7 @@ private void AddModelValidationResult(ValidationResult? validationResult) { Name = validationResult.MemberNames.FirstOrDefault() ?? "", IsInvalid = true, - Message = validationResult?.ErrorMessage ?? "Invalid Value." + Message = validationResult.ErrorMessage ?? "Invalid Value." }); } } diff --git a/src/EvoSC.Manialinks/ManialinkManager.cs b/src/EvoSC.Manialinks/ManialinkManager.cs index e8de4fee7..04ea623c6 100644 --- a/src/EvoSC.Manialinks/ManialinkManager.cs +++ b/src/EvoSC.Manialinks/ManialinkManager.cs @@ -1,14 +1,21 @@ using System.Collections.Concurrent; using System.Reflection; using System.Text; +using EvoSC.Common.Events; using EvoSC.Common.Interfaces; using EvoSC.Common.Interfaces.Models; +using EvoSC.Common.Interfaces.Themes; using EvoSC.Common.Remote; +using EvoSC.Common.Themes; +using EvoSC.Common.Themes.Events; +using EvoSC.Common.Themes.Events.Args; using EvoSC.Common.Util; using EvoSC.Common.Util.EnumIdentifier; using EvoSC.Manialinks.Interfaces; using EvoSC.Manialinks.Interfaces.Models; using EvoSC.Manialinks.Models; +using EvoSC.Manialinks.Themes; +using EvoSC.Manialinks.Util; using GbxRemoteNet; using GbxRemoteNet.Events; using ManiaTemplates; @@ -21,6 +28,7 @@ public class ManialinkManager : IManialinkManager { private readonly ILogger _logger; private readonly IServerClient _server; + private readonly IThemeManager _themeManager; private readonly ManiaTemplateEngine _engine = new(); private readonly Dictionary _templates = new(); @@ -32,11 +40,13 @@ public class ManialinkManager : IManialinkManager typeof(IOnlinePlayer).Assembly, typeof(ManialinkManager).Assembly }; - public ManialinkManager(ILogger logger, IServerClient server, IEventManager events) + public ManialinkManager(ILogger logger, IServerClient server, IEventManager events, + IThemeManager themeManager) { _logger = logger; _server = server; - + _themeManager = themeManager; + events.Subscribe(s => s .WithEvent(GbxRemoteEvent.PlayerConnect) .WithInstance(this) @@ -44,25 +54,26 @@ public ManialinkManager(ILogger logger, IServerClient server, .WithHandlerMethod(HandlePlayerConnectAsync) .AsAsync() ); - } - /// - /// Used to send persistent manialinks to newly connected players. - /// - private async Task HandlePlayerConnectAsync(object sender, PlayerConnectGbxEventArgs e) - { - try - { - foreach (var (_, output) in _persistentManialinks) - { - await _server.Remote.SendDisplayManialinkPageToLoginAsync(e.Login, output, 0, false); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to send persistent manialink login '{Login}'. Did they leave already?", - e.Login); - } + events.Subscribe(s => s + .WithPriority(EventPriority.High) + .WithEvent(ThemeEvents.CurrentThemeChanged) + .WithInstance(this) + .WithInstanceClass() + .WithHandlerMethod(HandleThemeActivatedAsync)); + + themeManager.AddThemeAsync(); + themeManager.AddThemeAsync(); + themeManager.AddThemeAsync(); + themeManager.AddThemeAsync(); + themeManager.AddThemeAsync(); + themeManager.AddThemeAsync(); + themeManager.AddThemeAsync(); + themeManager.AddThemeAsync(); + + _engine.GlobalVariables["Util"] = new GlobalManialinkUtils(themeManager); + _engine.GlobalVariables["Icons"] = new GameIcons(); + _engine.GlobalVariables["Font"] = new FontManialinkHelper(themeManager); } public async Task AddDefaultTemplatesAsync() @@ -124,36 +135,6 @@ public async Task AddDefaultTemplatesAsync() } } - private static string GetManialinkTemplateName(string[] namespaceParts, string[] nameComponents) - { - var index = 0; - while (index < namespaceParts.Length && - nameComponents[index].Equals(namespaceParts[index], StringComparison.Ordinal)) - { - index++; - } - - if (nameComponents[index].Equals("Templates", StringComparison.Ordinal)) - { - index++; - } - - var templateName = $"EvoSC.{string.Join(".", nameComponents[index..^1])}"; - return templateName; - } - - private MultiCall CreateMultiCall(IEnumerable players, string manialinkOutput) - { - var multiCall = new MultiCall(); - - foreach (var player in players) - { - multiCall.Add("SendDisplayManialinkPageToLogin", player.GetLogin(), manialinkOutput, 0, false); - } - - return multiCall; - } - public void AddTemplate(IManialinkTemplateInfo template) { if (_templates.ContainsKey(template.Name)) @@ -200,52 +181,16 @@ public void RemoveManiaScript(string name) _scripts.Remove(name); } - private IEnumerable PrepareRender(string name) - { - if (!_templates.ContainsKey(name)) - { - throw new InvalidOperationException($"Template '{name}' not found."); - } - - var assemblies = new List(); - assemblies.AddRange(s_defaultAssemblies); - assemblies.AddRange(_templates[name].Assemblies); - - return assemblies; - } - - private async Task PrepareAndRenderAsync(string name, IDictionary data) - { - var assemblies = PrepareRender(name); - return await _engine.RenderAsync(name, data, assemblies); - } - - private async Task PrepareAndRenderAsync(string name, dynamic data) - { - var assemblies = PrepareRender(name); - return await _engine.RenderAsync(name, data, assemblies); - } - - private string CreateHideManialink(string name) - { - var sb = new StringBuilder() - .Append("\n") - .Append("\n") - .Append("\n"); - - return sb.ToString(); - } - public async Task SendManialinkAsync(string name, IDictionary data) { + name = GetEffectiveName(name); var manialinkOutput = await PrepareAndRenderAsync(name, data); await _server.Remote.SendDisplayManialinkPageAsync(manialinkOutput, 0, false); } public async Task SendManialinkAsync(string name, dynamic data) { + name = GetEffectiveName(name); var manialinkOutput = await PrepareAndRenderAsync(name, data); await _server.Remote.SendDisplayManialinkPageAsync(manialinkOutput, 0, false); } @@ -254,6 +199,7 @@ public async Task SendManialinkAsync(string name, dynamic data) public async Task SendPersistentManialinkAsync(string name, IDictionary data) { + name = GetEffectiveName(name); var manialinkOutput = await PrepareAndRenderAsync(name, data); await _server.Remote.SendDisplayManialinkPageAsync(manialinkOutput, 0, false); _persistentManialinks[name] = manialinkOutput; @@ -261,6 +207,7 @@ public async Task SendPersistentManialinkAsync(string name, IDictionary data) { + name = GetEffectiveName(name); var manialinkOutput = await PrepareAndRenderAsync(name, data); await _server.Remote.SendDisplayManialinkPageToLoginAsync(player.GetLogin(), manialinkOutput, 0, false); } public async Task SendManialinkAsync(IPlayer player, string name, dynamic data) { + name = GetEffectiveName(name); var manialinkOutput = await PrepareAndRenderAsync(name, data); await _server.Remote.SendDisplayManialinkPageToLoginAsync(player.GetLogin(), manialinkOutput, 0, false); } public async Task SendManialinkAsync(string playerLogin, string name, dynamic data) { + name = GetEffectiveName(name); var manialinkOutput = await PrepareAndRenderAsync(name, data); await _server.Remote.SendDisplayManialinkPageToLoginAsync(playerLogin, manialinkOutput, 0, false); } public async Task SendManialinkAsync(IEnumerable players, string name, IDictionary data) { + name = GetEffectiveName(name); var manialinkOutput = await PrepareAndRenderAsync(name, data); var multiCall = CreateMultiCall(players, manialinkOutput); await _server.Remote.MultiCallAsync(multiCall); @@ -295,6 +246,7 @@ public async Task SendManialinkAsync(IEnumerable players, string name, public async Task SendManialinkAsync(IEnumerable players, string name, dynamic data) { + name = GetEffectiveName(name); var manialinkOutput = await PrepareAndRenderAsync(name, data); var multiCall = CreateMultiCall(players, manialinkOutput); await _server.Remote.MultiCallAsync(multiCall); @@ -302,6 +254,7 @@ public async Task SendManialinkAsync(IEnumerable players, string name, public Task HideManialinkAsync(string name) { + name = GetEffectiveName(name); _persistentManialinks.TryRemove(name, out _); var manialinkOutput = CreateHideManialink(name); return _server.Remote.SendDisplayManialinkPageAsync(manialinkOutput, 3, true); @@ -309,6 +262,7 @@ public Task HideManialinkAsync(string name) public Task HideManialinkAsync(IPlayer player, string name) { + name = GetEffectiveName(name); _persistentManialinks.TryRemove(name, out _); var manialinkOutput = CreateHideManialink(name); return _server.Remote.SendDisplayManialinkPageToLoginAsync(player.GetLogin(), manialinkOutput, 3, true); @@ -316,12 +270,14 @@ public Task HideManialinkAsync(IPlayer player, string name) public Task HideManialinkAsync(string playerLogin, string name) { + name = GetEffectiveName(name); var manialinkOutput = CreateHideManialink(name); return _server.Remote.SendDisplayManialinkPageToLoginAsync(playerLogin, manialinkOutput, 3, true); } public Task HideManialinkAsync(IEnumerable players, string name) { + name = GetEffectiveName(name); var manialinkOutput = CreateHideManialink(name); var multiCall = new MultiCall(); @@ -346,4 +302,118 @@ public async Task PreprocessAllAsync() await _engine.PreProcessAsync(template.Name, assembles); } } + + public void AddGlobalVariable(string name, T value) => + _engine.GlobalVariables.AddOrUpdate(name, value, (_, _) => value); + + public void RemoveGlobalVariable(string name) + { + if (_engine.GlobalVariables.ContainsKey(name)) + { + _engine.GlobalVariables.Remove(name, out _); + } + + throw new KeyNotFoundException($"Did not find global variable named '{name}'."); + } + + public void ClearGlobalVariables() => _engine.GlobalVariables.Clear(); + + /// + /// Used to send persistent manialinks to newly connected players. + /// + private async Task HandlePlayerConnectAsync(object sender, PlayerConnectGbxEventArgs e) + { + try + { + foreach (var (_, output) in _persistentManialinks) + { + await _server.Remote.SendDisplayManialinkPageToLoginAsync(e.Login, output, 0, false); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send persistent manialink login '{Login}'. Did they leave already?", + e.Login); + } + } + + private Task HandleThemeActivatedAsync(object sender, ThemeUpdatedEventArgs e) + { + _engine.GlobalVariables["Theme"] = _themeManager.Theme; + + return Task.CompletedTask; + } + + private static string GetManialinkTemplateName(string[] namespaceParts, string[] nameComponents) + { + var index = 0; + while (index < namespaceParts.Length && + nameComponents[index].Equals(namespaceParts[index], StringComparison.Ordinal)) + { + index++; + } + + if (nameComponents[index].Equals("Templates", StringComparison.Ordinal)) + { + index++; + } + + var templateName = $"EvoSC.{string.Join(".", nameComponents[index..^1])}"; + return templateName; + } + + private MultiCall CreateMultiCall(IEnumerable players, string manialinkOutput) + { + var multiCall = new MultiCall(); + + foreach (var player in players) + { + multiCall.Add("SendDisplayManialinkPageToLogin", player.GetLogin(), manialinkOutput, 0, false); + } + + return multiCall; + } + + private IEnumerable PrepareRender(string name) + { + if (!_templates.ContainsKey(name)) + { + throw new InvalidOperationException($"Template '{name}' not found."); + } + + var assemblies = new List(); + assemblies.AddRange(s_defaultAssemblies); + assemblies.AddRange(_templates[name].Assemblies); + + return assemblies; + } + + private async Task PrepareAndRenderAsync(string name, IDictionary data) + { + var assemblies = PrepareRender(name); + return await _engine.RenderAsync(name, data, assemblies); + } + + private async Task PrepareAndRenderAsync(string name, dynamic data) + { + var assemblies = PrepareRender(name); + return await _engine.RenderAsync(name, data, assemblies); + } + + private string CreateHideManialink(string name) + { + var sb = new StringBuilder() + .Append("\n") + .Append("\n") + .Append("\n"); + + return sb.ToString(); + } + + private string GetEffectiveName(string name) => + _themeManager.ComponentReplacements.TryGetValue(name, out var effectiveName) + ? effectiveName + : name; } diff --git a/src/EvoSC.Manialinks/Templates/Controls/Alert.mt b/src/EvoSC.Manialinks/Templates/Controls/Alert.mt index 0a0945901..dc8372f8c 100644 --- a/src/EvoSC.Manialinks/Templates/Controls/Alert.mt +++ b/src/EvoSC.Manialinks/Templates/Controls/Alert.mt @@ -14,11 +14,11 @@ - - - + + + diff --git a/src/EvoSC.Manialinks/Templates/Controls/Checkbox.mt b/src/EvoSC.Manialinks/Templates/Controls/Checkbox.mt index a4414b361..8b49e5bc1 100644 --- a/src/EvoSC.Manialinks/Templates/Controls/Checkbox.mt +++ b/src/EvoSC.Manialinks/Templates/Controls/Checkbox.mt @@ -19,12 +19,6 @@ - - - - - -