ConfigurationManager.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. global using static Terminal.Gui.ConfigurationManager;
  2. global using CM = Terminal.Gui.ConfigurationManager;
  3. using System;
  4. using System.Collections;
  5. using System.Collections.Generic;
  6. using System.Diagnostics;
  7. using System.IO;
  8. using System.Linq;
  9. using System.Reflection;
  10. using System.Text;
  11. using System.Text.Json;
  12. using System.Text.Json.Serialization;
  13. #nullable enable
  14. namespace Terminal.Gui;
  15. /// <summary>
  16. /// Provides settings and configuration management for Terminal.Gui applications.
  17. /// <para>
  18. /// Users can set Terminal.Gui settings on a global or per-application basis by providing JSON formatted configuration files.
  19. /// The configuration files can be placed in at <c>.tui</c> folder in the user's home directory (e.g. <c>C:/Users/username/.tui</c>,
  20. /// or <c>/usr/username/.tui</c>),
  21. /// the folder where the Terminal.Gui application was launched from (e.g. <c>./.tui</c>), or as a resource
  22. /// within the Terminal.Gui application's main assembly.
  23. /// </para>
  24. /// <para>
  25. /// Settings are defined in JSON format, according to this schema:
  26. /// https://gui-cs.github.io/Terminal.Gui/schemas/tui-config-schema.json
  27. /// </para>
  28. /// <para>
  29. /// Settings that will apply to all applications (global settings) reside in files named <c>config.json</c>. Settings
  30. /// that will apply to a specific Terminal.Gui application reside in files named <c>appname.config.json</c>,
  31. /// where <c>appname</c> is the assembly name of the application (e.g. <c>UICatalog.config.json</c>).
  32. /// </para>
  33. /// Settings are applied using the following precedence (higher precedence settings
  34. /// overwrite lower precedence settings):
  35. /// <para>
  36. /// 1. Application configuration found in the users's home directory (<c>~/.tui/appname.config.json</c>) -- Highest precedence
  37. /// </para>
  38. /// <para>
  39. /// 2. Application configuration found in the directory the app was launched from (<c>./.tui/appname.config.json</c>).
  40. /// </para>
  41. /// <para>
  42. /// 3. Application configuration found in the applications's resources (<c>Resources/config.json</c>).
  43. /// </para>
  44. /// <para>
  45. /// 4. Global configuration found in the user's home directory (<c>~/.tui/config.json</c>).
  46. /// </para>
  47. /// <para>
  48. /// 5. Global configuration found in the directory the app was launched from (<c>./.tui/config.json</c>).
  49. /// </para>
  50. /// <para>
  51. /// 6. Global configuration in <c>Terminal.Gui.dll</c>'s resources (<c>Terminal.Gui.Resources.config.json</c>) -- Lowest Precidence.
  52. /// </para>
  53. /// </summary>
  54. public static partial class ConfigurationManager {
  55. private static readonly string _configFilename = "config.json";
  56. internal static readonly JsonSerializerOptions _serializerOptions = new JsonSerializerOptions {
  57. ReadCommentHandling = JsonCommentHandling.Skip,
  58. PropertyNameCaseInsensitive = true,
  59. DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
  60. WriteIndented = true,
  61. Converters = {
  62. // We override the standard Rune converter to support specifying Glyphs in
  63. // a flexible way
  64. new RuneJsonConverter(),
  65. },
  66. };
  67. /// <summary>
  68. /// A dictionary of all properties in the Terminal.Gui project that are decorated with the <see cref="SerializableConfigurationProperty"/> attribute.
  69. /// The keys are the property names pre-pended with the class that implements the property (e.g. <c>Application.UseSystemConsole</c>).
  70. /// The values are instances of <see cref="ConfigProperty"/> which hold the property's value and the
  71. /// <see cref="PropertyInfo"/> that allows <see cref="ConfigurationManager"/> to get and set the property's value.
  72. /// </summary>
  73. /// <remarks>
  74. /// Is <see langword="null"/> until <see cref="Initialize"/> is called.
  75. /// </remarks>
  76. internal static Dictionary<string, ConfigProperty>? _allConfigProperties;
  77. /// <summary>
  78. /// The backing property for <see cref="Settings"/>.
  79. /// </summary>
  80. /// <remarks>
  81. /// Is <see langword="null"/> until <see cref="Reset"/> is called. Gets set to a new instance by
  82. /// deserialization (see <see cref="Load"/>).
  83. /// </remarks>
  84. private static SettingsScope? _settings;
  85. /// <summary>
  86. /// The root object of Terminal.Gui configuration settings / JSON schema. Contains only properties with the <see cref="SettingsScope"/>
  87. /// attribute value.
  88. /// </summary>
  89. public static SettingsScope? Settings {
  90. get {
  91. if (_settings == null) {
  92. throw new InvalidOperationException ("ConfigurationManager has not been initialized. Call ConfigurationManager.Reset() before accessing the Settings property.");
  93. }
  94. return _settings;
  95. }
  96. set {
  97. _settings = value!;
  98. }
  99. }
  100. /// <summary>
  101. /// The root object of Terminal.Gui themes manager. Contains only properties with the <see cref="ThemeScope"/>
  102. /// attribute value.
  103. /// </summary>
  104. public static ThemeManager? Themes => ThemeManager.Instance;
  105. /// <summary>
  106. /// Application-specific configuration settings scope.
  107. /// </summary>
  108. [SerializableConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true), JsonPropertyName ("AppSettings")]
  109. public static AppScope? AppSettings { get; set; }
  110. /// <summary>
  111. /// The set of glyphs used to draw checkboxes, lines, borders, etc...See also <seealso cref="Terminal.Gui.GlyphDefinitions"/>.
  112. /// </summary>
  113. [SerializableConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true),
  114. JsonPropertyName ("Glyphs")]
  115. public static GlyphDefinitions Glyphs { get; set; } = new GlyphDefinitions ();
  116. /// <summary>
  117. /// Initializes the internal state of ConfigurationManager. Nominally called once as part of application
  118. /// startup to initialize global state. Also called from some Unit Tests to ensure correctness (e.g. Reset()).
  119. /// </summary>
  120. internal static void Initialize ()
  121. {
  122. _allConfigProperties = new Dictionary<string, ConfigProperty> ();
  123. _settings = null;
  124. Dictionary<string, Type> classesWithConfigProps = new Dictionary<string, Type> (StringComparer.InvariantCultureIgnoreCase);
  125. // Get Terminal.Gui.dll classes
  126. var types = from assembly in AppDomain.CurrentDomain.GetAssemblies ()
  127. from type in assembly.GetTypes ()
  128. where type.GetProperties ().Any (prop => prop.GetCustomAttribute (typeof (SerializableConfigurationProperty)) != null)
  129. select type;
  130. foreach (var classWithConfig in types) {
  131. classesWithConfigProps.Add (classWithConfig.Name, classWithConfig);
  132. }
  133. Debug.WriteLine ($"ConfigManager.getConfigProperties found {classesWithConfigProps.Count} classes:");
  134. classesWithConfigProps.ToList ().ForEach (x => Debug.WriteLine ($" Class: {x.Key}"));
  135. foreach (var p in from c in classesWithConfigProps
  136. let props = c.Value.GetProperties (BindingFlags.Instance | BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).Where (prop =>
  137. prop.GetCustomAttribute (typeof (SerializableConfigurationProperty)) is SerializableConfigurationProperty)
  138. let enumerable = props
  139. from p in enumerable
  140. select p) {
  141. if (p.GetCustomAttribute (typeof (SerializableConfigurationProperty)) is SerializableConfigurationProperty scp) {
  142. if (p.GetGetMethod (true)!.IsStatic) {
  143. // If the class name is omitted, JsonPropertyName is allowed.
  144. _allConfigProperties!.Add (scp.OmitClassName ? ConfigProperty.GetJsonPropertyName (p) : $"{p.DeclaringType?.Name}.{p.Name}", new ConfigProperty {
  145. PropertyInfo = p,
  146. PropertyValue = null
  147. });
  148. } else {
  149. throw new Exception ($"Property {p.Name} in class {p.DeclaringType?.Name} is not static. All SerializableConfigurationProperty properties must be static.");
  150. }
  151. }
  152. }
  153. _allConfigProperties = _allConfigProperties!.OrderBy (x => x.Key).ToDictionary (x => x.Key, x => x.Value, StringComparer.InvariantCultureIgnoreCase);
  154. Debug.WriteLine ($"ConfigManager.Initialize found {_allConfigProperties.Count} properties:");
  155. //_allConfigProperties.ToList ().ForEach (x => Debug.WriteLine ($" Property: {x.Key}"));
  156. AppSettings = new AppScope ();
  157. }
  158. /// <summary>
  159. /// Creates a JSON document with the configuration specified.
  160. /// </summary>
  161. /// <returns></returns>
  162. internal static string ToJson ()
  163. {
  164. Debug.WriteLine ($"ConfigurationManager.ToJson()");
  165. return JsonSerializer.Serialize<SettingsScope> (Settings!, _serializerOptions);
  166. }
  167. internal static Stream ToStream ()
  168. {
  169. var json = JsonSerializer.Serialize<SettingsScope> (Settings!, _serializerOptions);
  170. // turn it into a stream
  171. var stream = new MemoryStream ();
  172. var writer = new StreamWriter (stream);
  173. writer.Write (json);
  174. writer.Flush ();
  175. stream.Position = 0;
  176. return stream;
  177. }
  178. /// <summary>
  179. /// Gets or sets whether the <see cref="ConfigurationManager"/> should throw an exception if it encounters
  180. /// an error on deserialization. If <see langword="false"/> (the default), the error is logged and printed to the
  181. /// console when <see cref="Application.Shutdown"/> is called.
  182. /// </summary>
  183. [SerializableConfigurationProperty (Scope = typeof (SettingsScope))]
  184. public static bool? ThrowOnJsonErrors { get; set; } = false;
  185. internal static StringBuilder jsonErrors = new StringBuilder ();
  186. internal static void AddJsonError (string error)
  187. {
  188. Debug.WriteLine ($"ConfigurationManager: {error}");
  189. jsonErrors.AppendLine (error);
  190. }
  191. /// <summary>
  192. /// Prints any Json deserialization errors that occurred during deserialization to the console.
  193. /// </summary>
  194. public static void PrintJsonErrors ()
  195. {
  196. if (jsonErrors.Length > 0) {
  197. Console.WriteLine ($"Terminal.Gui ConfigurationManager encountered the following errors while deserializing configuration files:");
  198. Console.WriteLine (jsonErrors.ToString ());
  199. }
  200. }
  201. private static void ClearJsonErrors ()
  202. {
  203. jsonErrors.Clear ();
  204. }
  205. /// <summary>
  206. /// Called when the configuration has been updated from a configuration file. Invokes the <see cref="Updated"/>
  207. /// event.
  208. /// </summary>
  209. public static void OnUpdated ()
  210. {
  211. Debug.WriteLine ($"ConfigurationManager.OnApplied()");
  212. Updated?.Invoke (null, new ConfigurationManagerEventArgs ());
  213. }
  214. /// <summary>
  215. /// Event fired when the configuration has been updated from a configuration source.
  216. /// application.
  217. /// </summary>
  218. public static event EventHandler<ConfigurationManagerEventArgs>? Updated;
  219. /// <summary>
  220. /// Resets the state of <see cref="ConfigurationManager"/>. Should be called whenever a new app session
  221. /// (e.g. in <see cref="Application.Init(ConsoleDriver, IMainLoopDriver)"/> starts. Called by <see cref="Load"/>
  222. /// if the <c>reset</c> parameter is <see langword="true"/>.
  223. /// </summary>
  224. /// <remarks>
  225. ///
  226. /// </remarks>
  227. public static void Reset ()
  228. {
  229. Debug.WriteLine ($"ConfigurationManager.Reset()");
  230. if (_allConfigProperties == null) {
  231. ConfigurationManager.Initialize ();
  232. }
  233. ClearJsonErrors ();
  234. Settings = new SettingsScope ();
  235. ThemeManager.Reset ();
  236. AppSettings = new AppScope ();
  237. // To enable some unit tests, we only load from resources if the flag is set
  238. if (Locations.HasFlag (ConfigLocations.DefaultOnly)) {
  239. Settings.UpdateFromResource (typeof (ConfigurationManager).Assembly, $"Terminal.Gui.Resources.{_configFilename}");
  240. }
  241. Apply ();
  242. ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply ();
  243. AppSettings?.Apply ();
  244. }
  245. /// <summary>
  246. /// Retrieves the hard coded default settings from the Terminal.Gui library implementation. Used in development of
  247. /// the library to generate the default configuration file. Before calling Application.Init, make sure
  248. /// <see cref="Locations"/> is set to <see cref="ConfigLocations.None"/>.
  249. /// </summary>
  250. /// <remarks>
  251. /// <para>
  252. /// This method is only really useful when using ConfigurationManagerTests
  253. /// to generate the JSON doc that is embedded into Terminal.Gui (during development).
  254. /// </para>
  255. /// <para>
  256. /// WARNING: The <c>Terminal.Gui.Resources.config.json</c> resource has setting definitions (Themes)
  257. /// that are NOT generated by this function. If you use this function to regenerate <c>Terminal.Gui.Resources.config.json</c>,
  258. /// make sure you copy the Theme definitions from the existing <c>Terminal.Gui.Resources.config.json</c> file.
  259. /// </para>
  260. /// </remarks>
  261. internal static void GetHardCodedDefaults ()
  262. {
  263. if (_allConfigProperties == null) {
  264. throw new InvalidOperationException ("Initialize must be called first.");
  265. }
  266. Settings = new SettingsScope ();
  267. ThemeManager.GetHardCodedDefaults ();
  268. AppSettings?.RetrieveValues ();
  269. foreach (var p in Settings!.Where (cp => cp.Value.PropertyInfo != null)) {
  270. Settings! [p.Key].PropertyValue = p.Value.PropertyInfo?.GetValue (null);
  271. }
  272. }
  273. /// <summary>
  274. /// Applies the configuration settings to the running <see cref="Application"/> instance.
  275. /// </summary>
  276. public static void Apply ()
  277. {
  278. bool settings = Settings?.Apply () ?? false;
  279. bool themes = !string.IsNullOrEmpty (ThemeManager.SelectedTheme) && (ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply () ?? false);
  280. bool appsettings = AppSettings?.Apply () ?? false;
  281. if (settings || themes || appsettings) {
  282. OnApplied ();
  283. }
  284. }
  285. /// <summary>
  286. /// Called when an updated configuration has been applied to the
  287. /// application. Fires the <see cref="Applied"/> event.
  288. /// </summary>
  289. public static void OnApplied ()
  290. {
  291. Debug.WriteLine ($"ConfigurationManager.OnApplied()");
  292. Applied?.Invoke (null, new ConfigurationManagerEventArgs ());
  293. }
  294. /// <summary>
  295. /// Event fired when an updated configuration has been applied to the
  296. /// application.
  297. /// </summary>
  298. public static event EventHandler<ConfigurationManagerEventArgs>? Applied;
  299. /// <summary>
  300. /// Name of the running application. By default this property is set to the application's assembly name.
  301. /// </summary>
  302. public static string AppName { get; set; } = Assembly.GetEntryAssembly ()?.FullName?.Split (',') [0]?.Trim ()!;
  303. /// <summary>
  304. /// Describes the location of the configuration files. The constants can be
  305. /// combined (bitwise) to specify multiple locations.
  306. /// </summary>
  307. [Flags]
  308. public enum ConfigLocations {
  309. /// <summary>
  310. /// No configuration will be loaded.
  311. /// </summary>
  312. /// <remarks>
  313. /// Used for development and testing only. For Terminal,Gui to function properly, at least
  314. /// <see cref="DefaultOnly"/> should be set.
  315. /// </remarks>
  316. None = 0,
  317. /// <summary>
  318. /// Global configuration in <c>Terminal.Gui.dll</c>'s resources (<c>Terminal.Gui.Resources.config.json</c>) -- Lowest Precidence.
  319. /// </summary>
  320. DefaultOnly,
  321. /// <summary>
  322. /// This constant is a combination of all locations
  323. /// </summary>
  324. All = -1
  325. }
  326. /// <summary>
  327. /// Gets and sets the locations where <see cref="ConfigurationManager"/> will look for config files.
  328. /// The value is <see cref="ConfigLocations.All"/>.
  329. /// </summary>
  330. public static ConfigLocations Locations { get; set; } = ConfigLocations.All;
  331. /// <summary>
  332. /// Loads all settings found in the various configuration storage locations to
  333. /// the <see cref="ConfigurationManager"/>. Optionally,
  334. /// resets all settings attributed with <see cref="SerializableConfigurationProperty"/> to the defaults.
  335. /// </summary>
  336. /// <remarks>
  337. /// Use <see cref="Apply"/> to cause the loaded settings to be applied to the running application.
  338. /// </remarks>
  339. /// <param name="reset">If <see langword="true"/> the state of <see cref="ConfigurationManager"/> will
  340. /// be reset to the defaults.</param>
  341. public static void Load (bool reset = false)
  342. {
  343. Debug.WriteLine ($"ConfigurationManager.Load()");
  344. if (reset) Reset ();
  345. // LibraryResources is always loaded by Reset
  346. if (Locations == ConfigLocations.All) {
  347. var embeddedStylesResourceName = Assembly.GetEntryAssembly ()?
  348. .GetManifestResourceNames ().FirstOrDefault (x => x.EndsWith (_configFilename));
  349. if (string.IsNullOrEmpty (embeddedStylesResourceName)) {
  350. embeddedStylesResourceName = _configFilename;
  351. }
  352. Settings = Settings?
  353. // Global current directory
  354. .Update ($"./.tui/{_configFilename}")?
  355. // Global home directory
  356. .Update ($"~/.tui/{_configFilename}")?
  357. // App resources
  358. .UpdateFromResource (Assembly.GetEntryAssembly ()!, embeddedStylesResourceName!)?
  359. // App current directory
  360. .Update ($"./.tui/{AppName}.{_configFilename}")?
  361. // App home directory
  362. .Update ($"~/.tui/{AppName}.{_configFilename}");
  363. }
  364. }
  365. /// <summary>
  366. /// Returns an empty Json document with just the $schema tag.
  367. /// </summary>
  368. /// <returns></returns>
  369. public static string GetEmptyJson ()
  370. {
  371. var emptyScope = new SettingsScope ();
  372. emptyScope.Clear ();
  373. return JsonSerializer.Serialize<SettingsScope> (emptyScope, _serializerOptions);
  374. }
  375. /// <summary>
  376. /// System.Text.Json does not support copying a deserialized object to an existing instance.
  377. /// To work around this, we implement a 'deep, memberwise copy' method.
  378. /// </summary>
  379. /// <remarks>
  380. /// TOOD: When System.Text.Json implements `PopulateObject` revisit
  381. /// https://github.com/dotnet/corefx/issues/37627
  382. /// </remarks>
  383. /// <param name="source"></param>
  384. /// <param name="destination"></param>
  385. /// <returns><paramref name="destination"/> updated from <paramref name="source"/></returns>
  386. internal static object? DeepMemberwiseCopy (object? source, object? destination)
  387. {
  388. if (destination == null) {
  389. throw new ArgumentNullException (nameof (destination));
  390. }
  391. if (source == null) {
  392. return null!;
  393. }
  394. if (source.GetType () == typeof (SettingsScope)) {
  395. return ((SettingsScope)destination).Update ((SettingsScope)source);
  396. }
  397. if (source.GetType () == typeof (ThemeScope)) {
  398. return ((ThemeScope)destination).Update ((ThemeScope)source);
  399. }
  400. if (source.GetType () == typeof (AppScope)) {
  401. return ((AppScope)destination).Update ((AppScope)source);
  402. }
  403. // If value type, just use copy constructor.
  404. if (source.GetType ().IsValueType || source.GetType () == typeof (string)) {
  405. return source;
  406. }
  407. // Dictionary
  408. if (source.GetType ().IsGenericType && source.GetType ().GetGenericTypeDefinition ().IsAssignableFrom (typeof (Dictionary<,>))) {
  409. foreach (var srcKey in ((IDictionary)source).Keys) {
  410. if (((IDictionary)destination).Contains (srcKey))
  411. ((IDictionary)destination) [srcKey] = DeepMemberwiseCopy (((IDictionary)source) [srcKey], ((IDictionary)destination) [srcKey]);
  412. else {
  413. ((IDictionary)destination).Add (srcKey, ((IDictionary)source) [srcKey]);
  414. }
  415. }
  416. return destination;
  417. }
  418. // ALl other object types
  419. var sourceProps = source?.GetType ().GetProperties ().Where (x => x.CanRead).ToList ();
  420. var destProps = destination?.GetType ().GetProperties ().Where (x => x.CanWrite).ToList ()!;
  421. foreach (var (sourceProp, destProp) in
  422. from sourceProp in sourceProps
  423. where destProps.Any (x => x.Name == sourceProp.Name)
  424. let destProp = destProps.First (x => x.Name == sourceProp.Name)
  425. where destProp.CanWrite
  426. select (sourceProp, destProp)) {
  427. var sourceVal = sourceProp.GetValue (source);
  428. var destVal = destProp.GetValue (destination);
  429. if (sourceVal != null) {
  430. if (destVal != null) {
  431. // Recurse
  432. destProp.SetValue (destination, DeepMemberwiseCopy (sourceVal, destVal));
  433. } else {
  434. destProp.SetValue (destination, sourceVal);
  435. }
  436. }
  437. }
  438. return destination!;
  439. }
  440. //public class ConfiguraitonLocation
  441. //{
  442. // public string Name { get; set; } = string.Empty;
  443. // public string? Path { get; set; }
  444. // public async Task<SettingsScope> UpdateAsync (Stream stream)
  445. // {
  446. // var scope = await JsonSerializer.DeserializeAsync<SettingsScope> (stream, serializerOptions);
  447. // if (scope != null) {
  448. // ConfigurationManager.Settings?.UpdateFrom (scope);
  449. // return scope;
  450. // }
  451. // return new SettingsScope ();
  452. // }
  453. //}
  454. //public class StreamConfiguration {
  455. // private bool _reset;
  456. // public StreamConfiguration (bool reset)
  457. // {
  458. // _reset = reset;
  459. // }
  460. // public StreamConfiguration UpdateAppResources ()
  461. // {
  462. // if (Locations.HasFlag (ConfigLocations.AppResources)) LoadAppResources ();
  463. // return this;
  464. // }
  465. // public StreamConfiguration UpdateAppDirectory ()
  466. // {
  467. // if (Locations.HasFlag (ConfigLocations.AppDirectory)) LoadAppDirectory ();
  468. // return this;
  469. // }
  470. // // Additional update methods for each location here
  471. // private void LoadAppResources ()
  472. // {
  473. // // Load AppResources logic here
  474. // }
  475. // private void LoadAppDirectory ()
  476. // {
  477. // // Load AppDirectory logic here
  478. // }
  479. //}
  480. }