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 (); }
}