ThemeManager.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. #nullable disable
  2. #nullable enable
  3. using System.Collections.Concurrent;
  4. using System.Collections.Immutable;
  5. using System.Diagnostics.CodeAnalysis;
  6. using System.Text.Json.Serialization;
  7. namespace Terminal.Gui.Configuration;
  8. /// <summary>Manages Themes.</summary>
  9. /// <remarks>
  10. /// <para>A Theme is a collection of settings that are named. The default theme is named "Default".</para>
  11. /// <para>The <see cref="Theme"/> property is used to determine the currently active theme.</para>
  12. /// <para>The <see cref="Themes"/> property is a dictionary of themes.</para>
  13. /// </remarks>
  14. public static class ThemeManager
  15. {
  16. /// <summary>
  17. /// Convenience method to get the current theme. The current theme is the item in the <see cref="Themes"/> dictionary,
  18. /// with the key of <see cref="Theme"/>.
  19. /// </summary>
  20. /// <returns></returns>
  21. public static ThemeScope GetCurrentTheme () { return Themes! [Theme]; }
  22. /// <summary>
  23. /// INTERNAL: Getter for <see cref="Themes"/>.
  24. /// Convenience method to get the themes dictionary. The themes dictionary is a dictionary of <see cref="ThemeScope"/>
  25. /// objects, with the key being the name of the theme.
  26. /// </summary>
  27. /// <returns></returns>
  28. /// <exception cref="InvalidOperationException"></exception>
  29. private static ConcurrentDictionary<string, ThemeScope> GetThemes ()
  30. {
  31. if (!ConfigurationManager.IsInitialized ())
  32. {
  33. // We're being called from the module initializer.
  34. // We need to provide a dictionary of themes containing the hard-coded theme.
  35. return GetHardCodedThemes ()!;
  36. }
  37. if (ConfigurationManager.Settings is null)
  38. {
  39. throw new InvalidOperationException ("Settings is null.");
  40. }
  41. if (ConfigurationManager.Settings.TryGetValue ("Themes", out ConfigProperty? themes))
  42. {
  43. if (themes.HasValue)
  44. {
  45. return (themes.PropertyValue as ConcurrentDictionary<string, ThemeScope>)!;
  46. }
  47. return GetHardCodedThemes ()!;
  48. }
  49. throw new InvalidOperationException ("Settings has no Themes property.");
  50. }
  51. /// <summary>
  52. /// INTERNAL: Convenience method to get a list of theme names.
  53. /// </summary>
  54. /// <returns></returns>
  55. /// <exception cref="InvalidOperationException"></exception>
  56. public static ImmutableList<string> GetThemeNames ()
  57. {
  58. if (!ConfigurationManager.IsInitialized ())
  59. {
  60. // We're being called from the module initializer.
  61. // We need to provide a dictionary of themes containing the hard-coded theme.
  62. return GetHardCodedThemes ()!.Keys.ToImmutableList ();
  63. }
  64. if (ConfigurationManager.Settings is null)
  65. {
  66. throw new InvalidOperationException ("Settings is null.");
  67. }
  68. if (!ConfigurationManager.Settings.TryGetValue ("Themes", out ConfigProperty? themes))
  69. {
  70. throw new InvalidOperationException ("Settings has no Themes property.");
  71. }
  72. ConcurrentDictionary<string, ThemeScope>? returnConcurrentDictionary;
  73. if (themes.HasValue)
  74. {
  75. returnConcurrentDictionary = themes.PropertyValue as ConcurrentDictionary<string, ThemeScope>;
  76. }
  77. else
  78. {
  79. returnConcurrentDictionary = GetHardCodedThemes ();
  80. }
  81. return returnConcurrentDictionary!.Keys
  82. .OrderBy (key => key == DEFAULT_THEME_NAME ? string.Empty : key) // Ensure DEFAULT_THEME_NAME is first
  83. .ToImmutableList ();
  84. }
  85. /// <summary>
  86. /// Convenience method to get the current theme name. The current theme name is the value of <see cref="Theme"/>.
  87. /// </summary>
  88. /// <returns></returns>
  89. public static string GetCurrentThemeName () { return Theme!; }
  90. // TODO: Add a lock around Theme and Themes
  91. // TODO: For now, this test can't run in parallel with other tests that access Theme or Themes.
  92. // TODO: ThemeScopeList_WithThemes_ClonesSuccessfully
  93. /// <summary>
  94. /// Gets the Themes dictionary. <see cref="GetThemes"/> is preferred.
  95. /// The backing store is <c><see cref="ConfigurationManager.Settings"/> ["Themes"]</c>.
  96. /// However, if <see cref="ConfigurationManager.IsInitialized"/> is <c>false</c>, this property will return the
  97. /// hard-coded themes.
  98. /// </summary>
  99. /// <exception cref="InvalidOperationException"></exception>
  100. [JsonConverter (typeof (ConcurrentDictionaryJsonConverter<ThemeScope>))]
  101. [ConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true)]
  102. public static ConcurrentDictionary<string, ThemeScope>? Themes
  103. {
  104. // Note: This property getter must be public; DeepClone depends on it.
  105. get => GetThemes ();
  106. internal set => SetThemes (value);
  107. }
  108. /// <summary>
  109. /// INTERNAL: Setter for <see cref="Themes"/>.
  110. /// </summary>
  111. /// <param name="dictionary"></param>
  112. /// <exception cref="InvalidOperationException"></exception>
  113. private static void SetThemes (ConcurrentDictionary<string, ThemeScope>? dictionary)
  114. {
  115. if (dictionary is { } && !dictionary.ContainsKey (DEFAULT_THEME_NAME))
  116. {
  117. throw new InvalidOperationException ($"Themes must include an item named {DEFAULT_THEME_NAME}");
  118. }
  119. if (ConfigurationManager.Settings is { } && ConfigurationManager.Settings.TryGetValue ("Themes", out ConfigProperty? themes))
  120. {
  121. ConfigurationManager.Settings ["Themes"].PropertyValue = dictionary;
  122. return;
  123. }
  124. throw new InvalidOperationException ("Settings is null.");
  125. }
  126. /// <summary>
  127. /// INTERNAL: Returns the hard-coded Themes dictionary.
  128. /// </summary>
  129. /// <returns></returns>
  130. /// <exception cref="InvalidOperationException"></exception>
  131. private static ConcurrentDictionary<string, ThemeScope>? GetHardCodedThemes ()
  132. {
  133. ThemeScope? hardCodedThemeScope = GetHardCodedThemeScope ();
  134. if (hardCodedThemeScope is null)
  135. {
  136. throw new InvalidOperationException ("Hard coded theme scope is null.");
  137. }
  138. return new (new Dictionary<string, ThemeScope> { { DEFAULT_THEME_NAME, hardCodedThemeScope } }, StringComparer.InvariantCultureIgnoreCase);
  139. }
  140. /// <summary>
  141. /// INTERNAL: Returns the ThemeScope containing the hard-coded Themes.
  142. /// </summary>
  143. /// <returns></returns>
  144. private static ThemeScope GetHardCodedThemeScope ()
  145. {
  146. IEnumerable<KeyValuePair<string, ConfigProperty>>? hardCodedThemeProperties = ConfigurationManager.GetHardCodedConfigPropertiesByScope ("ThemeScope");
  147. if (hardCodedThemeProperties is null)
  148. {
  149. throw new InvalidOperationException ("Hard coded theme properties are null.");
  150. }
  151. var hardCodedThemeScope = new ThemeScope ();
  152. foreach (KeyValuePair<string, ConfigProperty> p in hardCodedThemeProperties)
  153. {
  154. hardCodedThemeScope.AddValue (p.Key, p.Value.PropertyValue);
  155. }
  156. return hardCodedThemeScope;
  157. }
  158. /// <summary>
  159. /// The name of the default theme ("Default").
  160. /// </summary>
  161. public const string DEFAULT_THEME_NAME = "Default";
  162. /// <summary>
  163. /// The currently selected theme. The backing store is <c><see cref="ConfigurationManager.Settings"/> ["Theme"]</c>.
  164. /// </summary>
  165. [JsonInclude]
  166. [ConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true)]
  167. [JsonPropertyName ("Theme")]
  168. public static string Theme
  169. {
  170. get
  171. {
  172. if (!ConfigurationManager.IsInitialized ())
  173. {
  174. // We're being called from the module initializer.
  175. // Hard coded default value
  176. return DEFAULT_THEME_NAME;
  177. }
  178. if (ConfigurationManager.Settings is { } && ConfigurationManager.Settings.TryGetValue ("Theme", out ConfigProperty? themeCp))
  179. {
  180. if (themeCp.HasValue)
  181. {
  182. return (themeCp.PropertyValue as string)!;
  183. }
  184. return DEFAULT_THEME_NAME;
  185. }
  186. throw new InvalidOperationException ("Settings is null.");
  187. }
  188. [RequiresUnreferencedCode ("Calls Terminal.Gui.ConfigurationManager.Settings")]
  189. [RequiresDynamicCode ("Calls Terminal.Gui.ConfigurationManager.Settings")]
  190. set
  191. {
  192. if (!ConfigurationManager.IsInitialized ())
  193. {
  194. throw new InvalidOperationException ("Theme cannot be set before ConfigurationManager is initialized.");
  195. }
  196. if (ConfigurationManager.Settings is null || !ConfigurationManager.Settings.TryGetValue ("Theme", out ConfigProperty? themeCp))
  197. {
  198. throw new InvalidOperationException ("Settings is null.");
  199. }
  200. if (themeCp is null || !themeCp.HasValue)
  201. {
  202. throw new InvalidOperationException ("Theme has no value.");
  203. }
  204. if (!ConfigurationManager.Settings.TryGetValue ("Themes", out ConfigProperty? themesCp))
  205. {
  206. throw new InvalidOperationException ("Settings has no Themes property.");
  207. }
  208. string previousThemeValue = GetCurrentThemeName ();
  209. if (value == previousThemeValue)
  210. {
  211. return;
  212. }
  213. if (!Themes!.ContainsKey (value))
  214. {
  215. Logging.Warning ($"{value} is not a valid theme name.");
  216. }
  217. // Update the backing store
  218. ConfigurationManager.Settings! ["Theme"].PropertyValue = value;
  219. OnThemeChanged (previousThemeValue, value);
  220. }
  221. }
  222. /// <summary>
  223. /// INTERNAL: Updates <see cref="Themes"/> to the current values of the static
  224. /// <see cref="ConfigurationPropertyAttribute"/> properties.
  225. /// </summary>
  226. [RequiresUnreferencedCode ("Calls Terminal.Gui.ThemeManager.Themes")]
  227. [RequiresDynamicCode ("Calls Terminal.Gui.ThemeManager.Themes")]
  228. internal static void UpdateToCurrentValues ()
  229. {
  230. // BUGBUG: This corrupts _hardCodedDefaults. See #4288
  231. Themes! [Theme].UpdateToCurrentValues ();
  232. }
  233. /// <summary>
  234. /// INTERNAL: Loads all Themes to their hard-coded default values.
  235. /// </summary>
  236. [RequiresUnreferencedCode ("Calls SchemeManager.LoadToHardCodedDefaults")]
  237. [RequiresDynamicCode ("Calls SchemeManager.LoadToHardCodedDefaults")]
  238. internal static void LoadHardCodedDefaults ()
  239. {
  240. if (!ConfigurationManager.IsInitialized ())
  241. {
  242. throw new InvalidOperationException ("ThemeManager is not initialized.");
  243. }
  244. if (ConfigurationManager.Settings is null)
  245. {
  246. return;
  247. }
  248. ThemeScope? hardCodedThemeScope = GetHardCodedThemeScope ();
  249. if (hardCodedThemeScope is null)
  250. {
  251. throw new InvalidOperationException ("Hard coded theme scope is null.");
  252. }
  253. ConcurrentDictionary<string, ThemeScope> hardCodedThemes = new (
  254. new Dictionary<string, ThemeScope>
  255. {
  256. { Theme, hardCodedThemeScope }
  257. },
  258. StringComparer.InvariantCultureIgnoreCase);
  259. // BUGBUG: SchemeManager is broken and needs to be fixed to not have the hard coded schemes get overwritten.
  260. // BUGBUG: This is a partial workaround
  261. // BUGBUG: See https://github.com/gui-cs/Terminal.Gui/issues/4288
  262. SchemeManager.LoadToHardCodedDefaults ();
  263. ConfigurationManager.Settings ["Themes"].PropertyValue = hardCodedThemes;
  264. ConfigurationManager.Settings ["Theme"].PropertyValue = DEFAULT_THEME_NAME;
  265. }
  266. /// <summary>Called when the selected theme has changed. Fires the <see cref="ThemeChanged"/> event.</summary>
  267. internal static void OnThemeChanged (string previousThemeName, string newThemeName)
  268. {
  269. Logging.Debug ($"Themes.OnThemeChanged({previousThemeName}) -> {Theme}");
  270. EventArgs<string> args = new (newThemeName);
  271. ThemeChanged?.Invoke (null, args);
  272. }
  273. /// <summary>Raised when the selected theme has changed.</summary>
  274. public static event EventHandler<EventArgs<string>>? ThemeChanged;
  275. }