From f3ab1fb1bd2928f657c402f2541c12f7634cc0fc Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 13 May 2023 01:23:37 +0200 Subject: [PATCH] Fixes #2629 - Config Manager error logging improvements (#2630) * Fixes #2629 * Fixed broken unit tests (which were already broken but latent) * Removed test code --- .../Configuration/ConfigurationManager.cs | 1209 ++++++++--------- .../Configuration/DictionaryJsonConverter.cs | 4 + Terminal.Gui/Configuration/Scope.cs | 2 +- Terminal.Gui/Resources/config.json | 5 + .../Configuration/ConfigurationMangerTests.cs | 30 +- 5 files changed, 623 insertions(+), 627 deletions(-) diff --git a/Terminal.Gui/Configuration/ConfigurationManager.cs b/Terminal.Gui/Configuration/ConfigurationManager.cs index 455697ffc..e709bca8e 100644 --- a/Terminal.Gui/Configuration/ConfigurationManager.cs +++ b/Terminal.Gui/Configuration/ConfigurationManager.cs @@ -13,646 +13,645 @@ using System.Text.Json.Serialization; #nullable enable -namespace Terminal.Gui { - /// - /// Provides settings and configuration management for Terminal.Gui applications. - /// - /// Users can set Terminal.Gui settings on a global or per-application basis by providing JSON formatted configuration files. - /// The configuration files can be placed in at .tui folder in the user's home directory (e.g. C:/Users/username/.tui, - /// or /usr/username/.tui), - /// the folder where the Terminal.Gui application was launched from (e.g. ./.tui), or as a resource - /// within the Terminal.Gui application's main assembly. - /// - /// - /// Settings are defined in JSON format, according to this schema: - /// https://gui-cs.github.io/Terminal.Gui/schemas/tui-config-schema.json - /// - /// - /// Settings that will apply to all applications (global settings) reside in files named config.json. Settings - /// that will apply to a specific Terminal.Gui application reside in files named appname.config.json, - /// where appname is the assembly name of the application (e.g. UICatalog.config.json). - /// - /// Settings are applied using the following precedence (higher precedence settings - /// overwrite lower precedence settings): - /// - /// 1. Application configuration found in the users's home directory (~/.tui/appname.config.json) -- Highest precedence - /// - /// - /// 2. Application configuration found in the directory the app was launched from (./.tui/appname.config.json). - /// - /// - /// 3. Application configuration found in the applications's resources (Resources/config.json). - /// - /// - /// 4. Global configuration found in the user's home directory (~/.tui/config.json). - /// - /// - /// 5. Global configuration found in the directory the app was launched from (./.tui/config.json). - /// - /// - /// 6. Global configuration in Terminal.Gui.dll's resources (Terminal.Gui.Resources.config.json) -- Lowest Precidence. - /// - /// - public static partial class ConfigurationManager { +namespace Terminal.Gui; +/// +/// Provides settings and configuration management for Terminal.Gui applications. +/// +/// Users can set Terminal.Gui settings on a global or per-application basis by providing JSON formatted configuration files. +/// The configuration files can be placed in at .tui folder in the user's home directory (e.g. C:/Users/username/.tui, +/// or /usr/username/.tui), +/// the folder where the Terminal.Gui application was launched from (e.g. ./.tui), or as a resource +/// within the Terminal.Gui application's main assembly. +/// +/// +/// Settings are defined in JSON format, according to this schema: +/// https://gui-cs.github.io/Terminal.Gui/schemas/tui-config-schema.json +/// +/// +/// Settings that will apply to all applications (global settings) reside in files named config.json. Settings +/// that will apply to a specific Terminal.Gui application reside in files named appname.config.json, +/// where appname is the assembly name of the application (e.g. UICatalog.config.json). +/// +/// Settings are applied using the following precedence (higher precedence settings +/// overwrite lower precedence settings): +/// +/// 1. Application configuration found in the users's home directory (~/.tui/appname.config.json) -- Highest precedence +/// +/// +/// 2. Application configuration found in the directory the app was launched from (./.tui/appname.config.json). +/// +/// +/// 3. Application configuration found in the applications's resources (Resources/config.json). +/// +/// +/// 4. Global configuration found in the user's home directory (~/.tui/config.json). +/// +/// +/// 5. Global configuration found in the directory the app was launched from (./.tui/config.json). +/// +/// +/// 6. Global configuration in Terminal.Gui.dll's resources (Terminal.Gui.Resources.config.json) -- Lowest Precidence. +/// +/// +public static partial class ConfigurationManager { - private static readonly string _configFilename = "config.json"; + private static readonly string _configFilename = "config.json"; - private static readonly JsonSerializerOptions _serializerOptions = new JsonSerializerOptions { - ReadCommentHandling = JsonCommentHandling.Skip, - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true, - Converters = { + private static readonly JsonSerializerOptions _serializerOptions = new JsonSerializerOptions { + ReadCommentHandling = JsonCommentHandling.Skip, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + Converters = { // We override the standard Rune converter to support specifying Glyphs in // a flexible way new RuneJsonConverter(), }, - }; + }; + + /// + /// An attribute that can be applied to a property to indicate that it should included in the configuration file. + /// + /// + /// [SerializableConfigurationProperty(Scope = typeof(Configuration.ThemeManager.ThemeScope)), JsonConverter (typeof (JsonStringEnumConverter))] + /// public static LineStyle DefaultBorderStyle { + /// ... + /// + [AttributeUsage (AttributeTargets.Property, AllowMultiple = false, Inherited = false)] + public class SerializableConfigurationProperty : System.Attribute { + /// + /// Specifies the scope of the property. + /// + public Type? Scope { get; set; } /// - /// An attribute that can be applied to a property to indicate that it should included in the configuration file. + /// If , the property will be serialized to the configuration file using only the property name + /// as the key. If , the property will be serialized to the configuration file using the + /// property name pre-pended with the classname (e.g. Application.UseSystemConsole). /// - /// - /// [SerializableConfigurationProperty(Scope = typeof(Configuration.ThemeManager.ThemeScope)), JsonConverter (typeof (JsonStringEnumConverter))] - /// public static LineStyle DefaultBorderStyle { - /// ... - /// - [AttributeUsage (AttributeTargets.Property, AllowMultiple = false, Inherited = false)] - public class SerializableConfigurationProperty : System.Attribute { - /// - /// Specifies the scope of the property. - /// - public Type? Scope { get; set; } + public bool OmitClassName { get; set; } + } - /// - /// If , the property will be serialized to the configuration file using only the property name - /// as the key. If , the property will be serialized to the configuration file using the - /// property name pre-pended with the classname (e.g. Application.UseSystemConsole). - /// - public bool OmitClassName { get; set; } + /// + /// Holds a property's value and the that allows + /// to get and set the property's value. + /// + /// + /// Configuration properties must be and + /// and have the + /// attribute. If the type of the property requires specialized JSON serialization, + /// a must be provided using + /// the attribute. + /// + public class ConfigProperty { + private object? propertyValue; + + /// + /// Describes the property. + /// + public PropertyInfo? PropertyInfo { get; set; } + + /// + /// Helper to get either the Json property named (specified by [JsonPropertyName(name)] + /// or the actual property name. + /// + /// + /// + public static string GetJsonPropertyName (PropertyInfo pi) + { + var jpna = pi.GetCustomAttribute (typeof (JsonPropertyNameAttribute)) as JsonPropertyNameAttribute; + return jpna?.Name ?? pi.Name; } /// - /// Holds a property's value and the that allows - /// to get and set the property's value. + /// Holds the property's value as it was either read from the class's implementation or from a config file. + /// If the property has not been set (e.g. because no configuration file specified a value), + /// this will be . /// /// - /// Configuration properties must be and - /// and have the - /// attribute. If the type of the property requires specialized JSON serialization, - /// a must be provided using - /// the attribute. + /// On , performs a sparse-copy of the new value to the existing value (only copies elements of + /// the object that are non-null). /// - public class ConfigProperty { - private object? propertyValue; - - /// - /// Describes the property. - /// - public PropertyInfo? PropertyInfo { get; set; } - - /// - /// Helper to get either the Json property named (specified by [JsonPropertyName(name)] - /// or the actual property name. - /// - /// - /// - public static string GetJsonPropertyName (PropertyInfo pi) - { - var jpna = pi.GetCustomAttribute (typeof (JsonPropertyNameAttribute)) as JsonPropertyNameAttribute; - return jpna?.Name ?? pi.Name; + public object? PropertyValue { + get => propertyValue; + set { + propertyValue = value; } + } - /// - /// Holds the property's value as it was either read from the class's implementation or from a config file. - /// If the property has not been set (e.g. because no configuration file specified a value), - /// this will be . - /// - /// - /// On , performs a sparse-copy of the new value to the existing value (only copies elements of - /// the object that are non-null). - /// - public object? PropertyValue { - get => propertyValue; - set { - propertyValue = value; - } - } - - internal object? UpdateValueFrom (object source) - { - if (source == null) { - return PropertyValue; - } - - var ut = Nullable.GetUnderlyingType (PropertyInfo!.PropertyType); - if (source.GetType () != PropertyInfo!.PropertyType && (ut != null && source.GetType () != ut)) { - throw new ArgumentException ($"The source object ({PropertyInfo!.DeclaringType}.{PropertyInfo!.Name}) is not of type {PropertyInfo!.PropertyType}."); - } - if (PropertyValue != null && source != null) { - PropertyValue = DeepMemberwiseCopy (source, PropertyValue); - } else { - PropertyValue = source; - } - + internal object? UpdateValueFrom (object source) + { + if (source == null) { return PropertyValue; } - /// - /// Retrieves (using reflection) the value of the static property described in - /// into . - /// - /// - public object? RetrieveValue () - { - return PropertyValue = PropertyInfo!.GetValue (null); + var ut = Nullable.GetUnderlyingType (PropertyInfo!.PropertyType); + if (source.GetType () != PropertyInfo!.PropertyType && (ut != null && source.GetType () != ut)) { + throw new ArgumentException ($"The source object ({PropertyInfo!.DeclaringType}.{PropertyInfo!.Name}) is not of type {PropertyInfo!.PropertyType}."); + } + if (PropertyValue != null && source != null) { + PropertyValue = DeepMemberwiseCopy (source, PropertyValue); + } else { + PropertyValue = source; } - /// - /// Applies the to the property described by . - /// - /// - public bool Apply () - { - if (PropertyValue != null) { - PropertyInfo?.SetValue (null, DeepMemberwiseCopy (PropertyValue, PropertyInfo?.GetValue (null))); - } - return PropertyValue != null; - } + return PropertyValue; } /// - /// A dictionary of all properties in the Terminal.Gui project that are decorated with the attribute. - /// The keys are the property names pre-pended with the class that implements the property (e.g. Application.UseSystemConsole). - /// The values are instances of which hold the property's value and the - /// that allows to get and set the property's value. - /// - /// - /// Is until is called. - /// - private static Dictionary? _allConfigProperties; - - /// - /// The backing property for . - /// - /// - /// Is until is called. Gets set to a new instance by - /// deserialization (see ). - /// - private static SettingsScope? _settings; - - /// - /// The root object of Terminal.Gui configuration settings / JSON schema. Contains only properties with the - /// attribute value. - /// - public static SettingsScope? Settings { - get { - if (_settings == null) { - throw new InvalidOperationException ("ConfigurationManager has not been initialized. Call ConfigurationManager.Reset() before accessing the Settings property."); - } - return _settings; - } - set { - _settings = value!; - } - } - - /// - /// The root object of Terminal.Gui themes manager. Contains only properties with the - /// attribute value. - /// - public static ThemeManager? Themes => ThemeManager.Instance; - - /// - /// Application-specific configuration settings scope. - /// - [SerializableConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true), JsonPropertyName ("AppSettings")] - public static AppScope? AppSettings { get; set; } - - /// - /// The set of glyphs used to draw checkboxes, lines, borders, etc...See also . - /// - [SerializableConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true), - JsonPropertyName ("Glyphs")] - public static GlyphDefinitions Glyphs { get; set; } = new GlyphDefinitions (); - - /// - /// Initializes the internal state of ConfigurationManager. Nominally called once as part of application - /// startup to initialize global state. Also called from some Unit Tests to ensure correctness (e.g. Reset()). - /// - internal static void Initialize () - { - _allConfigProperties = new Dictionary (); - _settings = null; - - Dictionary classesWithConfigProps = new Dictionary (StringComparer.InvariantCultureIgnoreCase); - // Get Terminal.Gui.dll classes - - var types = from assembly in AppDomain.CurrentDomain.GetAssemblies () - from type in assembly.GetTypes () - where type.GetProperties ().Any (prop => prop.GetCustomAttribute (typeof (SerializableConfigurationProperty)) != null) - select type; - - foreach (var classWithConfig in types) { - classesWithConfigProps.Add (classWithConfig.Name, classWithConfig); - } - - Debug.WriteLine ($"ConfigManager.getConfigProperties found {classesWithConfigProps.Count} classes:"); - classesWithConfigProps.ToList ().ForEach (x => Debug.WriteLine ($" Class: {x.Key}")); - - foreach (var p in from c in classesWithConfigProps - let props = c.Value.GetProperties (BindingFlags.Instance | BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).Where (prop => - prop.GetCustomAttribute (typeof (SerializableConfigurationProperty)) is SerializableConfigurationProperty) - let enumerable = props - from p in enumerable - select p) { - if (p.GetCustomAttribute (typeof (SerializableConfigurationProperty)) is SerializableConfigurationProperty scp) { - if (p.GetGetMethod (true)!.IsStatic) { - // If the class name is omitted, JsonPropertyName is allowed. - _allConfigProperties!.Add (scp.OmitClassName ? ConfigProperty.GetJsonPropertyName (p) : $"{p.DeclaringType?.Name}.{p.Name}", new ConfigProperty { - PropertyInfo = p, - PropertyValue = null - }); - } else { - throw new Exception ($"Property {p.Name} in class {p.DeclaringType?.Name} is not static. All SerializableConfigurationProperty properties must be static."); - } - } - } - - _allConfigProperties = _allConfigProperties!.OrderBy (x => x.Key).ToDictionary (x => x.Key, x => x.Value, StringComparer.InvariantCultureIgnoreCase); - - Debug.WriteLine ($"ConfigManager.Initialize found {_allConfigProperties.Count} properties:"); - //_allConfigProperties.ToList ().ForEach (x => Debug.WriteLine ($" Property: {x.Key}")); - - AppSettings = new AppScope (); - } - - /// - /// Creates a JSON document with the configuration specified. + /// Retrieves (using reflection) the value of the static property described in + /// into . /// /// - internal static string ToJson () + public object? RetrieveValue () { - Debug.WriteLine ($"ConfigurationManager.ToJson()"); - return JsonSerializer.Serialize (Settings!, _serializerOptions); - } - - internal static Stream ToStream () - { - var json = JsonSerializer.Serialize (Settings!, _serializerOptions); - // turn it into a stream - var stream = new MemoryStream (); - var writer = new StreamWriter (stream); - writer.Write (json); - writer.Flush (); - stream.Position = 0; - return stream; + return PropertyValue = PropertyInfo!.GetValue (null); } /// - /// Gets or sets whether the should throw an exception if it encounters - /// an error on deserialization. If (the default), the error is logged and printed to the - /// console when is called. - /// - [SerializableConfigurationProperty (Scope = typeof (SettingsScope))] - public static bool? ThrowOnJsonErrors { get; set; } = false; - - internal static StringBuilder jsonErrors = new StringBuilder (); - - private static void AddJsonError (string error) - { - Debug.WriteLine ($"ConfigurationManager: {error}"); - jsonErrors.AppendLine (error); - } - - /// - /// Prints any Json deserialization errors that occurred during deserialization to the console. - /// - public static void PrintJsonErrors () - { - if (jsonErrors.Length > 0) { - Console.WriteLine ($"Terminal.Gui ConfigurationManager encountered the following errors while deserializing configuration files:"); - Console.WriteLine (jsonErrors.ToString ()); - } - } - - private static void ClearJsonErrors () - { - jsonErrors.Clear (); - } - - /// - /// Called when the configuration has been updated from a configuration file. Invokes the - /// event. - /// - public static void OnUpdated () - { - Debug.WriteLine ($"ConfigurationManager.OnApplied()"); - Updated?.Invoke (null, new ConfigurationManagerEventArgs ()); - } - - /// - /// Event fired when the configuration has been updated from a configuration source. - /// application. - /// - public static event EventHandler? Updated; - - /// - /// Resets the state of . Should be called whenever a new app session - /// (e.g. in starts. Called by - /// if the reset parameter is . - /// - /// - /// - /// - public static void Reset () - { - Debug.WriteLine ($"ConfigurationManager.Reset()"); - if (_allConfigProperties == null) { - ConfigurationManager.Initialize (); - } - - ClearJsonErrors (); - - Settings = new SettingsScope (); - ThemeManager.Reset (); - AppSettings = new AppScope (); - - // To enable some unit tests, we only load from resources if the flag is set - if (Locations.HasFlag (ConfigLocations.DefaultOnly)) { - Settings.UpdateFromResource (typeof (ConfigurationManager).Assembly, $"Terminal.Gui.Resources.{_configFilename}"); - } - Apply (); - ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply (); - AppSettings?.Apply (); - } - - /// - /// Retrieves the hard coded default settings from the Terminal.Gui library implementation. Used in development of - /// the library to generate the default configuration file. Before calling Application.Init, make sure - /// is set to . - /// - /// - /// - /// This method is only really useful when using ConfigurationManagerTests - /// to generate the JSON doc that is embedded into Terminal.Gui (during development). - /// - /// - /// WARNING: The Terminal.Gui.Resources.config.json resource has setting definitions (Themes) - /// that are NOT generated by this function. If you use this function to regenerate Terminal.Gui.Resources.config.json, - /// make sure you copy the Theme definitions from the existing Terminal.Gui.Resources.config.json file. - /// - /// - internal static void GetHardCodedDefaults () - { - if (_allConfigProperties == null) { - throw new InvalidOperationException ("Initialize must be called first."); - } - Settings = new SettingsScope (); - ThemeManager.GetHardCodedDefaults (); - AppSettings?.RetrieveValues (); - foreach (var p in Settings!.Where (cp => cp.Value.PropertyInfo != null)) { - Settings! [p.Key].PropertyValue = p.Value.PropertyInfo?.GetValue (null); - } - } - - /// - /// Applies the configuration settings to the running instance. - /// - public static void Apply () - { - bool settings = Settings?.Apply () ?? false; - bool themes = !string.IsNullOrEmpty(ThemeManager.SelectedTheme) && (ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply () ?? false); - bool appsettings = AppSettings?.Apply () ?? false; - if (settings || themes || appsettings) { - OnApplied (); - } - } - - /// - /// Called when an updated configuration has been applied to the - /// application. Fires the event. - /// - public static void OnApplied () - { - Debug.WriteLine ($"ConfigurationManager.OnApplied()"); - Applied?.Invoke (null, new ConfigurationManagerEventArgs ()); - } - - /// - /// Event fired when an updated configuration has been applied to the - /// application. - /// - public static event EventHandler? Applied; - - /// - /// Name of the running application. By default this property is set to the application's assembly name. - /// - public static string AppName { get; set; } = Assembly.GetEntryAssembly ()?.FullName?.Split (',') [0]?.Trim ()!; - - /// - /// Describes the location of the configuration files. The constants can be - /// combined (bitwise) to specify multiple locations. - /// - [Flags] - public enum ConfigLocations { - /// - /// No configuration will be loaded. - /// - /// - /// Used for development and testing only. For Terminal,Gui to function properly, at least - /// should be set. - /// - None = 0, - - /// - /// Global configuration in Terminal.Gui.dll's resources (Terminal.Gui.Resources.config.json) -- Lowest Precidence. - /// - DefaultOnly, - - /// - /// This constant is a combination of all locations - /// - All = -1 - - } - - /// - /// Gets and sets the locations where will look for config files. - /// The value is . - /// - public static ConfigLocations Locations { get; set; } = ConfigLocations.All; - - /// - /// Loads all settings found in the various configuration storage locations to - /// the . Optionally, - /// resets all settings attributed with to the defaults. - /// - /// - /// Use to cause the loaded settings to be applied to the running application. - /// - /// If the state of will - /// be reset to the defaults. - public static void Load (bool reset = false) - { - Debug.WriteLine ($"ConfigurationManager.Load()"); - - if (reset) Reset (); - - // LibraryResources is always loaded by Reset - if (Locations == ConfigLocations.All) { - var embeddedStylesResourceName = Assembly.GetEntryAssembly ()? - .GetManifestResourceNames ().FirstOrDefault (x => x.EndsWith (_configFilename)); - if (string.IsNullOrEmpty (embeddedStylesResourceName)) { - embeddedStylesResourceName = _configFilename; - } - - Settings = Settings? - // Global current directory - .Update ($"./.tui/{_configFilename}")? - // Global home directory - .Update ($"~/.tui/{_configFilename}")? - // App resources - .UpdateFromResource (Assembly.GetEntryAssembly ()!, embeddedStylesResourceName!)? - // App current directory - .Update ($"./.tui/{AppName}.{_configFilename}")? - // App home directory - .Update ($"~/.tui/{AppName}.{_configFilename}"); - } - } - - /// - /// Returns an empty Json document with just the $schema tag. + /// Applies the to the property described by . /// /// - public static string GetEmptyJson () + public bool Apply () { - var emptyScope = new SettingsScope (); - emptyScope.Clear (); - return JsonSerializer.Serialize (emptyScope, _serializerOptions); + if (PropertyValue != null) { + PropertyInfo?.SetValue (null, DeepMemberwiseCopy (PropertyValue, PropertyInfo?.GetValue (null))); + } + return PropertyValue != null; } - - /// - /// System.Text.Json does not support copying a deserialized object to an existing instance. - /// To work around this, we implement a 'deep, memberwise copy' method. - /// - /// - /// TOOD: When System.Text.Json implements `PopulateObject` revisit - /// https://github.com/dotnet/corefx/issues/37627 - /// - /// - /// - /// updated from - internal static object? DeepMemberwiseCopy (object? source, object? destination) - { - if (destination == null) { - throw new ArgumentNullException (nameof (destination)); - } - - if (source == null) { - return null!; - } - - if (source.GetType () == typeof (SettingsScope)) { - return ((SettingsScope)destination).Update ((SettingsScope)source); - } - if (source.GetType () == typeof (ThemeScope)) { - return ((ThemeScope)destination).Update ((ThemeScope)source); - } - if (source.GetType () == typeof (AppScope)) { - return ((AppScope)destination).Update ((AppScope)source); - } - - // If value type, just use copy constructor. - if (source.GetType ().IsValueType || source.GetType () == typeof (string)) { - return source; - } - - // Dictionary - if (source.GetType ().IsGenericType && source.GetType ().GetGenericTypeDefinition ().IsAssignableFrom (typeof (Dictionary<,>))) { - foreach (var srcKey in ((IDictionary)source).Keys) { - if (((IDictionary)destination).Contains (srcKey)) - ((IDictionary)destination) [srcKey] = DeepMemberwiseCopy (((IDictionary)source) [srcKey], ((IDictionary)destination) [srcKey]); - else { - ((IDictionary)destination).Add (srcKey, ((IDictionary)source) [srcKey]); - } - } - return destination; - } - - // ALl other object types - var sourceProps = source?.GetType ().GetProperties ().Where (x => x.CanRead).ToList (); - var destProps = destination?.GetType ().GetProperties ().Where (x => x.CanWrite).ToList ()!; - foreach (var (sourceProp, destProp) in - from sourceProp in sourceProps - where destProps.Any (x => x.Name == sourceProp.Name) - let destProp = destProps.First (x => x.Name == sourceProp.Name) - where destProp.CanWrite - select (sourceProp, destProp)) { - - var sourceVal = sourceProp.GetValue (source); - var destVal = destProp.GetValue (destination); - if (sourceVal != null) { - if (destVal != null) { - // Recurse - destProp.SetValue (destination, DeepMemberwiseCopy (sourceVal, destVal)); - } else { - destProp.SetValue (destination, sourceVal); - } - } - } - return destination!; - } - - //public class ConfiguraitonLocation - //{ - // public string Name { get; set; } = string.Empty; - - // public string? Path { get; set; } - - // public async Task UpdateAsync (Stream stream) - // { - // var scope = await JsonSerializer.DeserializeAsync (stream, serializerOptions); - // if (scope != null) { - // ConfigurationManager.Settings?.UpdateFrom (scope); - // return scope; - // } - // return new SettingsScope (); - // } - - //} - - //public class StreamConfiguration { - // private bool _reset; - - // public StreamConfiguration (bool reset) - // { - // _reset = reset; - // } - - // public StreamConfiguration UpdateAppResources () - // { - // if (Locations.HasFlag (ConfigLocations.AppResources)) LoadAppResources (); - // return this; - // } - - // public StreamConfiguration UpdateAppDirectory () - // { - // if (Locations.HasFlag (ConfigLocations.AppDirectory)) LoadAppDirectory (); - // return this; - // } - - // // Additional update methods for each location here - - // private void LoadAppResources () - // { - // // Load AppResources logic here - // } - - // private void LoadAppDirectory () - // { - // // Load AppDirectory logic here - // } - //} } + + /// + /// A dictionary of all properties in the Terminal.Gui project that are decorated with the attribute. + /// The keys are the property names pre-pended with the class that implements the property (e.g. Application.UseSystemConsole). + /// The values are instances of which hold the property's value and the + /// that allows to get and set the property's value. + /// + /// + /// Is until is called. + /// + private static Dictionary? _allConfigProperties; + + /// + /// The backing property for . + /// + /// + /// Is until is called. Gets set to a new instance by + /// deserialization (see ). + /// + private static SettingsScope? _settings; + + /// + /// The root object of Terminal.Gui configuration settings / JSON schema. Contains only properties with the + /// attribute value. + /// + public static SettingsScope? Settings { + get { + if (_settings == null) { + throw new InvalidOperationException ("ConfigurationManager has not been initialized. Call ConfigurationManager.Reset() before accessing the Settings property."); + } + return _settings; + } + set { + _settings = value!; + } + } + + /// + /// The root object of Terminal.Gui themes manager. Contains only properties with the + /// attribute value. + /// + public static ThemeManager? Themes => ThemeManager.Instance; + + /// + /// Application-specific configuration settings scope. + /// + [SerializableConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true), JsonPropertyName ("AppSettings")] + public static AppScope? AppSettings { get; set; } + + /// + /// The set of glyphs used to draw checkboxes, lines, borders, etc...See also . + /// + [SerializableConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true), + JsonPropertyName ("Glyphs")] + public static GlyphDefinitions Glyphs { get; set; } = new GlyphDefinitions (); + + /// + /// Initializes the internal state of ConfigurationManager. Nominally called once as part of application + /// startup to initialize global state. Also called from some Unit Tests to ensure correctness (e.g. Reset()). + /// + internal static void Initialize () + { + _allConfigProperties = new Dictionary (); + _settings = null; + + Dictionary classesWithConfigProps = new Dictionary (StringComparer.InvariantCultureIgnoreCase); + // Get Terminal.Gui.dll classes + + var types = from assembly in AppDomain.CurrentDomain.GetAssemblies () + from type in assembly.GetTypes () + where type.GetProperties ().Any (prop => prop.GetCustomAttribute (typeof (SerializableConfigurationProperty)) != null) + select type; + + foreach (var classWithConfig in types) { + classesWithConfigProps.Add (classWithConfig.Name, classWithConfig); + } + + Debug.WriteLine ($"ConfigManager.getConfigProperties found {classesWithConfigProps.Count} classes:"); + classesWithConfigProps.ToList ().ForEach (x => Debug.WriteLine ($" Class: {x.Key}")); + + foreach (var p in from c in classesWithConfigProps + let props = c.Value.GetProperties (BindingFlags.Instance | BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).Where (prop => + prop.GetCustomAttribute (typeof (SerializableConfigurationProperty)) is SerializableConfigurationProperty) + let enumerable = props + from p in enumerable + select p) { + if (p.GetCustomAttribute (typeof (SerializableConfigurationProperty)) is SerializableConfigurationProperty scp) { + if (p.GetGetMethod (true)!.IsStatic) { + // If the class name is omitted, JsonPropertyName is allowed. + _allConfigProperties!.Add (scp.OmitClassName ? ConfigProperty.GetJsonPropertyName (p) : $"{p.DeclaringType?.Name}.{p.Name}", new ConfigProperty { + PropertyInfo = p, + PropertyValue = null + }); + } else { + throw new Exception ($"Property {p.Name} in class {p.DeclaringType?.Name} is not static. All SerializableConfigurationProperty properties must be static."); + } + } + } + + _allConfigProperties = _allConfigProperties!.OrderBy (x => x.Key).ToDictionary (x => x.Key, x => x.Value, StringComparer.InvariantCultureIgnoreCase); + + Debug.WriteLine ($"ConfigManager.Initialize found {_allConfigProperties.Count} properties:"); + //_allConfigProperties.ToList ().ForEach (x => Debug.WriteLine ($" Property: {x.Key}")); + + AppSettings = new AppScope (); + } + + /// + /// Creates a JSON document with the configuration specified. + /// + /// + internal static string ToJson () + { + Debug.WriteLine ($"ConfigurationManager.ToJson()"); + return JsonSerializer.Serialize (Settings!, _serializerOptions); + } + + internal static Stream ToStream () + { + var json = JsonSerializer.Serialize (Settings!, _serializerOptions); + // turn it into a stream + var stream = new MemoryStream (); + var writer = new StreamWriter (stream); + writer.Write (json); + writer.Flush (); + stream.Position = 0; + return stream; + } + + /// + /// Gets or sets whether the should throw an exception if it encounters + /// an error on deserialization. If (the default), the error is logged and printed to the + /// console when is called. + /// + [SerializableConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool? ThrowOnJsonErrors { get; set; } = false; + + internal static StringBuilder jsonErrors = new StringBuilder (); + + private static void AddJsonError (string error) + { + Debug.WriteLine ($"ConfigurationManager: {error}"); + jsonErrors.AppendLine (error); + } + + /// + /// Prints any Json deserialization errors that occurred during deserialization to the console. + /// + public static void PrintJsonErrors () + { + if (jsonErrors.Length > 0) { + Console.WriteLine ($"Terminal.Gui ConfigurationManager encountered the following errors while deserializing configuration files:"); + Console.WriteLine (jsonErrors.ToString ()); + } + } + + private static void ClearJsonErrors () + { + jsonErrors.Clear (); + } + + /// + /// Called when the configuration has been updated from a configuration file. Invokes the + /// event. + /// + public static void OnUpdated () + { + Debug.WriteLine ($"ConfigurationManager.OnApplied()"); + Updated?.Invoke (null, new ConfigurationManagerEventArgs ()); + } + + /// + /// Event fired when the configuration has been updated from a configuration source. + /// application. + /// + public static event EventHandler? Updated; + + /// + /// Resets the state of . Should be called whenever a new app session + /// (e.g. in starts. Called by + /// if the reset parameter is . + /// + /// + /// + /// + public static void Reset () + { + Debug.WriteLine ($"ConfigurationManager.Reset()"); + if (_allConfigProperties == null) { + ConfigurationManager.Initialize (); + } + + ClearJsonErrors (); + + Settings = new SettingsScope (); + ThemeManager.Reset (); + AppSettings = new AppScope (); + + // To enable some unit tests, we only load from resources if the flag is set + if (Locations.HasFlag (ConfigLocations.DefaultOnly)) { + Settings.UpdateFromResource (typeof (ConfigurationManager).Assembly, $"Terminal.Gui.Resources.{_configFilename}"); + } + Apply (); + ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply (); + AppSettings?.Apply (); + } + + /// + /// Retrieves the hard coded default settings from the Terminal.Gui library implementation. Used in development of + /// the library to generate the default configuration file. Before calling Application.Init, make sure + /// is set to . + /// + /// + /// + /// This method is only really useful when using ConfigurationManagerTests + /// to generate the JSON doc that is embedded into Terminal.Gui (during development). + /// + /// + /// WARNING: The Terminal.Gui.Resources.config.json resource has setting definitions (Themes) + /// that are NOT generated by this function. If you use this function to regenerate Terminal.Gui.Resources.config.json, + /// make sure you copy the Theme definitions from the existing Terminal.Gui.Resources.config.json file. + /// + /// + internal static void GetHardCodedDefaults () + { + if (_allConfigProperties == null) { + throw new InvalidOperationException ("Initialize must be called first."); + } + Settings = new SettingsScope (); + ThemeManager.GetHardCodedDefaults (); + AppSettings?.RetrieveValues (); + foreach (var p in Settings!.Where (cp => cp.Value.PropertyInfo != null)) { + Settings! [p.Key].PropertyValue = p.Value.PropertyInfo?.GetValue (null); + } + } + + /// + /// Applies the configuration settings to the running instance. + /// + public static void Apply () + { + bool settings = Settings?.Apply () ?? false; + bool themes = !string.IsNullOrEmpty (ThemeManager.SelectedTheme) && (ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply () ?? false); + bool appsettings = AppSettings?.Apply () ?? false; + if (settings || themes || appsettings) { + OnApplied (); + } + } + + /// + /// Called when an updated configuration has been applied to the + /// application. Fires the event. + /// + public static void OnApplied () + { + Debug.WriteLine ($"ConfigurationManager.OnApplied()"); + Applied?.Invoke (null, new ConfigurationManagerEventArgs ()); + } + + /// + /// Event fired when an updated configuration has been applied to the + /// application. + /// + public static event EventHandler? Applied; + + /// + /// Name of the running application. By default this property is set to the application's assembly name. + /// + public static string AppName { get; set; } = Assembly.GetEntryAssembly ()?.FullName?.Split (',') [0]?.Trim ()!; + + /// + /// Describes the location of the configuration files. The constants can be + /// combined (bitwise) to specify multiple locations. + /// + [Flags] + public enum ConfigLocations { + /// + /// No configuration will be loaded. + /// + /// + /// Used for development and testing only. For Terminal,Gui to function properly, at least + /// should be set. + /// + None = 0, + + /// + /// Global configuration in Terminal.Gui.dll's resources (Terminal.Gui.Resources.config.json) -- Lowest Precidence. + /// + DefaultOnly, + + /// + /// This constant is a combination of all locations + /// + All = -1 + + } + + /// + /// Gets and sets the locations where will look for config files. + /// The value is . + /// + public static ConfigLocations Locations { get; set; } = ConfigLocations.All; + + /// + /// Loads all settings found in the various configuration storage locations to + /// the . Optionally, + /// resets all settings attributed with to the defaults. + /// + /// + /// Use to cause the loaded settings to be applied to the running application. + /// + /// If the state of will + /// be reset to the defaults. + public static void Load (bool reset = false) + { + Debug.WriteLine ($"ConfigurationManager.Load()"); + + if (reset) Reset (); + + // LibraryResources is always loaded by Reset + if (Locations == ConfigLocations.All) { + var embeddedStylesResourceName = Assembly.GetEntryAssembly ()? + .GetManifestResourceNames ().FirstOrDefault (x => x.EndsWith (_configFilename)); + if (string.IsNullOrEmpty (embeddedStylesResourceName)) { + embeddedStylesResourceName = _configFilename; + } + + Settings = Settings? + // Global current directory + .Update ($"./.tui/{_configFilename}")? + // Global home directory + .Update ($"~/.tui/{_configFilename}")? + // App resources + .UpdateFromResource (Assembly.GetEntryAssembly ()!, embeddedStylesResourceName!)? + // App current directory + .Update ($"./.tui/{AppName}.{_configFilename}")? + // App home directory + .Update ($"~/.tui/{AppName}.{_configFilename}"); + } + } + + /// + /// Returns an empty Json document with just the $schema tag. + /// + /// + public static string GetEmptyJson () + { + var emptyScope = new SettingsScope (); + emptyScope.Clear (); + return JsonSerializer.Serialize (emptyScope, _serializerOptions); + } + + /// + /// System.Text.Json does not support copying a deserialized object to an existing instance. + /// To work around this, we implement a 'deep, memberwise copy' method. + /// + /// + /// TOOD: When System.Text.Json implements `PopulateObject` revisit + /// https://github.com/dotnet/corefx/issues/37627 + /// + /// + /// + /// updated from + internal static object? DeepMemberwiseCopy (object? source, object? destination) + { + if (destination == null) { + throw new ArgumentNullException (nameof (destination)); + } + + if (source == null) { + return null!; + } + + if (source.GetType () == typeof (SettingsScope)) { + return ((SettingsScope)destination).Update ((SettingsScope)source); + } + if (source.GetType () == typeof (ThemeScope)) { + return ((ThemeScope)destination).Update ((ThemeScope)source); + } + if (source.GetType () == typeof (AppScope)) { + return ((AppScope)destination).Update ((AppScope)source); + } + + // If value type, just use copy constructor. + if (source.GetType ().IsValueType || source.GetType () == typeof (string)) { + return source; + } + + // Dictionary + if (source.GetType ().IsGenericType && source.GetType ().GetGenericTypeDefinition ().IsAssignableFrom (typeof (Dictionary<,>))) { + foreach (var srcKey in ((IDictionary)source).Keys) { + if (((IDictionary)destination).Contains (srcKey)) + ((IDictionary)destination) [srcKey] = DeepMemberwiseCopy (((IDictionary)source) [srcKey], ((IDictionary)destination) [srcKey]); + else { + ((IDictionary)destination).Add (srcKey, ((IDictionary)source) [srcKey]); + } + } + return destination; + } + + // ALl other object types + var sourceProps = source?.GetType ().GetProperties ().Where (x => x.CanRead).ToList (); + var destProps = destination?.GetType ().GetProperties ().Where (x => x.CanWrite).ToList ()!; + foreach (var (sourceProp, destProp) in + from sourceProp in sourceProps + where destProps.Any (x => x.Name == sourceProp.Name) + let destProp = destProps.First (x => x.Name == sourceProp.Name) + where destProp.CanWrite + select (sourceProp, destProp)) { + + var sourceVal = sourceProp.GetValue (source); + var destVal = destProp.GetValue (destination); + if (sourceVal != null) { + if (destVal != null) { + // Recurse + destProp.SetValue (destination, DeepMemberwiseCopy (sourceVal, destVal)); + } else { + destProp.SetValue (destination, sourceVal); + } + } + } + return destination!; + } + + //public class ConfiguraitonLocation + //{ + // public string Name { get; set; } = string.Empty; + + // public string? Path { get; set; } + + // public async Task UpdateAsync (Stream stream) + // { + // var scope = await JsonSerializer.DeserializeAsync (stream, serializerOptions); + // if (scope != null) { + // ConfigurationManager.Settings?.UpdateFrom (scope); + // return scope; + // } + // return new SettingsScope (); + // } + + //} + + //public class StreamConfiguration { + // private bool _reset; + + // public StreamConfiguration (bool reset) + // { + // _reset = reset; + // } + + // public StreamConfiguration UpdateAppResources () + // { + // if (Locations.HasFlag (ConfigLocations.AppResources)) LoadAppResources (); + // return this; + // } + + // public StreamConfiguration UpdateAppDirectory () + // { + // if (Locations.HasFlag (ConfigLocations.AppDirectory)) LoadAppDirectory (); + // return this; + // } + + // // Additional update methods for each location here + + // private void LoadAppResources () + // { + // // Load AppResources logic here + // } + + // private void LoadAppDirectory () + // { + // // Load AppDirectory logic here + // } + //} } diff --git a/Terminal.Gui/Configuration/DictionaryJsonConverter.cs b/Terminal.Gui/Configuration/DictionaryJsonConverter.cs index d6187fc14..53ff7f3af 100644 --- a/Terminal.Gui/Configuration/DictionaryJsonConverter.cs +++ b/Terminal.Gui/Configuration/DictionaryJsonConverter.cs @@ -7,6 +7,10 @@ namespace Terminal.Gui { class DictionaryJsonConverter : JsonConverter> { public override Dictionary Read (ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType != JsonTokenType.StartArray) { + throw new JsonException ($"Expected a JSON array (\"[ {{ ... }} ]\"), but got \"{reader.TokenType}\"."); + } + var dictionary = new Dictionary (); while (reader.Read ()) { if (reader.TokenType == JsonTokenType.StartObject) { diff --git a/Terminal.Gui/Configuration/Scope.cs b/Terminal.Gui/Configuration/Scope.cs index d950405ed..245153dd2 100644 --- a/Terminal.Gui/Configuration/Scope.cs +++ b/Terminal.Gui/Configuration/Scope.cs @@ -100,7 +100,7 @@ namespace Terminal.Gui { public override scopeT Read (ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.StartObject) { - throw new JsonException ($"Expected a JSON object, but got \"{reader.TokenType}\"."); + throw new JsonException ($"Expected a JSON object (\"{{ \"propName\" : ... }}\"), but got \"{reader.TokenType}\"."); } var scope = (scopeT)Activator.CreateInstance (typeof (scopeT))!; diff --git a/Terminal.Gui/Resources/config.json b/Terminal.Gui/Resources/config.json index af1932937..10d489107 100644 --- a/Terminal.Gui/Resources/config.json +++ b/Terminal.Gui/Resources/config.json @@ -11,6 +11,11 @@ // null). // "$schema": "https://gui-cs.github.io/Terminal.Gui/schemas/tui-config-schema.json", + + // Set this to true in a .config file to be loaded to cause JSON parsing errors + // to throw exceptions. + "ConfigurationManager.ThrowOnJsonErrors": false, + "Application.AlternateBackwardKey": { "Key": "PageUp", "Modifiers": [ diff --git a/UnitTests/Configuration/ConfigurationMangerTests.cs b/UnitTests/Configuration/ConfigurationMangerTests.cs index 7390d6092..e95433e22 100644 --- a/UnitTests/Configuration/ConfigurationMangerTests.cs +++ b/UnitTests/Configuration/ConfigurationMangerTests.cs @@ -544,8 +544,7 @@ namespace Terminal.Gui.ConfigurationTests { // "yellow" is not a color string json = @" { - ""Themes"" : { - ""ThemeDefinitions"" : [ + ""Themes"" : [ { ""Default"" : { ""ColorSchemes"": [ @@ -560,8 +559,7 @@ namespace Terminal.Gui.ConfigurationTests { ] } } - ] - } + ] }"; JsonException jsonException = Assert.Throws (() => ConfigurationManager.Settings.Update (json, "test")); @@ -570,8 +568,7 @@ namespace Terminal.Gui.ConfigurationTests { // AbNormal is not a ColorScheme attribute json = @" { - ""Themes"" : { - ""ThemeDefinitions"" : [ + ""Themes"" : [ { ""Default"" : { ""ColorSchemes"": [ @@ -586,8 +583,7 @@ namespace Terminal.Gui.ConfigurationTests { ] } } - ] - } + ] }"; jsonException = Assert.Throws (() => ConfigurationManager.Settings.Update (json, "test")); @@ -596,8 +592,7 @@ namespace Terminal.Gui.ConfigurationTests { // Modify hotNormal background only json = @" { - ""Themes"" : { - ""ThemeDefinitions"" : [ + ""Themes"" : [ { ""Default"" : { ""ColorSchemes"": [ @@ -611,8 +606,7 @@ namespace Terminal.Gui.ConfigurationTests { ] } } - ] - } + ] }"; jsonException = Assert.Throws (() => ConfigurationManager.Settings.Update (json, "test")); @@ -641,8 +635,7 @@ namespace Terminal.Gui.ConfigurationTests { // "yellow" is not a color string json = @" { - ""Themes"" : { - ""ThemeDefinitions"" : [ + ""Themes"" : [ { ""Default"" : { ""ColorSchemes"": [ @@ -657,7 +650,6 @@ namespace Terminal.Gui.ConfigurationTests { ] } } - ] } }"; @@ -666,8 +658,7 @@ namespace Terminal.Gui.ConfigurationTests { // AbNormal is not a ColorScheme attribute json = @" { - ""Themes"" : { - ""ThemeDefinitions"" : [ + ""Themes"" : [ { ""Default"" : { ""ColorSchemes"": [ @@ -682,7 +673,6 @@ namespace Terminal.Gui.ConfigurationTests { ] } } - ] } }"; @@ -691,8 +681,7 @@ namespace Terminal.Gui.ConfigurationTests { // Modify hotNormal background only json = @" { - ""Themes"" : { - ""ThemeDefinitions"" : [ + ""Themes"" : [ { ""Default"" : { ""ColorSchemes"": [ @@ -706,7 +695,6 @@ namespace Terminal.Gui.ConfigurationTests { ] } } - ] } }";