From 4bde80cfb7a7d692391fb1fe7b372bc0b37c2e45 Mon Sep 17 00:00:00 2001 From: BDisp Date: Fri, 12 May 2023 17:20:09 +0100 Subject: [PATCH 1/2] Fixes #2626. ScrollView contentBottomRightCorner isn't set to false if not needed. (#2627) * Fixes #2626. ScrollView contentBottomRightCorner isn't set to false if not needed. * Fix unit test errors. * Prefix private members with underscore. --- Terminal.Gui/Views/ScrollView.cs | 309 ++++++++++++++++--------------- 1 file changed, 163 insertions(+), 146 deletions(-) diff --git a/Terminal.Gui/Views/ScrollView.cs b/Terminal.Gui/Views/ScrollView.cs index a1dcf9fa8..59eba276f 100644 --- a/Terminal.Gui/Views/ScrollView.cs +++ b/Terminal.Gui/Views/ScrollView.cs @@ -40,8 +40,8 @@ namespace Terminal.Gui { } } - ContentView contentView; - ScrollBarView vertical, horizontal; + ContentView _contentView; + ScrollBarView _vertical, _horizontal; /// /// Initializes a new instance of the class using positioning. @@ -62,32 +62,32 @@ namespace Terminal.Gui { void SetInitialProperties (Rect frame) { - contentView = new ContentView (frame); - vertical = new ScrollBarView (1, 0, isVertical: true) { + _contentView = new ContentView (frame); + _vertical = new ScrollBarView (1, 0, isVertical: true) { X = Pos.AnchorEnd (1), Y = 0, Width = 1, - Height = Dim.Fill (showHorizontalScrollIndicator ? 1 : 0) + Height = Dim.Fill (_showHorizontalScrollIndicator ? 1 : 0), + Host = this }; - vertical.Host = this; - horizontal = new ScrollBarView (1, 0, isVertical: false) { + _horizontal = new ScrollBarView (1, 0, isVertical: false) { X = 0, Y = Pos.AnchorEnd (1), - Width = Dim.Fill (showVerticalScrollIndicator ? 1 : 0), - Height = 1 + Width = Dim.Fill (_showVerticalScrollIndicator ? 1 : 0), + Height = 1, + Host = this }; - horizontal.Host = this; - vertical.OtherScrollBarView = horizontal; - horizontal.OtherScrollBarView = vertical; - base.Add (contentView); + _vertical.OtherScrollBarView = _horizontal; + _horizontal.OtherScrollBarView = _vertical; + base.Add (_contentView); CanFocus = true; MouseEnter += View_MouseEnter; MouseLeave += View_MouseLeave; - contentView.MouseEnter += View_MouseEnter; - contentView.MouseLeave += View_MouseLeave; + _contentView.MouseEnter += View_MouseEnter; + _contentView.MouseLeave += View_MouseLeave; // Things this view knows how to do AddCommand (Command.ScrollUp, () => ScrollUp (1)); @@ -98,10 +98,10 @@ namespace Terminal.Gui { AddCommand (Command.PageDown, () => ScrollDown (Bounds.Height)); AddCommand (Command.PageLeft, () => ScrollLeft (Bounds.Width)); AddCommand (Command.PageRight, () => ScrollRight (Bounds.Width)); - AddCommand (Command.TopHome, () => ScrollUp (contentSize.Height)); - AddCommand (Command.BottomEnd, () => ScrollDown (contentSize.Height)); - AddCommand (Command.LeftHome, () => ScrollLeft (contentSize.Width)); - AddCommand (Command.RightEnd, () => ScrollRight (contentSize.Width)); + AddCommand (Command.TopHome, () => ScrollUp (_contentSize.Height)); + AddCommand (Command.BottomEnd, () => ScrollDown (_contentSize.Height)); + AddCommand (Command.LeftHome, () => ScrollLeft (_contentSize.Width)); + AddCommand (Command.RightEnd, () => ScrollRight (_contentSize.Width)); // Default keybindings for this view AddKeyBinding (Key.CursorUp, Command.ScrollUp); @@ -123,21 +123,21 @@ namespace Terminal.Gui { AddKeyBinding (Key.End | Key.CtrlMask, Command.RightEnd); Initialized += (s, e) => { - if (!vertical.IsInitialized) { - vertical.BeginInit (); - vertical.EndInit (); + if (!_vertical.IsInitialized) { + _vertical.BeginInit (); + _vertical.EndInit (); } - if (!horizontal.IsInitialized) { - horizontal.BeginInit (); - horizontal.EndInit (); + if (!_horizontal.IsInitialized) { + _horizontal.BeginInit (); + _horizontal.EndInit (); } - SetContentOffset (contentOffset); - contentView.Frame = new Rect (ContentOffset, ContentSize); - vertical.ChangedPosition += delegate { - ContentOffset = new Point (ContentOffset.X, vertical.Position); + SetContentOffset (_contentOffset); + _contentView.Frame = new Rect (ContentOffset, ContentSize); + _vertical.ChangedPosition += delegate { + ContentOffset = new Point (ContentOffset.X, _vertical.Position); }; - horizontal.ChangedPosition += delegate { - ContentOffset = new Point (horizontal.Position, ContentOffset.Y); + _horizontal.ChangedPosition += delegate { + ContentOffset = new Point (_horizontal.Position, ContentOffset.Y); }; }; } @@ -148,12 +148,12 @@ namespace Terminal.Gui { // base.BeginInit (); //} - Size contentSize; - Point contentOffset; - bool showHorizontalScrollIndicator; - bool showVerticalScrollIndicator; - bool keepContentAlwaysInViewport = true; - bool autoHideScrollBars = true; + Size _contentSize; + Point _contentOffset; + bool _showHorizontalScrollIndicator; + bool _showVerticalScrollIndicator; + bool _keepContentAlwaysInViewport = true; + bool _autoHideScrollBars = true; /// /// Represents the contents of the data shown inside the scrollview @@ -161,14 +161,14 @@ namespace Terminal.Gui { /// The size of the content. public Size ContentSize { get { - return contentSize; + return _contentSize; } set { - if (contentSize != value) { - contentSize = value; - contentView.Frame = new Rect (contentOffset, value); - vertical.Size = contentSize.Height; - horizontal.Size = contentSize.Width; + if (_contentSize != value) { + _contentSize = value; + _contentView.Frame = new Rect (_contentOffset, value); + _vertical.Size = _contentSize.Height; + _horizontal.Size = _contentSize.Width; SetNeedsDisplay (); } } @@ -180,12 +180,12 @@ namespace Terminal.Gui { /// The content offset. public Point ContentOffset { get { - return contentOffset; + return _contentOffset; } set { if (!IsInitialized) { // We're not initialized so we can't do anything fancy. Just cache value. - contentOffset = new Point (-Math.Abs (value.X), -Math.Abs (value.Y)); ; + _contentOffset = new Point (-Math.Abs (value.X), -Math.Abs (value.Y)); ; return; } @@ -196,15 +196,15 @@ namespace Terminal.Gui { private void SetContentOffset (Point offset) { var co = new Point (-Math.Abs (offset.X), -Math.Abs (offset.Y)); - contentOffset = co; - contentView.Frame = new Rect (contentOffset, contentSize); - var p = Math.Max (0, -contentOffset.Y); - if (vertical.Position != p) { - vertical.Position = Math.Max (0, -contentOffset.Y); + _contentOffset = co; + _contentView.Frame = new Rect (_contentOffset, _contentSize); + var p = Math.Max (0, -_contentOffset.Y); + if (_vertical.Position != p) { + _vertical.Position = Math.Max (0, -_contentOffset.Y); } - p = Math.Max (0, -contentOffset.X); - if (horizontal.Position != p) { - horizontal.Position = Math.Max (0, -contentOffset.X); + p = Math.Max (0, -_contentOffset.X); + if (_horizontal.Position != p) { + _horizontal.Position = Math.Max (0, -_contentOffset.X); } SetNeedsDisplay (); } @@ -213,15 +213,15 @@ namespace Terminal.Gui { /// If true the vertical/horizontal scroll bars won't be showed if it's not needed. /// public bool AutoHideScrollBars { - get => autoHideScrollBars; + get => _autoHideScrollBars; set { - if (autoHideScrollBars != value) { - autoHideScrollBars = value; - if (Subviews.Contains (vertical)) { - vertical.AutoHideScrollBars = value; + if (_autoHideScrollBars != value) { + _autoHideScrollBars = value; + if (Subviews.Contains (_vertical)) { + _vertical.AutoHideScrollBars = value; } - if (Subviews.Contains (horizontal)) { - horizontal.AutoHideScrollBars = value; + if (Subviews.Contains (_horizontal)) { + _horizontal.AutoHideScrollBars = value; } SetNeedsDisplay (); } @@ -232,21 +232,21 @@ namespace Terminal.Gui { /// Get or sets if the view-port is kept always visible in the area of this /// public bool KeepContentAlwaysInViewport { - get { return keepContentAlwaysInViewport; } + get { return _keepContentAlwaysInViewport; } set { - if (keepContentAlwaysInViewport != value) { - keepContentAlwaysInViewport = value; - vertical.OtherScrollBarView.KeepContentAlwaysInViewport = value; - horizontal.OtherScrollBarView.KeepContentAlwaysInViewport = value; + if (_keepContentAlwaysInViewport != value) { + _keepContentAlwaysInViewport = value; + _vertical.OtherScrollBarView.KeepContentAlwaysInViewport = value; + _horizontal.OtherScrollBarView.KeepContentAlwaysInViewport = value; Point p = default; - if (value && -contentOffset.X + Bounds.Width > contentSize.Width) { - p = new Point (contentSize.Width - Bounds.Width + (showVerticalScrollIndicator ? 1 : 0), -contentOffset.Y); + if (value && -_contentOffset.X + Bounds.Width > _contentSize.Width) { + p = new Point (_contentSize.Width - Bounds.Width + (_showVerticalScrollIndicator ? 1 : 0), -_contentOffset.Y); } - if (value && -contentOffset.Y + Bounds.Height > contentSize.Height) { + if (value && -_contentOffset.Y + Bounds.Height > _contentSize.Height) { if (p == default) { - p = new Point (-contentOffset.X, contentSize.Height - Bounds.Height + (showHorizontalScrollIndicator ? 1 : 0)); + p = new Point (-_contentOffset.X, _contentSize.Height - Bounds.Height + (_showHorizontalScrollIndicator ? 1 : 0)); } else { - p.Y = contentSize.Height - Bounds.Height + (showHorizontalScrollIndicator ? 1 : 0); + p.Y = _contentSize.Height - Bounds.Height + (_showHorizontalScrollIndicator ? 1 : 0); } } if (p != default) { @@ -256,6 +256,8 @@ namespace Terminal.Gui { } } + View _contentBottomRightCorner; + /// /// Adds the view to the scrollview. /// @@ -263,20 +265,21 @@ namespace Terminal.Gui { public override void Add (View view) { if (view.Id == "contentBottomRightCorner") { + _contentBottomRightCorner = view; base.Add (view); } else { if (!IsOverridden (view, "MouseEvent")) { view.MouseEnter += View_MouseEnter; view.MouseLeave += View_MouseLeave; } - contentView.Add (view); + _contentView.Add (view); } SetNeedsLayout (); } void View_MouseLeave (object sender, MouseEventEventArgs e) { - if (Application.MouseGrabView != null && Application.MouseGrabView != vertical && Application.MouseGrabView != horizontal) { + if (Application.MouseGrabView != null && Application.MouseGrabView != _vertical && Application.MouseGrabView != _horizontal) { Application.UngrabMouse (); } } @@ -291,27 +294,27 @@ namespace Terminal.Gui { /// /// true if show horizontal scroll indicator; otherwise, false. public bool ShowHorizontalScrollIndicator { - get => showHorizontalScrollIndicator; + get => _showHorizontalScrollIndicator; set { - if (value != showHorizontalScrollIndicator) { - showHorizontalScrollIndicator = value; + if (value != _showHorizontalScrollIndicator) { + _showHorizontalScrollIndicator = value; SetNeedsLayout (); if (value) { - horizontal.OtherScrollBarView = vertical; - base.Add (horizontal); - horizontal.ShowScrollIndicator = value; - horizontal.AutoHideScrollBars = autoHideScrollBars; - horizontal.OtherScrollBarView.ShowScrollIndicator = value; - horizontal.MouseEnter += View_MouseEnter; - horizontal.MouseLeave += View_MouseLeave; + _horizontal.OtherScrollBarView = _vertical; + base.Add (_horizontal); + _horizontal.ShowScrollIndicator = value; + _horizontal.AutoHideScrollBars = _autoHideScrollBars; + _horizontal.OtherScrollBarView.ShowScrollIndicator = value; + _horizontal.MouseEnter += View_MouseEnter; + _horizontal.MouseLeave += View_MouseLeave; } else { - base.Remove (horizontal); - horizontal.OtherScrollBarView = null; - horizontal.MouseEnter -= View_MouseEnter; - horizontal.MouseLeave -= View_MouseLeave; + base.Remove (_horizontal); + _horizontal.OtherScrollBarView = null; + _horizontal.MouseEnter -= View_MouseEnter; + _horizontal.MouseLeave -= View_MouseLeave; } } - vertical.Height = Dim.Fill (showHorizontalScrollIndicator ? 1 : 0); + _vertical.Height = Dim.Fill (_showHorizontalScrollIndicator ? 1 : 0); } } @@ -322,7 +325,7 @@ namespace Terminal.Gui { /// public override void RemoveAll () { - contentView.RemoveAll (); + _contentView.RemoveAll (); } /// @@ -330,27 +333,27 @@ namespace Terminal.Gui { /// /// true if show vertical scroll indicator; otherwise, false. public bool ShowVerticalScrollIndicator { - get => showVerticalScrollIndicator; + get => _showVerticalScrollIndicator; set { - if (value != showVerticalScrollIndicator) { - showVerticalScrollIndicator = value; + if (value != _showVerticalScrollIndicator) { + _showVerticalScrollIndicator = value; SetNeedsLayout (); if (value) { - vertical.OtherScrollBarView = horizontal; - base.Add (vertical); - vertical.ShowScrollIndicator = value; - vertical.AutoHideScrollBars = autoHideScrollBars; - vertical.OtherScrollBarView.ShowScrollIndicator = value; - vertical.MouseEnter += View_MouseEnter; - vertical.MouseLeave += View_MouseLeave; + _vertical.OtherScrollBarView = _horizontal; + base.Add (_vertical); + _vertical.ShowScrollIndicator = value; + _vertical.AutoHideScrollBars = _autoHideScrollBars; + _vertical.OtherScrollBarView.ShowScrollIndicator = value; + _vertical.MouseEnter += View_MouseEnter; + _vertical.MouseLeave += View_MouseLeave; } else { - Remove (vertical); - vertical.OtherScrollBarView = null; - vertical.MouseEnter -= View_MouseEnter; - vertical.MouseLeave -= View_MouseLeave; + Remove (_vertical); + _vertical.OtherScrollBarView = null; + _vertical.MouseEnter -= View_MouseEnter; + _vertical.MouseLeave -= View_MouseLeave; } } - horizontal.Width = Dim.Fill (showVerticalScrollIndicator ? 1 : 0); + _horizontal.Width = Dim.Fill (_showVerticalScrollIndicator ? 1 : 0); } } @@ -363,41 +366,50 @@ namespace Terminal.Gui { Driver.SetAttribute (GetNormalColor ()); Clear (); - contentView.Draw (); + _contentView.Draw (); - if (autoHideScrollBars) { + DrawScrollBars (); + + Driver.Clip = savedClip; + } + + private void DrawScrollBars () + { + if (_autoHideScrollBars) { ShowHideScrollBars (); } else { if (ShowVerticalScrollIndicator) { - //vertical.SetRelativeLayout (Bounds); - vertical.Draw (); + _vertical.Draw (); } - if (ShowHorizontalScrollIndicator) { - //horizontal.SetRelativeLayout (Bounds); - horizontal.Draw (); + _horizontal.Draw (); + } + if (ShowVerticalScrollIndicator && ShowHorizontalScrollIndicator) { + SetContentBottomRightCornerVisibility (); + _contentBottomRightCorner.Draw (); } } + } - // Fill in the bottom left corner. Note we don't rely on ScrollBarView.contentBottomRightCorner here - // because that only applies when ScrollBarView is hosted. - if (ShowVerticalScrollIndicator && ShowHorizontalScrollIndicator) { - AddRune (Bounds.Width - 1, Bounds.Height - 1, ' '); + private void SetContentBottomRightCornerVisibility () + { + if (_showHorizontalScrollIndicator && _showVerticalScrollIndicator) { + _contentBottomRightCorner.Visible = true; + } else if (_horizontal.IsAdded || _vertical.IsAdded) { + _contentBottomRightCorner.Visible = false; } - Driver.SetAttribute (GetNormalColor ()); - Driver.Clip = savedClip; } void ShowHideScrollBars () { bool v = false, h = false; bool p = false; - if (Bounds.Height == 0 || Bounds.Height > contentSize.Height) { + if (Bounds.Height == 0 || Bounds.Height > _contentSize.Height) { if (ShowVerticalScrollIndicator) { ShowVerticalScrollIndicator = false; } v = false; - } else if (Bounds.Height > 0 && Bounds.Height == contentSize.Height) { + } else if (Bounds.Height > 0 && Bounds.Height == _contentSize.Height) { p = true; } else { if (!ShowVerticalScrollIndicator) { @@ -405,12 +417,12 @@ namespace Terminal.Gui { } v = true; } - if (Bounds.Width == 0 || Bounds.Width > contentSize.Width) { + if (Bounds.Width == 0 || Bounds.Width > _contentSize.Width) { if (ShowHorizontalScrollIndicator) { ShowHorizontalScrollIndicator = false; } h = false; - } else if (Bounds.Width > 0 && Bounds.Width == contentSize.Width && p) { + } else if (Bounds.Width > 0 && Bounds.Width == _contentSize.Width && p) { if (ShowHorizontalScrollIndicator) { ShowHorizontalScrollIndicator = false; } @@ -432,27 +444,32 @@ namespace Terminal.Gui { h = true; } var dim = Dim.Fill (h ? 1 : 0); - if (!vertical.Height.Equals (dim)) { - vertical.Height = dim; + if (!_vertical.Height.Equals (dim)) { + _vertical.Height = dim; } dim = Dim.Fill (v ? 1 : 0); - if (!horizontal.Width.Equals (dim)) { - horizontal.Width = dim; + if (!_horizontal.Width.Equals (dim)) { + _horizontal.Width = dim; } if (v) { - vertical.SetRelativeLayout (Bounds); - vertical.Draw (); + _vertical.SetRelativeLayout (Bounds); + _vertical.Draw (); } if (h) { - horizontal.SetRelativeLayout (Bounds); - horizontal.Draw (); + _horizontal.SetRelativeLayout (Bounds); + _horizontal.Draw (); + } + SetContentBottomRightCornerVisibility (); + if (v && h) { + _contentBottomRightCorner.SetRelativeLayout (Bounds); + _contentBottomRightCorner.Draw (); } } void SetViewsNeedsDisplay () { - foreach (View view in contentView.Subviews) { + foreach (View view in _contentView.Subviews) { view.SetNeedsDisplay (); } } @@ -473,8 +490,8 @@ namespace Terminal.Gui { /// Number of lines to scroll. public bool ScrollUp (int lines) { - if (contentOffset.Y < 0) { - ContentOffset = new Point (contentOffset.X, Math.Min (contentOffset.Y + lines, 0)); + if (_contentOffset.Y < 0) { + ContentOffset = new Point (_contentOffset.X, Math.Min (_contentOffset.Y + lines, 0)); return true; } return false; @@ -487,8 +504,8 @@ namespace Terminal.Gui { /// Number of columns to scroll by. public bool ScrollLeft (int cols) { - if (contentOffset.X < 0) { - ContentOffset = new Point (Math.Min (contentOffset.X + cols, 0), contentOffset.Y); + if (_contentOffset.X < 0) { + ContentOffset = new Point (Math.Min (_contentOffset.X + cols, 0), _contentOffset.Y); return true; } return false; @@ -501,8 +518,8 @@ namespace Terminal.Gui { /// Number of lines to scroll. public bool ScrollDown (int lines) { - if (vertical.CanScroll (lines, out _, true)) { - ContentOffset = new Point (contentOffset.X, contentOffset.Y - lines); + if (_vertical.CanScroll (lines, out _, true)) { + ContentOffset = new Point (_contentOffset.X, _contentOffset.Y - lines); return true; } return false; @@ -515,8 +532,8 @@ namespace Terminal.Gui { /// Number of columns to scroll by. public bool ScrollRight (int cols) { - if (horizontal.CanScroll (cols, out _)) { - ContentOffset = new Point (contentOffset.X - cols, contentOffset.Y); + if (_horizontal.CanScroll (cols, out _)) { + ContentOffset = new Point (_contentOffset.X - cols, _contentOffset.Y); return true; } return false; @@ -550,14 +567,14 @@ namespace Terminal.Gui { ScrollDown (1); } else if (me.Flags == MouseFlags.WheeledUp && ShowVerticalScrollIndicator) { ScrollUp (1); - } else if (me.Flags == MouseFlags.WheeledRight && showHorizontalScrollIndicator) { + } else if (me.Flags == MouseFlags.WheeledRight && _showHorizontalScrollIndicator) { ScrollRight (1); } else if (me.Flags == MouseFlags.WheeledLeft && ShowVerticalScrollIndicator) { ScrollLeft (1); - } else if (me.X == vertical.Frame.X && ShowVerticalScrollIndicator) { - vertical.MouseEvent (me); - } else if (me.Y == horizontal.Frame.Y && ShowHorizontalScrollIndicator) { - horizontal.MouseEvent (me); + } else if (me.X == _vertical.Frame.X && ShowVerticalScrollIndicator) { + _vertical.MouseEvent (me); + } else if (me.Y == _horizontal.Frame.Y && ShowHorizontalScrollIndicator) { + _horizontal.MouseEvent (me); } else if (IsOverridden (me.View, "MouseEvent")) { Application.UngrabMouse (); } @@ -567,13 +584,13 @@ namespace Terminal.Gui { /// protected override void Dispose (bool disposing) { - if (!showVerticalScrollIndicator) { + if (!_showVerticalScrollIndicator) { // It was not added to SuperView, so it won't get disposed automatically - vertical?.Dispose (); + _vertical?.Dispose (); } - if (!showHorizontalScrollIndicator) { + if (!_showHorizontalScrollIndicator) { // It was not added to SuperView, so it won't get disposed automatically - horizontal?.Dispose (); + _horizontal?.Dispose (); } base.Dispose (disposing); } From f3ab1fb1bd2928f657c402f2541c12f7634cc0fc Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 13 May 2023 01:23:37 +0200 Subject: [PATCH 2/2] Fixes #2629 - Config Manager error logging improvements (#2630) * Fixes #2629 * Fixed broken unit tests (which were already broken but latent) * Removed test code --- .../Configuration/ConfigurationManager.cs | 1209 ++++++++--------- .../Configuration/DictionaryJsonConverter.cs | 4 + Terminal.Gui/Configuration/Scope.cs | 2 +- Terminal.Gui/Resources/config.json | 5 + .../Configuration/ConfigurationMangerTests.cs | 30 +- 5 files changed, 623 insertions(+), 627 deletions(-) diff --git a/Terminal.Gui/Configuration/ConfigurationManager.cs b/Terminal.Gui/Configuration/ConfigurationManager.cs index 455697ffc..e709bca8e 100644 --- a/Terminal.Gui/Configuration/ConfigurationManager.cs +++ b/Terminal.Gui/Configuration/ConfigurationManager.cs @@ -13,646 +13,645 @@ 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.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 { +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"; + private static readonly string _configFilename = "config.json"; - private static readonly JsonSerializerOptions _serializerOptions = new JsonSerializerOptions { - ReadCommentHandling = JsonCommentHandling.Skip, - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true, - Converters = { + private 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(), }, - }; + }; + + /// + /// An attribute that can be applied to a property to indicate that it should included in the configuration file. + /// + /// + /// [SerializableConfigurationProperty(Scope = typeof(Configuration.ThemeManager.ThemeScope)), JsonConverter (typeof (JsonStringEnumConverter))] + /// public static LineStyle DefaultBorderStyle { + /// ... + /// + [AttributeUsage (AttributeTargets.Property, AllowMultiple = false, Inherited = false)] + public class SerializableConfigurationProperty : System.Attribute { + /// + /// Specifies the scope of the property. + /// + public Type? Scope { get; set; } /// - /// An attribute that can be applied to a property to indicate that it should included in the configuration file. + /// If , the property will be serialized to the configuration file using only the property name + /// as the key. If , the property will be serialized to the configuration file using the + /// property name pre-pended with the classname (e.g. Application.UseSystemConsole). /// - /// - /// [SerializableConfigurationProperty(Scope = typeof(Configuration.ThemeManager.ThemeScope)), JsonConverter (typeof (JsonStringEnumConverter))] - /// public static LineStyle DefaultBorderStyle { - /// ... - /// - [AttributeUsage (AttributeTargets.Property, AllowMultiple = false, Inherited = false)] - public class SerializableConfigurationProperty : System.Attribute { - /// - /// Specifies the scope of the property. - /// - public Type? Scope { get; set; } + public bool OmitClassName { get; set; } + } - /// - /// If , the property will be serialized to the configuration file using only the property name - /// as the key. If , the property will be serialized to the configuration file using the - /// property name pre-pended with the classname (e.g. Application.UseSystemConsole). - /// - public bool OmitClassName { get; set; } + /// + /// Holds a property's value and the that allows + /// to get and set the property's value. + /// + /// + /// Configuration properties must be and + /// and have the + /// attribute. If the type of the property requires specialized JSON serialization, + /// a must be provided using + /// the attribute. + /// + public class ConfigProperty { + private object? propertyValue; + + /// + /// Describes the property. + /// + public PropertyInfo? PropertyInfo { get; set; } + + /// + /// Helper to get either the Json property named (specified by [JsonPropertyName(name)] + /// or the actual property name. + /// + /// + /// + public static string GetJsonPropertyName (PropertyInfo pi) + { + var jpna = pi.GetCustomAttribute (typeof (JsonPropertyNameAttribute)) as JsonPropertyNameAttribute; + return jpna?.Name ?? pi.Name; } /// - /// Holds a property's value and the that allows - /// to get and set the property's value. + /// Holds the property's value as it was either read from the class's implementation or from a config file. + /// If the property has not been set (e.g. because no configuration file specified a value), + /// this will be . /// /// - /// Configuration properties must be and - /// and have the - /// attribute. If the type of the property requires specialized JSON serialization, - /// a must be provided using - /// the attribute. + /// On , performs a sparse-copy of the new value to the existing value (only copies elements of + /// the object that are non-null). /// - public class ConfigProperty { - private object? propertyValue; - - /// - /// Describes the property. - /// - public PropertyInfo? PropertyInfo { get; set; } - - /// - /// Helper to get either the Json property named (specified by [JsonPropertyName(name)] - /// or the actual property name. - /// - /// - /// - public static string GetJsonPropertyName (PropertyInfo pi) - { - var jpna = pi.GetCustomAttribute (typeof (JsonPropertyNameAttribute)) as JsonPropertyNameAttribute; - return jpna?.Name ?? pi.Name; + public object? PropertyValue { + get => propertyValue; + set { + propertyValue = value; } + } - /// - /// Holds the property's value as it was either read from the class's implementation or from a config file. - /// If the property has not been set (e.g. because no configuration file specified a value), - /// this will be . - /// - /// - /// On , performs a sparse-copy of the new value to the existing value (only copies elements of - /// the object that are non-null). - /// - public object? PropertyValue { - get => propertyValue; - set { - propertyValue = value; - } - } - - internal object? UpdateValueFrom (object source) - { - if (source == null) { - return PropertyValue; - } - - var ut = Nullable.GetUnderlyingType (PropertyInfo!.PropertyType); - if (source.GetType () != PropertyInfo!.PropertyType && (ut != null && source.GetType () != ut)) { - throw new ArgumentException ($"The source object ({PropertyInfo!.DeclaringType}.{PropertyInfo!.Name}) is not of type {PropertyInfo!.PropertyType}."); - } - if (PropertyValue != null && source != null) { - PropertyValue = DeepMemberwiseCopy (source, PropertyValue); - } else { - PropertyValue = source; - } - + internal object? UpdateValueFrom (object source) + { + if (source == null) { return PropertyValue; } - /// - /// Retrieves (using reflection) the value of the static property described in - /// into . - /// - /// - public object? RetrieveValue () - { - return PropertyValue = PropertyInfo!.GetValue (null); + var ut = Nullable.GetUnderlyingType (PropertyInfo!.PropertyType); + if (source.GetType () != PropertyInfo!.PropertyType && (ut != null && source.GetType () != ut)) { + throw new ArgumentException ($"The source object ({PropertyInfo!.DeclaringType}.{PropertyInfo!.Name}) is not of type {PropertyInfo!.PropertyType}."); + } + if (PropertyValue != null && source != null) { + PropertyValue = DeepMemberwiseCopy (source, PropertyValue); + } else { + PropertyValue = source; } - /// - /// Applies the to the property described by . - /// - /// - public bool Apply () - { - if (PropertyValue != null) { - PropertyInfo?.SetValue (null, DeepMemberwiseCopy (PropertyValue, PropertyInfo?.GetValue (null))); - } - return PropertyValue != null; - } + return PropertyValue; } /// - /// 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. - /// - private 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. + /// Retrieves (using reflection) the value of the static property described in + /// into . /// /// - internal static string ToJson () + public object? RetrieveValue () { - 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; + return PropertyValue = PropertyInfo!.GetValue (null); } /// - /// 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 (); - - private 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 () - { - bool settings = Settings?.Apply () ?? false; - bool themes = !string.IsNullOrEmpty(ThemeManager.SelectedTheme) && (ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply () ?? false); - bool appsettings = AppSettings?.Apply () ?? false; - 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 ()); - } - - /// - /// 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. + /// Applies the to the property described by . /// /// - public static string GetEmptyJson () + public bool Apply () { - var emptyScope = new SettingsScope (); - emptyScope.Clear (); - return JsonSerializer.Serialize (emptyScope, _serializerOptions); + if (PropertyValue != null) { + PropertyInfo?.SetValue (null, DeepMemberwiseCopy (PropertyValue, PropertyInfo?.GetValue (null))); + } + return PropertyValue != null; } - - /// - /// 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 (((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 - // } - //} } + + /// + /// 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. + /// + private 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 (); + + private 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 () + { + bool settings = Settings?.Apply () ?? false; + bool themes = !string.IsNullOrEmpty (ThemeManager.SelectedTheme) && (ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply () ?? false); + bool appsettings = AppSettings?.Apply () ?? false; + 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 ()); + } + + /// + /// 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 (((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 + // } + //} } diff --git a/Terminal.Gui/Configuration/DictionaryJsonConverter.cs b/Terminal.Gui/Configuration/DictionaryJsonConverter.cs index d6187fc14..53ff7f3af 100644 --- a/Terminal.Gui/Configuration/DictionaryJsonConverter.cs +++ b/Terminal.Gui/Configuration/DictionaryJsonConverter.cs @@ -7,6 +7,10 @@ namespace Terminal.Gui { class DictionaryJsonConverter : JsonConverter> { public override Dictionary Read (ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType != JsonTokenType.StartArray) { + throw new JsonException ($"Expected a JSON array (\"[ {{ ... }} ]\"), but got \"{reader.TokenType}\"."); + } + var dictionary = new Dictionary (); while (reader.Read ()) { if (reader.TokenType == JsonTokenType.StartObject) { diff --git a/Terminal.Gui/Configuration/Scope.cs b/Terminal.Gui/Configuration/Scope.cs index d950405ed..245153dd2 100644 --- a/Terminal.Gui/Configuration/Scope.cs +++ b/Terminal.Gui/Configuration/Scope.cs @@ -100,7 +100,7 @@ namespace Terminal.Gui { public override scopeT Read (ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.StartObject) { - throw new JsonException ($"Expected a JSON object, but got \"{reader.TokenType}\"."); + throw new JsonException ($"Expected a JSON object (\"{{ \"propName\" : ... }}\"), but got \"{reader.TokenType}\"."); } var scope = (scopeT)Activator.CreateInstance (typeof (scopeT))!; diff --git a/Terminal.Gui/Resources/config.json b/Terminal.Gui/Resources/config.json index af1932937..10d489107 100644 --- a/Terminal.Gui/Resources/config.json +++ b/Terminal.Gui/Resources/config.json @@ -11,6 +11,11 @@ // null). // "$schema": "https://gui-cs.github.io/Terminal.Gui/schemas/tui-config-schema.json", + + // Set this to true in a .config file to be loaded to cause JSON parsing errors + // to throw exceptions. + "ConfigurationManager.ThrowOnJsonErrors": false, + "Application.AlternateBackwardKey": { "Key": "PageUp", "Modifiers": [ diff --git a/UnitTests/Configuration/ConfigurationMangerTests.cs b/UnitTests/Configuration/ConfigurationMangerTests.cs index 7390d6092..e95433e22 100644 --- a/UnitTests/Configuration/ConfigurationMangerTests.cs +++ b/UnitTests/Configuration/ConfigurationMangerTests.cs @@ -544,8 +544,7 @@ namespace Terminal.Gui.ConfigurationTests { // "yellow" is not a color string json = @" { - ""Themes"" : { - ""ThemeDefinitions"" : [ + ""Themes"" : [ { ""Default"" : { ""ColorSchemes"": [ @@ -560,8 +559,7 @@ namespace Terminal.Gui.ConfigurationTests { ] } } - ] - } + ] }"; JsonException jsonException = Assert.Throws (() => ConfigurationManager.Settings.Update (json, "test")); @@ -570,8 +568,7 @@ namespace Terminal.Gui.ConfigurationTests { // AbNormal is not a ColorScheme attribute json = @" { - ""Themes"" : { - ""ThemeDefinitions"" : [ + ""Themes"" : [ { ""Default"" : { ""ColorSchemes"": [ @@ -586,8 +583,7 @@ namespace Terminal.Gui.ConfigurationTests { ] } } - ] - } + ] }"; jsonException = Assert.Throws (() => ConfigurationManager.Settings.Update (json, "test")); @@ -596,8 +592,7 @@ namespace Terminal.Gui.ConfigurationTests { // Modify hotNormal background only json = @" { - ""Themes"" : { - ""ThemeDefinitions"" : [ + ""Themes"" : [ { ""Default"" : { ""ColorSchemes"": [ @@ -611,8 +606,7 @@ namespace Terminal.Gui.ConfigurationTests { ] } } - ] - } + ] }"; jsonException = Assert.Throws (() => ConfigurationManager.Settings.Update (json, "test")); @@ -641,8 +635,7 @@ namespace Terminal.Gui.ConfigurationTests { // "yellow" is not a color string json = @" { - ""Themes"" : { - ""ThemeDefinitions"" : [ + ""Themes"" : [ { ""Default"" : { ""ColorSchemes"": [ @@ -657,7 +650,6 @@ namespace Terminal.Gui.ConfigurationTests { ] } } - ] } }"; @@ -666,8 +658,7 @@ namespace Terminal.Gui.ConfigurationTests { // AbNormal is not a ColorScheme attribute json = @" { - ""Themes"" : { - ""ThemeDefinitions"" : [ + ""Themes"" : [ { ""Default"" : { ""ColorSchemes"": [ @@ -682,7 +673,6 @@ namespace Terminal.Gui.ConfigurationTests { ] } } - ] } }"; @@ -691,8 +681,7 @@ namespace Terminal.Gui.ConfigurationTests { // Modify hotNormal background only json = @" { - ""Themes"" : { - ""ThemeDefinitions"" : [ + ""Themes"" : [ { ""Default"" : { ""ColorSchemes"": [ @@ -706,7 +695,6 @@ namespace Terminal.Gui.ConfigurationTests { ] } } - ] } }";