global using static Terminal.Gui.ConfigurationManager; global using CM = Terminal.Gui.ConfigurationManager; using System.Collections; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.Versioning; using System.Text.Encodings.Web; using System.Text.Json; 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.GuiV2Docs/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' 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' 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 Precedence. /// /// [ComponentGuarantees (ComponentGuaranteesOptions.None)] public static class ConfigurationManager { /// /// 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 Precedence. /// DefaultOnly, /// This constant is a combination of all locations All = -1 } /// /// 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. [SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "")] internal static Dictionary? _allConfigProperties; [SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "")] internal static readonly JsonSerializerOptions _serializerOptions = new () { 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 (), // Override Key to support "Ctrl+Q" format. new KeyJsonConverter () }, // Enables Key to be "Ctrl+Q" vs "Ctrl\u002BQ" Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, TypeInfoResolver = SourceGenerationContext.Default }; [SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "")] internal static readonly SourceGenerationContext _serializerContext = new (_serializerOptions); [SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "")] internal static StringBuilder _jsonErrors = new (); [SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "")] private static readonly string _configFilename = "config.json"; /// The backing property for . /// /// Is until is called. Gets set to a new instance by deserialization /// (see ). /// private static SettingsScope? _settings; /// 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 ()!; /// 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 (); /// /// Gets and sets the locations where will look for config files. The value is /// . /// public static ConfigLocations Locations { get; set; } = ConfigLocations.All; /// /// The root object of Terminal.Gui configuration settings / JSON schema. Contains only properties with the /// attribute value. /// public static SettingsScope? Settings { [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] get { if (_settings is null) { // If Settings is null, we need to initialize it. Reset (); } 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; /// /// 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; /// Event fired when an updated configuration has been applied to the application. public static event EventHandler? Applied; /// Applies the configuration settings to the running instance. [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] public static void Apply () { var settings = false; var themes = false; var appSettings = false; try { if (string.IsNullOrEmpty (ThemeManager.SelectedTheme)) { // First start. Apply settings first. This ensures if a config sets Theme to something other than "Default", it gets used settings = Settings?.Apply () ?? false; themes = !string.IsNullOrEmpty (ThemeManager.SelectedTheme) && (ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply () ?? false); } else { // Subsequently. Apply Themes first using whatever the SelectedTheme is themes = ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply () ?? false; settings = Settings?.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 (); } } } /// Returns an empty Json document with just the $schema tag. /// public static string GetEmptyJson () { var emptyScope = new SettingsScope (); emptyScope.Clear (); return JsonSerializer.Serialize (emptyScope, typeof (SettingsScope), _serializerContext); } /// /// 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. /// [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] public static void Load (bool reset = false) { Debug.WriteLine ("ConfigurationManager.Load()"); if (reset) { Reset (); } // LibraryResources is always loaded by Reset if (Locations == ConfigLocations.All) { string? 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}"); } } /// /// 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 ()); // 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 } /// /// 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 ()); } /// 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 ()); } } /// /// Resets the state of . Should be called whenever a new app session (e.g. in /// starts. Called by if the reset parameter is /// . /// /// [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] public static void Reset () { Debug.WriteLine (@"ConfigurationManager.Reset()"); if (_allConfigProperties is null) { Initialize (); } ClearJsonErrors (); Settings = new (); ThemeManager.Reset (); AppSettings = new (); // 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 (); } /// Event fired when the configuration has been updated from a configuration source. application. public static event EventHandler? Updated; internal static void AddJsonError (string error) { Debug.WriteLine ($"ConfigurationManager: {error}"); _jsonErrors.AppendLine (error); } /// /// System.Text.Json does not support copying a deserialized object to an existing instance. To work around this, /// we implement a 'deep, member-wise 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) { ArgumentNullException.ThrowIfNull (destination); if (source is 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 (object? 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 List? sourceProps = source?.GetType ().GetProperties ().Where (x => x.CanRead).ToList (); List? destProps = destination?.GetType ().GetProperties ().Where (x => x.CanWrite).ToList ()!; foreach ((PropertyInfo? sourceProp, PropertyInfo? 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)) { object? sourceVal = sourceProp.GetValue (source); object? destVal = destProp.GetValue (destination); if (sourceVal is { }) { try { if (destVal is { }) { // Recurse destProp.SetValue (destination, DeepMemberWiseCopy (sourceVal, destVal)); } else { destProp.SetValue (destination, sourceVal); } } catch (ArgumentException e) { throw new JsonException ($"Error Applying Configuration Change: {e.Message}", e); } } } return destination!; } /// /// 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. /// /// [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] internal static void GetHardCodedDefaults () { if (_allConfigProperties is null) { throw new InvalidOperationException ("Initialize must be called first."); } Settings = new (); ThemeManager.GetHardCodedDefaults (); AppSettings?.RetrieveValues (); foreach (KeyValuePair p in Settings!.Where (cp => cp.Value.PropertyInfo is { })) { Settings! [p.Key].PropertyValue = p.Value.PropertyInfo?.GetValue (null); } } /// /// 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()). /// [RequiresUnreferencedCode ("AOT")] internal static void Initialize () { _allConfigProperties = new (); _settings = null; Dictionary classesWithConfigProps = new (StringComparer.InvariantCultureIgnoreCase); // Get Terminal.Gui.dll classes IEnumerable 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 (Type? 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 (PropertyInfo? 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() { PropertyInfo = p, PropertyValue = null } ); } else { throw new ( $"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 (); } /// Creates a JSON document with the configuration specified. /// [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] internal static string ToJson () { //Debug.WriteLine ("ConfigurationManager.ToJson()"); return JsonSerializer.Serialize (Settings!, typeof (SettingsScope), _serializerContext); } [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] internal static Stream ToStream () { string json = JsonSerializer.Serialize (Settings!, typeof (SettingsScope), _serializerContext); // turn it into a stream var stream = new MemoryStream (); var writer = new StreamWriter (stream); writer.Write (json); writer.Flush (); stream.Position = 0; return stream; } private static void ClearJsonErrors () { _jsonErrors.Clear (); } }