global using static Terminal.Gui.ConfigurationManager; global using CM = Terminal.Gui.ConfigurationManager; 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 static Terminal.Gui.SpinnerStyle; #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"; internal 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(), }, }; /// /// 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. /// internal 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 (); internal 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 () { var settings = false; var themes = false; var appSettings = false; try { settings = Settings?.Apply () ?? false; themes = !string.IsNullOrEmpty (ThemeManager.SelectedTheme) && (ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply () ?? false); appSettings = AppSettings?.Apply () ?? false; } catch (JsonException e) { if (ThrowOnJsonErrors ?? false) { throw; } else { AddJsonError ($"Error applying Configuration Change: {e.Message}"); } } finally { 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 ()); // TODO: Refactor ConfigurationManager to not use an event handler for this. // Instead, have it call a method on any class appropriately attributed // to update the cached values. See Issue #2871 } /// /// 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 (srcKey is string) { } 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 // } //} }