using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using static Terminal.Gui.ConfigurationManager; #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 { 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 = { // No need to set converters - the ConfigRootConverter uses property attributes apply the correct // Converter. }, }; /// /// 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 BorderStyle DefaultBorderStyle { /// ... /// [AttributeUsage (AttributeTargets.Property, AllowMultiple = false, Inherited = false)] public class SerializableConfigurationProperty : System.Attribute { /// /// Specifies the scope of the property. /// public Type? Scope { 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 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; } return PropertyValue; } /// /// Retrieves (using reflection) the value of the static property described in /// into . /// /// public object? RetrieveValue () { return PropertyValue = PropertyInfo!.GetValue (null); } /// /// Applies the to the property described by . /// /// public bool Apply () { if (PropertyValue != null) { PropertyInfo?.SetValue (null, DeepMemberwiseCopy (PropertyValue, PropertyInfo?.GetValue (null))); } return PropertyValue != null; } } /// /// 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; } /// /// 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} clases:"); 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 = 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 // } //} } }