diff --git a/.gitignore b/.gitignore index 02d767004..837695307 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ UnitTests/TestResults demo.* *.deb + +*.tui/ \ No newline at end of file diff --git a/README.md b/README.md index d62088a11..636aa542b 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,10 @@ _The Documentation matches the most recent Nuget release from the `main` branch * **Cross Platform** - Windows, Mac, and Linux. Terminal drivers for Curses, [Windows Console](https://github.com/gui-cs/Terminal.Gui/issues/27), and the .NET Console mean apps will work well on both color and monochrome terminals. * **Keyboard and Mouse Input** - Both keyboard and mouse input are supported, including support for drag & drop. * **[Flexible Layout](https://gui-cs.github.io/Terminal.Gui/articles/overview.html#layout)** - Supports both *Absolute layout* and an innovative *Computed Layout* system. *Computed Layout* makes it easy to layout controls relative to each other and enables dynamic terminal UIs. +* **[Configuration & Themes](https://gui-cs.github.io/Terminal.Gui/articles/config.html)** - Terminal.Gui supports a rich configuration system that allows end-user customization of how the UI looks (e.g. colors) and behaves (e.g. key-bindings). * **Clipboard support** - Cut, Copy, and Paste of text provided through the [`Clipboard`](https://gui-cs.github.io/Terminal.Gui/api/Terminal.Gui/Terminal.Gui.Clipboard.html) class. * **[Arbitrary Views](https://gui-cs.github.io/Terminal.Gui/api/Terminal.Gui/Terminal.Gui.View.html)** - All visible UI elements are subclasses of the `View` class, and these in turn can contain an arbitrary number of sub-views. -* **Advanced App Features** - The [Mainloop](https://gui-cs.github.io/Terminal.Gui/api/Terminal.Gui/Terminal.Gui.MainLoop.html) supports processing events, idle handlers, timers, and monitoring file -descriptors. Most classes are safe for threading. +* **Advanced App Features** - The [Mainloop](https://gui-cs.github.io/Terminal.Gui/api/Terminal.Gui/Terminal.Gui.MainLoop.html) supports processing events, idle handlers, timers, and monitoring file descriptors. Most classes are safe for threading. * **Reactive Extensions** - Use [reactive extensions](https://github.com/dotnet/reactive) and benefit from increased code readability, and the ability to apply the MVVM pattern and [ReactiveUI](https://www.reactiveui.net/) data bindings. See the [source code](https://github.com/gui-cs/Terminal.Gui/tree/master/ReactiveExample) of a sample app in order to learn how to achieve this. ## Showcase & Examples diff --git a/Terminal.Gui UnitTests/ScenarioTests.cs b/Terminal.Gui UnitTests/ScenarioTests.cs deleted file mode 100644 index f5f1dc57b..000000000 --- a/Terminal.Gui UnitTests/ScenarioTests.cs +++ /dev/null @@ -1,566 +0,0 @@ -using NStack; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Terminal.Gui; -using UICatalog; -using Xunit; -using Xunit.Abstractions; - -// Alias Console to MockConsole so we don't accidentally use Console -using Console = Terminal.Gui.FakeConsole; - -namespace UICatalog { - public class ScenarioTests { - readonly ITestOutputHelper output; - - public ScenarioTests (ITestOutputHelper output) - { -#if DEBUG_IDISPOSABLE - Responder.Instances.Clear (); -#endif - this.output = output; - } - - int CreateInput (string input) - { - // Put a control-q in at the end - FakeConsole.MockKeyPresses.Push (new ConsoleKeyInfo ('q', ConsoleKey.Q, shift: false, alt: false, control: true)); - foreach (var c in input.Reverse ()) { - if (char.IsLetter (c)) { - FakeConsole.MockKeyPresses.Push (new ConsoleKeyInfo (char.ToLower (c), (ConsoleKey)char.ToUpper (c), shift: char.IsUpper (c), alt: false, control: false)); - } else { - FakeConsole.MockKeyPresses.Push (new ConsoleKeyInfo (c, (ConsoleKey)c, shift: false, alt: false, control: false)); - } - } - return FakeConsole.MockKeyPresses.Count; - } - - - /// - /// - /// This runs through all Scenarios defined in UI Catalog, calling Init, Setup, and Run. - /// - /// - /// Should find any Scenarios which crash on load or do not respond to . - /// - /// - [Fact] - public void Run_All_Scenarios () - { - List scenarios = Scenario.GetScenarios (); - Assert.NotEmpty (scenarios); - - foreach (var scenario in scenarios) { - - output.WriteLine ($"Running Scenario '{scenario}'"); - - Func closeCallback = (MainLoop loop) => { - Application.RequestStop (); - return false; - }; - - Application.Init (new FakeDriver (), new FakeMainLoop (() => FakeConsole.ReadKey (true))); - - // Close after a short period of time - var token = Application.MainLoop.AddTimeout (TimeSpan.FromMilliseconds (100), closeCallback); - - scenario.Init (Colors.Base); - scenario.Setup (); - scenario.Run (); - Application.Shutdown (); -#if DEBUG_IDISPOSABLE - foreach (var inst in Responder.Instances) { - Assert.True (inst.WasDisposed); - } - Responder.Instances.Clear (); -#endif - } -#if DEBUG_IDISPOSABLE - foreach (var inst in Responder.Instances) { - Assert.True (inst.WasDisposed); - } - Responder.Instances.Clear (); -#endif - } - - [Fact] - public void Run_Generic () - { - List scenarios = Scenario.GetScenarios (); - Assert.NotEmpty (scenarios); - - var item = scenarios.FindIndex (s => s.GetName ().Equals ("Generic", StringComparison.OrdinalIgnoreCase)); - var generic = scenarios [item]; - // Setup some fake keypresses - // Passing empty string will cause just a ctrl-q to be fired - int stackSize = CreateInput (""); - - Application.Init (new FakeDriver (), new FakeMainLoop (() => FakeConsole.ReadKey (true))); - - int iterations = 0; - Application.Iteration = () => { - iterations++; - // Stop if we run out of control... - if (iterations == 10) { - Application.RequestStop (); - } - }; - - var ms = 1000; - var abortCount = 0; - Func abortCallback = (MainLoop loop) => { - abortCount++; - Application.RequestStop (); - return false; - }; - var token = Application.MainLoop.AddTimeout (TimeSpan.FromMilliseconds (ms), abortCallback); - - Application.Top.KeyPress += (View.KeyEventEventArgs args) => { - Assert.Equal (Key.CtrlMask | Key.Q, args.KeyEvent.Key); - }; - - generic.Init (Colors.Base); - generic.Setup (); - // There is no need to call Application.Begin because Init already creates the Application.Top - // If Application.RunState is used then the Application.RunLoop must also be used instead Application.Run. - //var rs = Application.Begin (Application.Top); - generic.Run (); - - //Application.End (rs); - - Assert.Equal (0, abortCount); - // # of key up events should match # of iterations - Assert.Equal (1, iterations); - // Using variable in the left side of Assert.Equal/NotEqual give error. Must be used literals values. - //Assert.Equal (stackSize, iterations); - - // Shutdown must be called to safely clean up Application if Init has been called - Application.Shutdown (); - -#if DEBUG_IDISPOSABLE - foreach (var inst in Responder.Instances) { - Assert.True (inst.WasDisposed); - } - Responder.Instances.Clear (); -#endif - } - - [Fact] - public void Run_All_Views_Tester_Scenario () - { - Window _leftPane; - ListView _classListView; - FrameView _hostPane; - - Dictionary _viewClasses; - View _curView = null; - - // Settings - FrameView _settingsPane; - CheckBox _computedCheckBox; - FrameView _locationFrame; - RadioGroup _xRadioGroup; - TextField _xText; - int _xVal = 0; - RadioGroup _yRadioGroup; - TextField _yText; - int _yVal = 0; - - FrameView _sizeFrame; - RadioGroup _wRadioGroup; - TextField _wText; - int _wVal = 0; - RadioGroup _hRadioGroup; - TextField _hText; - int _hVal = 0; - List posNames = new List { "Factor", "AnchorEnd", "Center", "Absolute" }; - List dimNames = new List { "Factor", "Fill", "Absolute" }; - - - Application.Init (new FakeDriver (), new FakeMainLoop (() => FakeConsole.ReadKey (true))); - - var Top = Application.Top; - - _viewClasses = GetAllViewClassesCollection () - .OrderBy (t => t.Name) - .Select (t => new KeyValuePair (t.Name, t)) - .ToDictionary (t => t.Key, t => t.Value); - - _leftPane = new Window ("Classes") { - X = 0, - Y = 0, - Width = 15, - Height = Dim.Fill (1), // for status bar - CanFocus = false, - ColorScheme = Colors.TopLevel, - }; - - _classListView = new ListView (_viewClasses.Keys.ToList ()) { - X = 0, - Y = 0, - Width = Dim.Fill (0), - Height = Dim.Fill (0), - AllowsMarking = false, - ColorScheme = Colors.TopLevel, - }; - _leftPane.Add (_classListView); - - _settingsPane = new FrameView ("Settings") { - X = Pos.Right (_leftPane), - Y = 0, // for menu - Width = Dim.Fill (), - Height = 10, - CanFocus = false, - ColorScheme = Colors.TopLevel, - }; - _computedCheckBox = new CheckBox ("Computed Layout", true) { X = 0, Y = 0 }; - _settingsPane.Add (_computedCheckBox); - - var radioItems = new ustring [] { "Percent(x)", "AnchorEnd(x)", "Center", "At(x)" }; - _locationFrame = new FrameView ("Location (Pos)") { - X = Pos.Left (_computedCheckBox), - Y = Pos.Bottom (_computedCheckBox), - Height = 3 + radioItems.Length, - Width = 36, - }; - _settingsPane.Add (_locationFrame); - - var label = new Label ("x:") { X = 0, Y = 0 }; - _locationFrame.Add (label); - _xRadioGroup = new RadioGroup (radioItems) { - X = 0, - Y = Pos.Bottom (label), - }; - _xText = new TextField ($"{_xVal}") { X = Pos.Right (label) + 1, Y = 0, Width = 4 }; - _locationFrame.Add (_xText); - - _locationFrame.Add (_xRadioGroup); - - radioItems = new ustring [] { "Percent(y)", "AnchorEnd(y)", "Center", "At(y)" }; - label = new Label ("y:") { X = Pos.Right (_xRadioGroup) + 1, Y = 0 }; - _locationFrame.Add (label); - _yText = new TextField ($"{_yVal}") { X = Pos.Right (label) + 1, Y = 0, Width = 4 }; - _locationFrame.Add (_yText); - _yRadioGroup = new RadioGroup (radioItems) { - X = Pos.X (label), - Y = Pos.Bottom (label), - }; - _locationFrame.Add (_yRadioGroup); - - _sizeFrame = new FrameView ("Size (Dim)") { - X = Pos.Right (_locationFrame), - Y = Pos.Y (_locationFrame), - Height = 3 + radioItems.Length, - Width = 40, - }; - - radioItems = new ustring [] { "Percent(width)", "Fill(width)", "Sized(width)" }; - label = new Label ("width:") { X = 0, Y = 0 }; - _sizeFrame.Add (label); - _wRadioGroup = new RadioGroup (radioItems) { - X = 0, - Y = Pos.Bottom (label), - }; - _wText = new TextField ($"{_wVal}") { X = Pos.Right (label) + 1, Y = 0, Width = 4 }; - _sizeFrame.Add (_wText); - _sizeFrame.Add (_wRadioGroup); - - radioItems = new ustring [] { "Percent(height)", "Fill(height)", "Sized(height)" }; - label = new Label ("height:") { X = Pos.Right (_wRadioGroup) + 1, Y = 0 }; - _sizeFrame.Add (label); - _hText = new TextField ($"{_hVal}") { X = Pos.Right (label) + 1, Y = 0, Width = 4 }; - _sizeFrame.Add (_hText); - - _hRadioGroup = new RadioGroup (radioItems) { - X = Pos.X (label), - Y = Pos.Bottom (label), - }; - _sizeFrame.Add (_hRadioGroup); - - _settingsPane.Add (_sizeFrame); - - _hostPane = new FrameView ("") { - X = Pos.Right (_leftPane), - Y = Pos.Bottom (_settingsPane), - Width = Dim.Fill (), - Height = Dim.Fill (1), // + 1 for status bar - ColorScheme = Colors.Dialog, - }; - - _classListView.OpenSelectedItem += (a) => { - _settingsPane.SetFocus (); - }; - _classListView.SelectedItemChanged += (args) => { - ClearClass (_curView); - _curView = CreateClass (_viewClasses.Values.ToArray () [_classListView.SelectedItem]); - }; - - _computedCheckBox.Toggled += (previousState) => { - if (_curView != null) { - _curView.LayoutStyle = previousState ? LayoutStyle.Absolute : LayoutStyle.Computed; - _hostPane.LayoutSubviews (); - } - }; - - _xRadioGroup.SelectedItemChanged += (selected) => DimPosChanged (_curView); - - _xText.TextChanged += (args) => { - try { - _xVal = int.Parse (_xText.Text.ToString ()); - DimPosChanged (_curView); - } catch { - - } - }; - - _yText.TextChanged += (args) => { - try { - _yVal = int.Parse (_yText.Text.ToString ()); - DimPosChanged (_curView); - } catch { - - } - }; - - _yRadioGroup.SelectedItemChanged += (selected) => DimPosChanged (_curView); - - _wRadioGroup.SelectedItemChanged += (selected) => DimPosChanged (_curView); - - _wText.TextChanged += (args) => { - try { - _wVal = int.Parse (_wText.Text.ToString ()); - DimPosChanged (_curView); - } catch { - - } - }; - - _hText.TextChanged += (args) => { - try { - _hVal = int.Parse (_hText.Text.ToString ()); - DimPosChanged (_curView); - } catch { - - } - }; - - _hRadioGroup.SelectedItemChanged += (selected) => DimPosChanged (_curView); - - Top.Add (_leftPane, _settingsPane, _hostPane); - - Top.LayoutSubviews (); - - _curView = CreateClass (_viewClasses.First ().Value); - - int iterations = 0; - - Application.Iteration += () => { - iterations++; - - if (iterations < _viewClasses.Count) { - _classListView.MoveDown (); - Assert.Equal (_curView.GetType ().Name, - _viewClasses.Values.ToArray () [_classListView.SelectedItem].Name); - } else { - Application.RequestStop (); - } - }; - - Application.Run (); - - Assert.Equal (_viewClasses.Count, iterations); - - Application.Shutdown (); - - - void DimPosChanged (View view) - { - if (view == null) { - return; - } - - var layout = view.LayoutStyle; - - try { - view.LayoutStyle = LayoutStyle.Absolute; - - switch (_xRadioGroup.SelectedItem) { - case 0: - view.X = Pos.Percent (_xVal); - break; - case 1: - view.X = Pos.AnchorEnd (_xVal); - break; - case 2: - view.X = Pos.Center (); - break; - case 3: - view.X = Pos.At (_xVal); - break; - } - - switch (_yRadioGroup.SelectedItem) { - case 0: - view.Y = Pos.Percent (_yVal); - break; - case 1: - view.Y = Pos.AnchorEnd (_yVal); - break; - case 2: - view.Y = Pos.Center (); - break; - case 3: - view.Y = Pos.At (_yVal); - break; - } - - switch (_wRadioGroup.SelectedItem) { - case 0: - view.Width = Dim.Percent (_wVal); - break; - case 1: - view.Width = Dim.Fill (_wVal); - break; - case 2: - view.Width = Dim.Sized (_wVal); - break; - } - - switch (_hRadioGroup.SelectedItem) { - case 0: - view.Height = Dim.Percent (_hVal); - break; - case 1: - view.Height = Dim.Fill (_hVal); - break; - case 2: - view.Height = Dim.Sized (_hVal); - break; - } - } catch (Exception e) { - MessageBox.ErrorQuery ("Exception", e.Message, "Ok"); - } finally { - view.LayoutStyle = layout; - } - UpdateTitle (view); - } - - void UpdateSettings (View view) - { - var x = view.X.ToString (); - var y = view.Y.ToString (); - _xRadioGroup.SelectedItem = posNames.IndexOf (posNames.Where (s => x.Contains (s)).First ()); - _yRadioGroup.SelectedItem = posNames.IndexOf (posNames.Where (s => y.Contains (s)).First ()); - _xText.Text = $"{view.Frame.X}"; - _yText.Text = $"{view.Frame.Y}"; - - var w = view.Width.ToString (); - var h = view.Height.ToString (); - _wRadioGroup.SelectedItem = dimNames.IndexOf (dimNames.Where (s => w.Contains (s)).First ()); - _hRadioGroup.SelectedItem = dimNames.IndexOf (dimNames.Where (s => h.Contains (s)).First ()); - _wText.Text = $"{view.Frame.Width}"; - _hText.Text = $"{view.Frame.Height}"; - } - - void UpdateTitle (View view) - { - _hostPane.Title = $"{view.GetType ().Name} - {view.X.ToString ()}, {view.Y.ToString ()}, {view.Width.ToString ()}, {view.Height.ToString ()}"; - } - - List GetAllViewClassesCollection () - { - List types = new List (); - foreach (Type type in typeof (View).Assembly.GetTypes () - .Where (myType => myType.IsClass && !myType.IsAbstract && myType.IsPublic && myType.IsSubclassOf (typeof (View)))) { - types.Add (type); - } - return types; - } - - void ClearClass (View view) - { - // Remove existing class, if any - if (view != null) { - view.LayoutComplete -= LayoutCompleteHandler; - _hostPane.Remove (view); - view.Dispose (); - _hostPane.Clear (); - } - } - - View CreateClass (Type type) - { - // If we are to create a generic Type - if (type.IsGenericType) { - - // For each of the arguments - List typeArguments = new List (); - - // use - foreach (var arg in type.GetGenericArguments ()) { - typeArguments.Add (typeof (object)); - } - - // And change what type we are instantiating from MyClass to MyClass - type = type.MakeGenericType (typeArguments.ToArray ()); - } - // Instantiate view - var view = (View)Activator.CreateInstance (type); - - //_curView.X = Pos.Center (); - //_curView.Y = Pos.Center (); - view.Width = Dim.Percent (75); - view.Height = Dim.Percent (75); - - // Set the colorscheme to make it stand out if is null by default - if (view.ColorScheme == null) { - view.ColorScheme = Colors.Base; - } - - // If the view supports a Text property, set it so we have something to look at - if (view.GetType ().GetProperty ("Text") != null) { - try { - view.GetType ().GetProperty ("Text")?.GetSetMethod ()?.Invoke (view, new [] { ustring.Make ("Test Text") }); - } catch (TargetInvocationException e) { - MessageBox.ErrorQuery ("Exception", e.InnerException.Message, "Ok"); - view = null; - } - } - - // If the view supports a Title property, set it so we have something to look at - if (view != null && view.GetType ().GetProperty ("Title") != null) { - view?.GetType ().GetProperty ("Title")?.GetSetMethod ()?.Invoke (view, new [] { ustring.Make ("Test Title") }); - } - - // If the view supports a Source property, set it so we have something to look at - if (view != null && view.GetType ().GetProperty ("Source") != null && view.GetType ().GetProperty ("Source").PropertyType == typeof (Terminal.Gui.IListDataSource)) { - var source = new ListWrapper (new List () { ustring.Make ("Test Text #1"), ustring.Make ("Test Text #2"), ustring.Make ("Test Text #3") }); - view?.GetType ().GetProperty ("Source")?.GetSetMethod ()?.Invoke (view, new [] { source }); - } - - // Set Settings - _computedCheckBox.Checked = view.LayoutStyle == LayoutStyle.Computed; - - // Add - _hostPane.Add (view); - //DimPosChanged (); - _hostPane.LayoutSubviews (); - _hostPane.Clear (); - _hostPane.SetNeedsDisplay (); - UpdateSettings (view); - UpdateTitle (view); - - view.LayoutComplete += LayoutCompleteHandler; - - return view; - } - - void LayoutCompleteHandler (View.LayoutEventArgs args) - { - UpdateTitle (_curView); - } - } - } -} diff --git a/Terminal.Gui UnitTests/UnitTests.csproj b/Terminal.Gui UnitTests/UnitTests.csproj deleted file mode 100644 index f01db4c0c..000000000 --- a/Terminal.Gui UnitTests/UnitTests.csproj +++ /dev/null @@ -1,57 +0,0 @@ - - - net6.0 - false - - - - - 1.0 - 1.0 - 1.0 - 1.0 - - - TRACE - - - TRACE;DEBUG_IDISPOSABLE - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - True - - - [UICatalog]* - - - - - - - - - - False - - - \ No newline at end of file diff --git a/Terminal.Gui/Configuration/AppScope.cs b/Terminal.Gui/Configuration/AppScope.cs new file mode 100644 index 000000000..46c7df349 --- /dev/null +++ b/Terminal.Gui/Configuration/AppScope.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Text.Json.Serialization; +using static Terminal.Gui.Configuration.ConfigurationManager; + +#nullable enable + +namespace Terminal.Gui.Configuration { + + public static partial class ConfigurationManager { + /// + /// The class for application-defined configuration settings. + /// + /// + /// + /// + /// + /// Use the attribute to mark properties that should be serialized as part + /// of application-defined configuration settings. + /// + /// + /// public class MyAppSettings { + /// [SerializableConfigurationProperty (Scope = typeof (AppScope))] + /// public static bool? MyProperty { get; set; } = true; + /// } + /// + /// + /// THe resultant Json will look like this: + /// + /// + /// "AppSettings": { + /// "MyAppSettings.MyProperty": true, + /// "UICatalog.ShowStatusBar": true + /// }, + /// + /// + [JsonConverter (typeof (ScopeJsonConverter))] + public class AppScope : Scope { + } + } +} \ No newline at end of file diff --git a/Terminal.Gui/Configuration/AttributeJsonConverter.cs b/Terminal.Gui/Configuration/AttributeJsonConverter.cs new file mode 100644 index 000000000..3e19fc5f1 --- /dev/null +++ b/Terminal.Gui/Configuration/AttributeJsonConverter.cs @@ -0,0 +1,89 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Terminal.Gui; + +namespace Terminal.Gui.Configuration { + /// + /// Json converter fro the class. + /// + public class AttributeJsonConverter : JsonConverter { + private static AttributeJsonConverter instance; + + /// + /// + /// + public static AttributeJsonConverter Instance { + get { + if (instance == null) { + instance = new AttributeJsonConverter (); + } + + return instance; + } + } + + /// + public override Attribute Read (ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) { + throw new JsonException ($"Unexpected StartObject token when parsing Attribute: {reader.TokenType}."); + } + + Attribute attribute = new Attribute (); + Color foreground = (Color)(-1); + Color background = (Color)(-1); + while (reader.Read ()) { + if (reader.TokenType == JsonTokenType.EndObject) { + if (foreground == (Color)(-1) || background == (Color)(-1)) { + throw new JsonException ($"Both Foreground and Background colors must be provided."); + } + return attribute; + } + + if (reader.TokenType != JsonTokenType.PropertyName) { + throw new JsonException ($"Unexpected token when parsing Attribute: {reader.TokenType}."); + } + + string propertyName = reader.GetString (); + reader.Read (); + string color = $"\"{reader.GetString ()}\""; + + switch (propertyName.ToLower ()) { + case "foreground": + foreground = JsonSerializer.Deserialize (color, options); + break; + case "background": + background = JsonSerializer.Deserialize (color, options); + break; + //case "Bright": + // attribute.Bright = reader.GetBoolean (); + // break; + //case "Underline": + // attribute.Underline = reader.GetBoolean (); + // break; + //case "Reverse": + // attribute.Reverse = reader.GetBoolean (); + // break; + default: + throw new JsonException ($"Unknown Attribute property {propertyName}."); + } + + attribute = new Attribute (foreground, background); + } + throw new JsonException (); + } + + /// + public override void Write (Utf8JsonWriter writer, Attribute value, JsonSerializerOptions options) + { + writer.WriteStartObject (); + writer.WritePropertyName ("Foreground"); + ColorJsonConverter.Instance.Write (writer, value.Foreground, options); + writer.WritePropertyName ("Background"); + ColorJsonConverter.Instance.Write (writer, value.Background, options); + writer.WriteEndObject (); + } + } +} + diff --git a/Terminal.Gui/Configuration/ColorJsonConverter.cs b/Terminal.Gui/Configuration/ColorJsonConverter.cs new file mode 100644 index 000000000..24896df69 --- /dev/null +++ b/Terminal.Gui/Configuration/ColorJsonConverter.cs @@ -0,0 +1,76 @@ +using System; +using System.Text.Json.Serialization; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Terminal.Gui.Configuration { + /// + /// Json converter for the class. + /// + public class ColorJsonConverter : JsonConverter { + private static ColorJsonConverter instance; + + /// + /// Singleton + /// + public static ColorJsonConverter Instance { + get { + if (instance == null) { + instance = new ColorJsonConverter (); + } + + return instance; + } + } + + /// + public override Color Read (ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Check if the value is a string + if (reader.TokenType == JsonTokenType.String) { + // Get the color string + var colorString = reader.GetString (); + + // Check if the color string is a color name + if (Enum.TryParse (colorString, ignoreCase: true, out Color color)) { + // Return the parsed color + return color; + } else { + // Parse the color string as an RGB value + var match = Regex.Match (colorString, @"rgb\((\d+),(\d+),(\d+)\)"); + if (match.Success) { + var r = int.Parse (match.Groups [1].Value); + var g = int.Parse (match.Groups [2].Value); + var b = int.Parse (match.Groups [3].Value); + return new TrueColor (r, g, b).ToConsoleColor (); + } else { + throw new JsonException ($"Invalid Color: '{colorString}'"); + } + } + } else { + throw new JsonException ($"Unexpected token when parsing Color: {reader.TokenType}"); + } + } + + /// + public override void Write (Utf8JsonWriter writer, Color value, JsonSerializerOptions options) + { + // Try to get the human readable color name from the map + var name = Enum.GetName (typeof (Color), value); + if (name != null) { + // Write the color name to the JSON + writer.WriteStringValue (name); + } else { + //// If the color is not in the map, look up its RGB values in the consoleDriver.colors array + //ConsoleColor consoleColor = (ConsoleDriver [(int)value]); + //int r = consoleColor.R; + //int g = consoleColor.G; + //int b = consoleColor.B; + + //// Write the RGB values as a string to the JSON + //writer.WriteStringValue ($"rgb({r},{g},{b})"); + throw new JsonException ($"Unknown Color value. Cannot serialize to JSON: {value}"); + } + } + } +} diff --git a/Terminal.Gui/Configuration/ColorSchemeJsonConverter.cs b/Terminal.Gui/Configuration/ColorSchemeJsonConverter.cs new file mode 100644 index 000000000..2f4044a22 --- /dev/null +++ b/Terminal.Gui/Configuration/ColorSchemeJsonConverter.cs @@ -0,0 +1,89 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Terminal.Gui.Configuration { + /// + /// Implements a JSON converter for . + /// + public class ColorSchemeJsonConverter : JsonConverter { + private static ColorSchemeJsonConverter instance; + + /// + /// Singleton + /// + public static ColorSchemeJsonConverter Instance { + get { + if (instance == null) { + instance = new ColorSchemeJsonConverter (); + } + return instance; + } + } + + /// + public override ColorScheme Read (ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) { + throw new JsonException ($"Unexpected StartObject token when parsing ColorScheme: {reader.TokenType}."); + } + + var colorScheme = new ColorScheme (); + + while (reader.Read ()) { + if (reader.TokenType == JsonTokenType.EndObject) { + return colorScheme; + } + + if (reader.TokenType != JsonTokenType.PropertyName) { + throw new JsonException ($"Unexpected token when parsing Attribute: {reader.TokenType}."); + } + + var propertyName = reader.GetString (); + reader.Read (); + var attribute = JsonSerializer.Deserialize (ref reader, options); + + switch (propertyName.ToLower()) { + case "normal": + colorScheme.Normal = attribute; + break; + case "focus": + colorScheme.Focus = attribute; + break; + case "hotnormal": + colorScheme.HotNormal = attribute; + break; + case "hotfocus": + colorScheme.HotFocus = attribute; + break; + case "disabled": + colorScheme.Disabled = attribute; + break; + default: + throw new JsonException ($"Unrecognized ColorScheme Attribute name: {propertyName}."); + } + } + + throw new JsonException (); + } + + /// + public override void Write (Utf8JsonWriter writer, ColorScheme value, JsonSerializerOptions options) + { + writer.WriteStartObject (); + + writer.WritePropertyName ("Normal"); + AttributeJsonConverter.Instance.Write (writer, value.Normal, options); + writer.WritePropertyName ("Focus"); + AttributeJsonConverter.Instance.Write (writer, value.Focus, options); + writer.WritePropertyName ("HotNormal"); + AttributeJsonConverter.Instance.Write (writer, value.HotNormal, options); + writer.WritePropertyName ("HotFocus"); + AttributeJsonConverter.Instance.Write (writer, value.HotFocus, options); + writer.WritePropertyName ("Disabled"); + AttributeJsonConverter.Instance.Write (writer, value.Disabled, options); + + writer.WriteEndObject (); + } + } +} \ No newline at end of file diff --git a/Terminal.Gui/Configuration/ConfigurationManager.cs b/Terminal.Gui/Configuration/ConfigurationManager.cs new file mode 100644 index 000000000..85e5c440f --- /dev/null +++ b/Terminal.Gui/Configuration/ConfigurationManager.cs @@ -0,0 +1,663 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using static Terminal.Gui.Configuration.ConfigurationManager; + +#nullable enable + +namespace Terminal.Gui.Configuration { + /// + /// 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 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 JsonSerializerOptions serializerOptions = new JsonSerializerOptions { + ReadCommentHandling = JsonCommentHandling.Skip, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + Converters = { + // No need to set converterss - the ConfigRootConverter uses property attributes apply the correct + // Converter. + }, + }; + + /// + /// 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 BorderStyle DefaultBorderStyle { + /// ... + /// + [AttributeUsage (AttributeTargets.Property, AllowMultiple = false, Inherited = false)] + public class SerializableConfigurationProperty : System.Attribute { + /// + /// Specifies the scope of the property. + /// + public Type? Scope { 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 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; + } + + return PropertyValue; + } + + /// + /// Retrieves (using reflection) the value of the static property described in + /// into . + /// + /// + public object? RetrieveValue () + { + return PropertyValue = PropertyInfo!.GetValue (null); + } + + /// + /// Applies the to the property described by . + /// + /// + public bool Apply () + { + if (PropertyValue != null) { + PropertyInfo?.SetValue (null, DeepMemberwiseCopy (PropertyValue, PropertyInfo?.GetValue (null))); + } + return PropertyValue != null; + } + } + + /// + /// 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 + /// deserializtion (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; + + /// + /// Aplication-specific configuration settings scope. + /// + [SerializableConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true), JsonPropertyName ("AppSettings")] + public static AppScope? AppSettings { get; set; } + + /// + /// Initializes the internal state of ConfiguraitonManager. Nominally called once as part of application + /// startup to initilaize 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} clases:"); + 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 ommited, 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; + } + + /// + /// Event arguments for the events. + /// + public class ConfigurationManagerEventArgs : EventArgs { + + /// + /// Initializes a new instance of + /// + public ConfigurationManagerEventArgs () + { + } + } + + /// + /// 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 (new ConfigurationManagerEventArgs ()); + } + + /// + /// Event fired when the configuration has been upddated from a configuration source. + /// application. + /// + public static event Action? 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 defintions (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 = 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 (new ConfigurationManagerEventArgs ()); + } + + /// + /// Event fired when an updated configuration has been applied to the + /// application. + /// + public static event Action? 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 constancts 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 configuraiton storage locations to + /// the . Optionally, + /// resets all settings attributed with to the defaults + /// defined in . + /// + /// + /// Use to cause the loaded settings to be applied to the running application. + /// + /// If the state of will + /// be reset to the defaults defined in . + public static void Load (bool reset = false) + { + Debug.WriteLine ($"ConfigurationManager.Load()"); + + if (reset) Reset (); + + // LibraryResoruces 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 new file mode 100644 index 000000000..aea4bf51e --- /dev/null +++ b/Terminal.Gui/Configuration/DictionaryJsonConverter.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using System.Text.Json; + +namespace Terminal.Gui.Configuration { + + class DictionaryJsonConverter : JsonConverter> { + public override Dictionary Read (ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var dictionary = new Dictionary (); + while (reader.Read ()) { + if (reader.TokenType == JsonTokenType.StartObject) { + reader.Read (); + if (reader.TokenType == JsonTokenType.PropertyName) { + string key = reader.GetString (); + reader.Read (); + T value = JsonSerializer.Deserialize (ref reader, options); + dictionary.Add (key, value); + } + } else if (reader.TokenType == JsonTokenType.EndArray) + break; + } + return dictionary; + } + + + public override void Write (Utf8JsonWriter writer, Dictionary value, JsonSerializerOptions options) + { + writer.WriteStartArray (); + foreach (var item in value) { + writer.WriteStartObject (); + //writer.WriteString (item.Key, item.Key); + writer.WritePropertyName (item.Key); + JsonSerializer.Serialize (writer, item.Value, options); + writer.WriteEndObject (); + } + writer.WriteEndArray (); + } + } +} diff --git a/Terminal.Gui/Configuration/KeyJsonConverter.cs b/Terminal.Gui/Configuration/KeyJsonConverter.cs new file mode 100644 index 000000000..26a258f54 --- /dev/null +++ b/Terminal.Gui/Configuration/KeyJsonConverter.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Terminal.Gui.Configuration { + /// + /// Json converter for the class. + /// + public class KeyJsonConverter : JsonConverter { + /// + public override Key Read (ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.StartObject) { + Key key = Key.Unknown; + Dictionary modifierDict = new Dictionary (comparer: StringComparer.InvariantCultureIgnoreCase) { + { "Shift", Key.ShiftMask }, + { "Ctrl", Key.CtrlMask }, + { "Alt", Key.AltMask } + }; + + List modifiers = new List (); + + while (reader.Read ()) { + if (reader.TokenType == JsonTokenType.EndObject) { + break; + } + + if (reader.TokenType == JsonTokenType.PropertyName) { + string propertyName = reader.GetString (); + reader.Read (); + + switch (propertyName.ToLowerInvariant ()) { + case "key": + if (reader.TokenType == JsonTokenType.String) { + if (Enum.TryParse (reader.GetString (), false, out key)) { + break; + } + + // The enum uses "D0..D9" for the number keys + if (Enum.TryParse (reader.GetString ().TrimStart ('D', 'd'), false, out key)) { + break; + } + + if (key == Key.Unknown || key == Key.Null) { + throw new JsonException ($"The value \"{reader.GetString ()}\" is not a valid Key."); + } + + } else if (reader.TokenType == JsonTokenType.Number) { + try { + key = (Key)reader.GetInt32 (); + } catch (InvalidOperationException ioe) { + throw new JsonException ($"Error parsing Key value: {ioe.Message}", ioe); + } catch (FormatException ioe) { + throw new JsonException ($"Error parsing Key value: {ioe.Message}", ioe); + } + break; + } + break; + + case "modifiers": + if (reader.TokenType == JsonTokenType.StartArray) { + while (reader.Read ()) { + if (reader.TokenType == JsonTokenType.EndArray) { + break; + } + var mod = reader.GetString (); + try { + modifiers.Add (modifierDict [mod]); + } catch (KeyNotFoundException e) { + throw new JsonException ($"The value \"{mod}\" is not a valid modifier.", e); + } + } + } else { + throw new JsonException ($"Expected an array of modifiers, but got \"{reader.TokenType}\"."); + } + break; + + default: + throw new JsonException ($"Unexpected Key property \"{propertyName}\"."); + } + } + } + + foreach (var modifier in modifiers) { + key |= modifier; + } + + return key; + } + throw new JsonException ($"Unexpected StartObject token when parsing Key: {reader.TokenType}."); + } + + /// + public override void Write (Utf8JsonWriter writer, Key value, JsonSerializerOptions options) + { + writer.WriteStartObject (); + + var keyName = (value & ~Key.CtrlMask & ~Key.ShiftMask & ~Key.AltMask).ToString (); + if (keyName != null) { + writer.WriteString ("Key", keyName); + } else { + writer.WriteNumber ("Key", (uint)(value & ~Key.CtrlMask & ~Key.ShiftMask & ~Key.AltMask)); + } + + Dictionary modifierDict = new Dictionary + { + { "Shift", Key.ShiftMask }, + { "Ctrl", Key.CtrlMask }, + { "Alt", Key.AltMask } + }; + + List modifiers = new List (); + foreach (var pair in modifierDict) { + if ((value & pair.Value) == pair.Value) { + modifiers.Add (pair.Key); + } + } + + if (modifiers.Count > 0) { + writer.WritePropertyName ("Modifiers"); + writer.WriteStartArray (); + foreach (var modifier in modifiers) { + writer.WriteStringValue (modifier); + } + writer.WriteEndArray (); + } + + writer.WriteEndObject (); + } + } +} diff --git a/Terminal.Gui/Configuration/Scope.cs b/Terminal.Gui/Configuration/Scope.cs new file mode 100644 index 000000000..7e8aceb79 --- /dev/null +++ b/Terminal.Gui/Configuration/Scope.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using static Terminal.Gui.Configuration.ConfigurationManager; + + +#nullable enable + +namespace Terminal.Gui.Configuration { + public static partial class ConfigurationManager { + + /// + /// Defines a configuration settings scope. Classes that inherit from this abstract class can be used to define + /// scopes for configuration settings. Each scope is a JSON object that contains a set of configuration settings. + /// + public class Scope : Dictionary { //, IScope> { + /// + /// Crates a new instance. + /// + public Scope () : base (StringComparer.InvariantCultureIgnoreCase) + { + foreach (var p in GetScopeProperties ()) { + Add (p.Key, new ConfigProperty () { PropertyInfo = p.Value.PropertyInfo, PropertyValue = null }); + } + } + + private IEnumerable> GetScopeProperties () + { + return ConfigurationManager._allConfigProperties!.Where (cp => + (cp.Value.PropertyInfo?.GetCustomAttribute (typeof (SerializableConfigurationProperty)) + as SerializableConfigurationProperty)?.Scope == GetType ()); + } + + /// + /// Updates this instance from the specified source scope. + /// + /// + /// The updated scope (this). + public Scope? Update (Scope source) + { + foreach (var prop in source) { + if (ContainsKey (prop.Key)) + this [prop.Key].PropertyValue = this [prop.Key].UpdateValueFrom (prop.Value.PropertyValue!); + else { + this [prop.Key].PropertyValue = prop.Value.PropertyValue; + } + } + return this; + } + + /// + /// Retrieves the values of the properties of this scope from their corresponding static properties. + /// + public void RetrieveValues () + { + foreach (var p in this.Where (cp => cp.Value.PropertyInfo != null)) { + p.Value.RetrieveValue (); + } + } + + /// + /// Applies the values of the properties of this scope to their corresponding static properties. + /// + /// + internal virtual bool Apply () + { + bool set = false; + foreach (var p in this.Where (t => t.Value != null && t.Value.PropertyValue != null)) { + if (p.Value.Apply ()) { + set = true; + } + } + return set; + } + } + + /// + /// Converts instances to/from JSON. Does all the heavy lifting of reading/writing + /// config data to/from JSON documents. + /// + /// + public class ScopeJsonConverter : JsonConverter where scopeT : Scope { + // See: https://stackoverflow.com/questions/60830084/how-to-pass-an-argument-by-reference-using-reflection + internal abstract class ReadHelper { + public abstract object? Read (ref Utf8JsonReader reader, Type type, JsonSerializerOptions options); + } + + internal class ReadHelper : ReadHelper { + private readonly ReadDelegate _readDelegate; + private delegate converterT ReadDelegate (ref Utf8JsonReader reader, Type type, JsonSerializerOptions options); + public ReadHelper (object converter) + => _readDelegate = (ReadDelegate)Delegate.CreateDelegate (typeof (ReadDelegate), converter, "Read"); + public override object? Read (ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) + => _readDelegate.Invoke (ref reader, type, options); + } + + /// + 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}\"."); + } + + var scope = (scopeT)Activator.CreateInstance (typeof (scopeT))!; + while (reader.Read ()) { + if (reader.TokenType == JsonTokenType.EndObject) { + return scope!; + } + if (reader.TokenType != JsonTokenType.PropertyName) { + throw new JsonException ($"Expected a JSON property name, but got \"{reader.TokenType}\"."); + } + var propertyName = reader.GetString (); + reader.Read (); + + if (propertyName != null && scope!.TryGetValue (propertyName, out var configProp)) { + // This property name was found in the Scope's ScopeProperties dictionary + // Figure out if it needs a JsonConverter and if so, create one + var propertyType = configProp?.PropertyInfo?.PropertyType!; + if (configProp?.PropertyInfo?.GetCustomAttribute (typeof (JsonConverterAttribute)) is JsonConverterAttribute jca) { + var converter = Activator.CreateInstance (jca.ConverterType!)!; + if (converter.GetType ().BaseType == typeof (JsonConverterFactory)) { + var factory = (JsonConverterFactory)converter; + if (propertyType != null && factory.CanConvert (propertyType)) { + converter = factory.CreateConverter (propertyType, options); + } + } + var readHelper = Activator.CreateInstance ((Type?)typeof (ReadHelper<>).MakeGenericType (typeof (scopeT), propertyType!)!, converter) as ReadHelper; + scope! [propertyName].PropertyValue = readHelper?.Read (ref reader, propertyType!, options); + } else { + scope! [propertyName].PropertyValue = JsonSerializer.Deserialize (ref reader, propertyType!, options); + } + } else { + // It is not a config property. Maybe it's just a property on the Scope with [JsonInclude] + // like ScopeSettings.$schema... + var property = scope!.GetType ().GetProperties ().Where (p => { + var jia = p.GetCustomAttribute (typeof (JsonIncludeAttribute)) as JsonIncludeAttribute; + if (jia != null) { + var jpna = p.GetCustomAttribute (typeof (JsonPropertyNameAttribute)) as JsonPropertyNameAttribute; + if (jpna?.Name == propertyName) { + // Bit of a hack, modifying propertyName in an enumerator... + propertyName = p.Name; + return true; + } + + return p.Name == propertyName; + } + return false; + }).FirstOrDefault (); + + if (property != null) { + var prop = scope.GetType ().GetProperty (propertyName!)!; + prop.SetValue (scope, JsonSerializer.Deserialize (ref reader, prop.PropertyType, options)); + } else { + // Unknown property + throw new JsonException ($"Unknown property name \"{propertyName}\"."); + } + } + } + throw new JsonException (); + } + + /// + public override void Write (Utf8JsonWriter writer, scopeT scope, JsonSerializerOptions options) + { + writer.WriteStartObject (); + + var properties = scope!.GetType ().GetProperties ().Where (p => p.GetCustomAttribute (typeof (JsonIncludeAttribute)) != null); + foreach (var p in properties) { + writer.WritePropertyName (ConfigProperty.GetJsonPropertyName (p)); + JsonSerializer.Serialize (writer, scope.GetType ().GetProperty (p.Name)?.GetValue (scope), options); + } + + foreach (var p in from p in scope + .Where (cp => + cp.Value.PropertyInfo?.GetCustomAttribute (typeof (SerializableConfigurationProperty)) is + SerializableConfigurationProperty scp && scp?.Scope == typeof (scopeT)) + where p.Value.PropertyValue != null + select p) { + + writer.WritePropertyName (p.Key); + var propertyType = p.Value.PropertyInfo?.PropertyType; + + if (propertyType != null && p.Value.PropertyInfo?.GetCustomAttribute (typeof (JsonConverterAttribute)) is JsonConverterAttribute jca) { + var converter = Activator.CreateInstance (jca.ConverterType!)!; + if (converter.GetType ().BaseType == typeof (JsonConverterFactory)) { + var factory = (JsonConverterFactory)converter; + if (factory.CanConvert (propertyType)) { + converter = factory.CreateConverter (propertyType, options)!; + } + } + if (p.Value.PropertyValue != null) { + converter.GetType ().GetMethod ("Write")?.Invoke (converter, new object [] { writer, p.Value.PropertyValue, options }); + } + } else { + JsonSerializer.Serialize (writer, p.Value.PropertyValue, options); + } + } + writer.WriteEndObject (); + } + } + } +} diff --git a/Terminal.Gui/Configuration/SettingsScope.cs b/Terminal.Gui/Configuration/SettingsScope.cs new file mode 100644 index 000000000..f66a2bc4b --- /dev/null +++ b/Terminal.Gui/Configuration/SettingsScope.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + + +#nullable enable + +namespace Terminal.Gui.Configuration { + public static partial class ConfigurationManager { + /// + /// The root object of Terminal.Gui configuration settings / JSON schema. Contains only properties + /// attributed with . + /// + /// + /// { + /// "$schema" : "https://gui-cs.github.io/Terminal.Gui/schemas/tui-config-schema.json", + /// "Application.UseSystemConsole" : true, + /// "Theme" : "Default", + /// "Themes": { + /// }, + /// }, + /// + /// + /// + [JsonConverter (typeof (ScopeJsonConverter))] + public class SettingsScope : Scope { + /// + /// Points to our JSON schema. + /// + [JsonInclude, JsonPropertyName ("$schema")] + public string Schema { get; set; } = "https://gui-cs.github.io/Terminal.Gui/schemas/tui-config-schema.json"; + + public List Sources = new List (); + + /// + /// Updates the with the settings in a JSON string. + /// + /// Json document to update the settings with. + /// The source (filename/resource name) the Json document was read from. + public SettingsScope? Update (Stream stream, string source) + { + // Update the existing settings with the new settings. + try { + Update (JsonSerializer.Deserialize (stream, serializerOptions)!); + OnUpdated (); + Debug.WriteLine ($"ConfigurationManager: Read configuration from \"{source}\""); + Sources.Add (source); + return this; + } catch (JsonException e) { + if (ThrowOnJsonErrors ?? false) { + throw; + } else { + AddJsonError ($"Error deserializing {source}: {e.Message}"); + } + } + return this; + } + + /// + /// Updates the with the settings in a JSON file. + /// + /// + public SettingsScope? Update (string filePath) + { + var realPath = filePath.Replace("~", Environment.GetFolderPath (Environment.SpecialFolder.UserProfile)); + if (!File.Exists (realPath)) { + Debug.WriteLine ($"ConfigurationManager: Configuration file \"{realPath}\" does not exist."); + Sources.Add (filePath); + return this; + } + + var stream = File.OpenRead (realPath); + return Update (stream, filePath); + } + + /// + /// Updates the with the settings from a Json resource. + /// + /// + /// + public SettingsScope? UpdateFromResource (Assembly assembly, string resourceName) + { + if (resourceName == null || string.IsNullOrEmpty (resourceName)) { + Debug.WriteLine ($"ConfigurationManager: Resource \"{resourceName}\" does not exist in \"{assembly.GetName ().Name}\"."); + return this; + } + + using Stream? stream = assembly.GetManifestResourceStream (resourceName)!; + if (stream == null) { + Debug.WriteLine ($"ConfigurationManager: Failed to read resource \"{resourceName}\" from \"{assembly.GetName ().Name}\"."); + return this; + } + + return Update (stream, $"resource://[{assembly.GetName().Name}]/{resourceName}"); + } + + /// + /// Updates the with the settings in a JSON string. + /// + /// Json document to update the settings with. + /// The source (filename/resource name) the Json document was read from. + public SettingsScope? Update (string json, string source) + { + var stream = new MemoryStream (); + var writer = new StreamWriter (stream); + writer.Write (json); + writer.Flush (); + stream.Position = 0; + + return Update (stream, source); + } + } + } +} diff --git a/Terminal.Gui/Configuration/ThemeScope.cs b/Terminal.Gui/Configuration/ThemeScope.cs new file mode 100644 index 000000000..aab8e0068 --- /dev/null +++ b/Terminal.Gui/Configuration/ThemeScope.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Text.Json.Serialization; +using static Terminal.Gui.Configuration.ConfigurationManager; + +#nullable enable + +namespace Terminal.Gui.Configuration { + + public static partial class ConfigurationManager { + /// + /// The root object for a Theme. A Theme is a set of settings that are applied to the running + /// as a group. + /// + /// + /// + /// + /// + /// + /// "Default": { + /// "ColorSchemes": [ + /// { + /// "TopLevel": { + /// "Normal": { + /// "Foreground": "BrightGreen", + /// "Background": "Black" + /// }, + /// "Focus": { + /// "Foreground": "White", + /// "Background": "Cyan" + /// + /// }, + /// "HotNormal": { + /// "Foreground": "Brown", + /// "Background": "Black" + /// + /// }, + /// "HotFocus": { + /// "Foreground": "Blue", + /// "Background": "Cyan" + /// }, + /// "Disabled": { + /// "Foreground": "DarkGray", + /// "Background": "Black" + /// + /// } + /// } + /// + [JsonConverter (typeof (ScopeJsonConverter))] + public class ThemeScope : Scope { + + /// + internal override bool Apply () + { + var ret = base.Apply (); + Application.Driver?.InitalizeColorSchemes (); + return ret; + } + } + + /// + /// Contains a dictionary of the s for a Terminal.Gui application. + /// + /// + /// + /// A Theme is a collection of settings that are named. The default theme is named "Default". + /// + /// + /// The property is used to detemrine the currently active theme. + /// + /// + /// + /// is a singleton class. It is created when the first property is accessed. + /// Accessing is the same as accessing . + /// + /// + /// "Themes": [ + /// { + /// "Default": { + /// "ColorSchemes": [ + /// { + /// "TopLevel": { + /// "Normal": { + /// "Foreground": "BrightGreen", + /// "Background": "Black" + /// }, + /// "Focus": { + /// "Foreground": "White", + /// "Background": "Cyan" + /// + /// }, + /// "HotNormal": { + /// "Foreground": "Brown", + /// "Background": "Black" + /// + /// }, + /// "HotFocus": { + /// "Foreground": "Blue", + /// "Background": "Cyan" + /// }, + /// "Disabled": { + /// "Foreground": "DarkGray", + /// "Background": "Black" + /// + /// } + /// } + /// } + /// + public class ThemeManager : IDictionary { + private static readonly ThemeManager _instance = new ThemeManager (); + static ThemeManager () { } // Make sure it's truly lazy + private ThemeManager () { } // Prevent instantiation outside + + /// + /// Class is a singleton... + /// + public static ThemeManager Instance { get { return _instance; } } + + private static string theme = string.Empty; + + /// + /// The currently selected theme. This is the internal version; see . + /// + [JsonInclude, SerializableConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true), JsonPropertyName ("Theme")] + internal static string SelectedTheme { + get => theme; + set { + var oldTheme = theme; + theme = value; + if (oldTheme != theme && + ConfigurationManager.Settings! ["Themes"]?.PropertyValue is Dictionary themes && + themes.ContainsKey (theme)) { + ConfigurationManager.Settings! ["Theme"].PropertyValue = theme; + Instance.OnThemeChanged (oldTheme); + } + } + } + + /// + /// Gets or sets the currently selected theme. The value is persisted to the "Theme" + /// property. + /// + [JsonIgnore] + public string Theme { + get => ThemeManager.SelectedTheme; + set { + ThemeManager.SelectedTheme = value; + } + } + + /// + /// Event arguments for the events. + /// + public class ThemeManagerEventArgs : EventArgs { + /// + /// The name of the new active theme.. + /// + public string NewTheme { get; set; } = string.Empty; + + /// + /// Initializes a new instance of + /// + public ThemeManagerEventArgs (string newTheme) + { + NewTheme = newTheme; + } + } + + /// + /// Called when the selected theme has changed. Fires the event. + /// + internal void OnThemeChanged (string theme) + { + Debug.WriteLine ($"Themes.OnThemeChanged({theme}) -> {Theme}"); + ThemeChanged?.Invoke (new ThemeManagerEventArgs (theme)); + } + + /// + /// Event fired he selected theme has changed. + /// application. + /// + public event Action? ThemeChanged; + + /// + /// Holds the definitions. + /// + [JsonInclude, JsonConverter (typeof (DictionaryJsonConverter))] + [SerializableConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true)] + public static Dictionary? Themes { + get => Settings? ["Themes"]?.PropertyValue as Dictionary; // themes ?? new Dictionary (); + set { + //if (themes == null || value == null) { + // themes = value; + //} else { + // themes = (Dictionary)DeepMemberwiseCopy (value!, themes!)!; + //} + Settings! ["Themes"].PropertyValue = value; + } + } + + internal static void Reset () + { + Debug.WriteLine ($"Themes.Reset()"); + + Themes?.Clear (); + SelectedTheme = string.Empty; + } + + internal static void GetHardCodedDefaults () + { + Debug.WriteLine ($"Themes.GetHardCodedDefaults()"); + var theme = new ThemeScope (); + theme.RetrieveValues (); + + Themes = new Dictionary (StringComparer.InvariantCultureIgnoreCase) { { "Default", theme } }; + SelectedTheme = "Default"; + } + + #region IDictionary + /// + public ICollection Keys => ((IDictionary)Themes!).Keys; + /// + public ICollection Values => ((IDictionary)Themes!).Values; + /// + public int Count => ((ICollection>)Themes!).Count; + /// + public bool IsReadOnly => ((ICollection>)Themes!).IsReadOnly; + /// + public ThemeScope this [string key] { get => ((IDictionary)Themes!) [key]; set => ((IDictionary)Themes!) [key] = value; } + /// + public void Add (string key, ThemeScope value) + { + ((IDictionary)Themes!).Add (key, value); + } + /// + public bool ContainsKey (string key) + { + return ((IDictionary)Themes!).ContainsKey (key); + } + /// + public bool Remove (string key) + { + return ((IDictionary)Themes!).Remove (key); + } + /// + public bool TryGetValue (string key, out ThemeScope value) + { + return ((IDictionary)Themes!).TryGetValue (key, out value!); + } + /// + public void Add (KeyValuePair item) + { + ((ICollection>)Themes!).Add (item); + } + /// + public void Clear () + { + ((ICollection>)Themes!).Clear (); + } + /// + public bool Contains (KeyValuePair item) + { + return ((ICollection>)Themes!).Contains (item); + } + /// + public void CopyTo (KeyValuePair [] array, int arrayIndex) + { + ((ICollection>)Themes!).CopyTo (array, arrayIndex); + } + /// + public bool Remove (KeyValuePair item) + { + return ((ICollection>)Themes!).Remove (item); + } + /// + public IEnumerator> GetEnumerator () + { + return ((IEnumerable>)Themes!).GetEnumerator (); + } + + IEnumerator IEnumerable.GetEnumerator () + { + return ((IEnumerable)Themes!).GetEnumerator (); + } + #endregion + } + } +} \ No newline at end of file diff --git a/Terminal.Gui/Core/Application.cs b/Terminal.Gui/Core/Application.cs index eb609e609..9fb6ecbc9 100644 --- a/Terminal.Gui/Core/Application.cs +++ b/Terminal.Gui/Core/Application.cs @@ -20,6 +20,9 @@ using System.ComponentModel; using System.Globalization; using System.Reflection; using System.IO; +using Terminal.Gui.Configuration; +using System.Text.Json.Serialization; +using static Terminal.Gui.Configuration.ConfigurationManager; namespace Terminal.Gui { @@ -117,6 +120,7 @@ namespace Terminal.Gui { /// The current used in the terminal. /// /// + [SerializableConfigurationProperty (Scope = typeof(SettingsScope))] public static bool HeightAsBuffer { get { if (Driver == null) { @@ -139,6 +143,7 @@ namespace Terminal.Gui { /// /// Alternative key to navigate forwards through views. Ctrl+Tab is the primary key. /// + [SerializableConfigurationProperty (Scope = typeof(SettingsScope)), JsonConverter(typeof(KeyJsonConverter))] public static Key AlternateForwardKey { get => alternateForwardKey; set { @@ -162,6 +167,7 @@ namespace Terminal.Gui { /// /// Alternative key to navigate backwards through views. Shift+Ctrl+Tab is the primary key. /// + [SerializableConfigurationProperty (Scope = typeof(SettingsScope)), JsonConverter (typeof (KeyJsonConverter))] public static Key AlternateBackwardKey { get => alternateBackwardKey; set { @@ -185,6 +191,7 @@ namespace Terminal.Gui { /// /// Gets or sets the key to quit the application. /// + [SerializableConfigurationProperty (Scope = typeof(SettingsScope)), JsonConverter (typeof (KeyJsonConverter))] public static Key QuitKey { get => quitKey; set { @@ -220,6 +227,7 @@ namespace Terminal.Gui { /// /// Disable or enable the mouse. The mouse is enabled by default. /// + [SerializableConfigurationProperty (Scope = typeof (SettingsScope))] public static bool IsMouseDisabled { get; set; } /// @@ -313,6 +321,7 @@ namespace Terminal.Gui { /// /// If , forces the use of the System.Console-based (see ) driver. The default is . /// + [SerializableConfigurationProperty (Scope = typeof (SettingsScope))] public static bool UseSystemConsole { get; set; } = false; // For Unit testing - ignores UseSystemConsole @@ -392,6 +401,14 @@ namespace Terminal.Gui { Driver = driver; } + // Start the process of configuration management. + // Note that we end up calling LoadConfigurationFromAllSources + // mulitlple times. We need to do this because some settings are only + // valid after a Driver is loaded. In this cases we need just + // `Settings` so we can determine which driver to use. + ConfigurationManager.Load (true); + ConfigurationManager.Apply (); + if (Driver == null) { var p = Environment.OSVersion.Platform; if (ForceFakeConsole) { @@ -779,9 +796,7 @@ namespace Terminal.Gui { } if (mouseGrabView != null) { - if (view == null) { - view = mouseGrabView; - } + view ??= mouseGrabView; var newxy = mouseGrabView.ScreenToView (me.X, me.Y); var nme = new MouseEvent () { @@ -1070,6 +1085,8 @@ namespace Terminal.Gui { public static void Shutdown () { ResetState (); + + ConfigurationManager.PrintJsonErrors (); } // Encapsulate all setting of initial state for Application; Having diff --git a/Terminal.Gui/Core/Border.cs b/Terminal.Gui/Core/Border.cs index a86638399..dd91c3877 100644 --- a/Terminal.Gui/Core/Border.cs +++ b/Terminal.Gui/Core/Border.cs @@ -1,6 +1,7 @@ using NStack; using System; using Terminal.Gui.Graphs; +using System.Text.Json.Serialization; namespace Terminal.Gui { /// @@ -34,18 +35,22 @@ namespace Terminal.Gui { /// /// Gets or sets the width, in integers, of the left side of the bounding rectangle. /// + [JsonInclude] public int Left; /// /// Gets or sets the width, in integers, of the upper side of the bounding rectangle. /// + [JsonInclude] public int Top; /// /// Gets or sets the width, in integers, of the right side of the bounding rectangle. /// + [JsonInclude] public int Right; /// /// Gets or sets the width, in integers, of the lower side of the bounding rectangle. /// + [JsonInclude] public int Bottom; /// @@ -330,6 +335,7 @@ namespace Terminal.Gui { /// /// Specifies the for a view. /// + [JsonInclude, JsonConverter (typeof(JsonStringEnumConverter))] public BorderStyle BorderStyle { get => borderStyle; set { @@ -345,6 +351,7 @@ namespace Terminal.Gui { /// /// Gets or sets if a margin frame is drawn around the regardless the /// + [JsonInclude] public bool DrawMarginFrame { get => drawMarginFrame; set { @@ -362,6 +369,7 @@ namespace Terminal.Gui { /// /// Gets or sets the relative of a . /// + [JsonInclude] public Thickness BorderThickness { get => borderThickness; set { @@ -373,6 +381,7 @@ namespace Terminal.Gui { /// /// Gets or sets the that draws the outer border color. /// + [JsonInclude, JsonConverter (typeof (Configuration.ColorJsonConverter))] public Color BorderBrush { get => borderBrush; set { @@ -384,6 +393,7 @@ namespace Terminal.Gui { /// /// Gets or sets the that fills the area between the bounds of a . /// + [JsonInclude, JsonConverter (typeof (Configuration.ColorJsonConverter))] public Color Background { get => background; set { @@ -396,6 +406,7 @@ namespace Terminal.Gui { /// Gets or sets a value that describes the amount of space between a /// and its child element. /// + [JsonInclude] public Thickness Padding { get => padding; set { @@ -407,6 +418,7 @@ namespace Terminal.Gui { /// /// Gets the rendered width of this element. /// + [JsonIgnore] public int ActualWidth { get { var driver = Application.Driver; @@ -420,6 +432,7 @@ namespace Terminal.Gui { /// /// Gets the rendered height of this element. /// + [JsonIgnore] public int ActualHeight { get { var driver = Application.Driver; @@ -434,21 +447,25 @@ namespace Terminal.Gui { /// /// Gets or sets the single child element of a . /// + [JsonIgnore] public View Child { get; set; } /// /// Gets the parent parent if any. /// + [JsonIgnore] public View Parent { get => Child?.SuperView; } /// /// Gets or private sets by the /// + [JsonIgnore] public ToplevelContainer ChildContainer { get; private set; } /// /// Gets or sets the 3D effect around the . /// + [JsonInclude] public bool Effect3D { get => effect3D; set { @@ -460,6 +477,7 @@ namespace Terminal.Gui { /// /// Get or sets the offset start position for the /// + [JsonInclude] public Point Effect3DOffset { get => effect3DOffset; set { @@ -470,8 +488,16 @@ namespace Terminal.Gui { /// /// Gets or sets the color for the /// + [JsonInclude, JsonConverter (typeof (Configuration.AttributeJsonConverter))] public Attribute? Effect3DBrush { - get => effect3DBrush; + get { + if (effect3DBrush == null && effect3D) { + return effect3DBrush = new Attribute (Color.Gray, Color.DarkGray); + } else { + return effect3DBrush; + } + } + set { effect3DBrush = value; OnBorderChanged (); @@ -481,6 +507,7 @@ namespace Terminal.Gui { /// /// The title to be displayed for this view. /// + [JsonIgnore] public ustring Title { get => title; set { @@ -551,7 +578,7 @@ namespace Terminal.Gui { // Draw 3D effects if (Effect3D) { - driver.SetAttribute (GetEffect3DBrush ()); + driver.SetAttribute ((Attribute)Effect3DBrush); var effectBorder = new Rect () { X = borderRect.X + Effect3DOffset.X, @@ -740,7 +767,7 @@ namespace Terminal.Gui { } if (Effect3D) { - driver.SetAttribute (GetEffect3DBrush ()); + driver.SetAttribute ((Attribute)Effect3DBrush); // Draw the upper Effect3D for (int r = frame.Y - drawMarginFrame - sumThickness.Top + effect3DOffset.Y; @@ -895,7 +922,7 @@ namespace Terminal.Gui { } if (Effect3D) { - driver.SetAttribute (GetEffect3DBrush ()); + driver.SetAttribute ((Attribute)Effect3DBrush); // Draw the upper Effect3D for (int r = Math.Max (frame.Y + effect3DOffset.Y, 0); @@ -940,13 +967,6 @@ namespace Terminal.Gui { driver.SetAttribute (savedAttribute); } - private Attribute GetEffect3DBrush () - { - return Effect3DBrush == null - ? new Attribute (Color.Gray, Color.DarkGray) - : (Attribute)Effect3DBrush; - } - private void AddRuneAt (ConsoleDriver driver, int col, int row, Rune ch) { if (col < driver.Cols && row < driver.Rows && col > 0 && driver.Contents [row, col, 2] == 0 diff --git a/Terminal.Gui/Core/ConsoleDriver.cs b/Terminal.Gui/Core/ConsoleDriver.cs index b41e59e17..e0527a6ce 100644 --- a/Terminal.Gui/Core/ConsoleDriver.cs +++ b/Terminal.Gui/Core/ConsoleDriver.cs @@ -8,7 +8,10 @@ using System.ComponentModel; using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; using System.Threading.Tasks; +using Terminal.Gui.Configuration; +using static Terminal.Gui.Configuration.ConfigurationManager; namespace Terminal.Gui { /// @@ -17,6 +20,7 @@ namespace Terminal.Gui { /// /// The value indicates either no-color has been set or the color is invalid. /// + [JsonConverter (typeof (ColorJsonConverter))] public enum Color { /// /// The black color. @@ -170,22 +174,26 @@ namespace Terminal.Gui { /// They encode both the foreground and the background color and are used in the /// class to define color schemes that can be used in an application. /// + [JsonConverter (typeof (AttributeJsonConverter))] public struct Attribute { /// /// The -specific color attribute value. If is /// the value of this property is invalid (typically because the Attribute was created before a driver was loaded) /// and the attribute should be re-made (see ) before it is used. /// + [JsonIgnore (Condition = JsonIgnoreCondition.Always)] public int Value { get; } /// /// The foreground color. /// + [JsonConverter (typeof (Configuration.ColorJsonConverter))] public Color Foreground { get; } /// /// The background color. /// + [JsonConverter (typeof (Configuration.ColorJsonConverter))] public Color Background { get; } /// @@ -303,12 +311,14 @@ namespace Terminal.Gui { /// /// Attributes that have not been initialized must eventually be initialized before being passed to a driver. /// + [JsonIgnore] public bool Initialized { get; internal set; } /// /// Returns if the Attribute is valid (both foreground and background have valid color values). /// /// + [JsonIgnore] public bool HasValidColors { get => (int)Foreground > -1 && (int)Background > -1; } } @@ -320,6 +330,7 @@ namespace Terminal.Gui { /// /// See also: . /// + [JsonConverter (typeof (ColorSchemeJsonConverter))] public class ColorScheme : IEquatable { Attribute _normal = new Attribute (Color.White, Color.Black); Attribute _focus = new Attribute (Color.White, Color.Black); @@ -586,6 +597,8 @@ namespace Terminal.Gui { /// /// Provides the defined s. /// + [SerializableConfigurationProperty (Scope = typeof(ThemeScope), OmitClassName = true)] + [JsonConverter(typeof(DictionaryJsonConverter))] public static Dictionary ColorSchemes { get; private set; } } @@ -1463,6 +1476,10 @@ namespace Terminal.Gui { /// Ensures all s in are correctly /// initialized by the driver. /// + /// + /// This method was previsouly named CreateColors. It was reanmed to InitalizeColorSchemes when + /// was enabled. + /// /// Flag indicating if colors are supported (not used). public void InitalizeColorSchemes (bool supportsColors = true) { @@ -1475,38 +1492,6 @@ namespace Terminal.Gui { return; } - - // Define the default color theme only if the user has not defined one. - - Colors.TopLevel.Normal = MakeColor (Color.BrightGreen, Color.Black); - Colors.TopLevel.Focus = MakeColor (Color.White, Color.Cyan); - Colors.TopLevel.HotNormal = MakeColor (Color.Brown, Color.Black); - Colors.TopLevel.HotFocus = MakeColor (Color.Blue, Color.Cyan); - Colors.TopLevel.Disabled = MakeColor (Color.DarkGray, Color.Black); - - Colors.Base.Normal = MakeColor (Color.White, Color.Blue); - Colors.Base.Focus = MakeColor (Color.Black, Color.Gray); - Colors.Base.HotNormal = MakeColor (Color.BrightCyan, Color.Blue); - Colors.Base.HotFocus = MakeColor (Color.BrightBlue, Color.Gray); - Colors.Base.Disabled = MakeColor (Color.DarkGray, Color.Blue); - - Colors.Dialog.Normal = MakeColor (Color.Black, Color.Gray); - Colors.Dialog.Focus = MakeColor (Color.White, Color.DarkGray); - Colors.Dialog.HotNormal = MakeColor (Color.Blue, Color.Gray); - Colors.Dialog.HotFocus = MakeColor (Color.BrightYellow, Color.DarkGray); - Colors.Dialog.Disabled = MakeColor (Color.Gray, Color.DarkGray); - - Colors.Menu.Normal = MakeColor (Color.White, Color.DarkGray); - Colors.Menu.Focus = MakeColor (Color.White, Color.Black); - Colors.Menu.HotNormal = MakeColor (Color.BrightYellow, Color.DarkGray); - Colors.Menu.HotFocus = MakeColor (Color.BrightYellow, Color.Black); - Colors.Menu.Disabled = MakeColor (Color.Gray, Color.DarkGray); - - Colors.Error.Normal = MakeColor (Color.Red, Color.White); - Colors.Error.Focus = MakeColor (Color.Black, Color.BrightRed); - Colors.Error.HotNormal = MakeColor (Color.Black, Color.White); - Colors.Error.HotFocus = MakeColor (Color.White, Color.BrightRed); - Colors.Error.Disabled = MakeColor (Color.DarkGray, Color.White); } } diff --git a/Terminal.Gui/Core/Window.cs b/Terminal.Gui/Core/Window.cs index 85c8929cc..ff9f3233b 100644 --- a/Terminal.Gui/Core/Window.cs +++ b/Terminal.Gui/Core/Window.cs @@ -11,7 +11,10 @@ using System; using System.Collections; +using System.Text.Json.Serialization; using NStack; +using Terminal.Gui.Configuration; +using static Terminal.Gui.Configuration.ConfigurationManager; namespace Terminal.Gui { /// @@ -170,6 +173,15 @@ namespace Terminal.Gui { Initialize (title, Rect.Empty, padding, border); } + /// + /// The default for . The default is . + /// + /// + /// This property can be set in a Theme to change the default for all s. + /// + ///[SerializableConfigurationProperty (Scope = typeof (ThemeScope)), JsonConverter (typeof (JsonStringEnumConverter))] + public static BorderStyle DefaultBorderStyle { get; set; } = BorderStyle.Single; + void Initialize (ustring title, Rect frame, int padding = 0, Border border = null) { CanFocus = true; @@ -178,7 +190,7 @@ namespace Terminal.Gui { Title = title; if (border == null) { Border = new Border () { - BorderStyle = BorderStyle.Single, + BorderStyle = DefaultBorderStyle, Padding = new Thickness (padding), BorderBrush = ColorScheme.Normal.Background }; @@ -338,7 +350,7 @@ namespace Terminal.Gui { } /// - /// An which allows passing a cancelable new value event. + /// Event arguments for chane events. /// public class TitleEventArgs : EventArgs { /// diff --git a/Terminal.Gui/Resources/config.json b/Terminal.Gui/Resources/config.json new file mode 100644 index 000000000..7bb465f68 --- /dev/null +++ b/Terminal.Gui/Resources/config.json @@ -0,0 +1,445 @@ +{ + // This document specifies the "source of truth" for default values for all Terminal.GUi settings managed by + // ConfigurationManager. It is automatically loaded, and applied, each time Application.Init + // is run (via the ConfiguraitonManager.Reset method). + // + // In otherwords, initial values set in the the codebase are always overwritten by the contents of this + // file. + // + // The Unit Test method "TestConfigurationManagerSaveDefaults" can be used to re-create the base of this file, but + // note that not all values here will be recreated (e.g. the Light and Dark themes and any property initialized + // null. + // + "$schema": "https://gui-cs.github.io/Terminal.Gui/schemas/tui-config-schema.json", + "Application.AlternateBackwardKey": { + "Key": "PageUp", + "Modifiers": [ + "Ctrl" + ] + }, + "Application.AlternateForwardKey": { + "Key": "PageDown", + "Modifiers": [ + "Ctrl" + ] + }, + "Application.HeightAsBuffer": false, + "Application.QuitKey": { + "Key": "Q", + "Modifiers": [ + "Ctrl" + ] + }, + "Application.UseSystemConsole": false, + "Application.IsMouseDisabled": false, + "Theme": "Default", + "Themes": [ + { + "Default": { + "Dialog.DefaultBorder": { + "BorderStyle": "Single", + "DrawMarginFrame": true, + "BorderThickness": { + "Left": 0, + "Top": 0, + "Right": 0, + "Bottom": 0 + }, + "BorderBrush": "Black", + "Background": "Black", + "Padding": { + "Left": 0, + "Top": 0, + "Right": 0, + "Bottom": 0 + }, + "Effect3D": true, + "Effect3DOffset": { + "X": 1, + "Y": 1 + }, + "Effect3DBrush": { + "Foreground": "Gray", + "Background": "DarkGray" + } + }, + "Dialog.DefaultButtonAlignment": "Center", + "FrameView.DefaultBorderStyle": "Single", + "ColorSchemes": [ + { + "TopLevel": { + "Normal": { + "Foreground": "BrightGreen", + "Background": "Black" + }, + "Focus": { + "Foreground": "White", + "Background": "Cyan" + }, + "HotNormal": { + "Foreground": "Brown", + "Background": "Black" + }, + "HotFocus": { + "Foreground": "Blue", + "Background": "Cyan" + }, + "Disabled": { + "Foreground": "DarkGray", + "Background": "Black" + } + } + }, + { + "Base": { + "Normal": { + "Foreground": "White", + "Background": "Blue" + }, + "Focus": { + "Foreground": "Black", + "Background": "Gray" + }, + "HotNormal": { + "Foreground": "BrightCyan", + "Background": "Blue" + }, + "HotFocus": { + "Foreground": "BrightBlue", + "Background": "Gray" + }, + "Disabled": { + "Foreground": "DarkGray", + "Background": "Blue" + } + } + }, + { + "Dialog": { + "Normal": { + "Foreground": "Black", + "Background": "Gray" + }, + "Focus": { + "Foreground": "White", + "Background": "DarkGray" + }, + "HotNormal": { + "Foreground": "Blue", + "Background": "Gray" + }, + "HotFocus": { + "Foreground": "BrightYellow", + "Background": "DarkGray" + }, + "Disabled": { + "Foreground": "Gray", + "Background": "DarkGray" + } + } + }, + { + "Menu": { + "Normal": { + "Foreground": "White", + "Background": "DarkGray" + }, + "Focus": { + "Foreground": "White", + "Background": "Black" + }, + "HotNormal": { + "Foreground": "BrightYellow", + "Background": "DarkGray" + }, + "HotFocus": { + "Foreground": "BrightYellow", + "Background": "Black" + }, + "Disabled": { + "Foreground": "Gray", + "Background": "DarkGray" + } + } + }, + { + "Error": { + "Normal": { + "Foreground": "Red", + "Background": "White" + }, + "Focus": { + "Foreground": "Black", + "Background": "BrightRed" + }, + "HotNormal": { + "Foreground": "Black", + "Background": "White" + }, + "HotFocus": { + "Foreground": "White", + "Background": "BrightRed" + }, + "Disabled": { + "Foreground": "DarkGray", + "Background": "White" + } + } + } + ] + } + }, + { + "Dark": { + "ColorSchemes": [ + { + "TopLevel": { + "Normal": { + "Foreground": "Gray", + "Background": "Black" + }, + "Focus": { + "Foreground": "White", + "Background": "BrightGreen" + }, + "HotNormal": { + "Foreground": "BrightGreen", + "Background": "Black" + }, + "HotFocus": { + "Foreground": "Cyan", + "Background": "Black" + }, + "Disabled": { + "Foreground": "Black", + "Background": "Gray" + } + } + }, + { + "Base": { + "Normal": { + "Foreground": "Gray", + "Background": "Black" + }, + "Focus": { + "Foreground": "White", + "Background": "DarkGray" + }, + "HotNormal": { + "Foreground": "BrightYellow", + "Background": "Black" + }, + "HotFocus": { + "Foreground": "Cyan", + "Background": "Black" + }, + "Disabled": { + "Foreground": "Black", + "Background": "Gray" + } + } + }, + { + "Dialog": { + "Normal": { + "Foreground": "Gray", + "Background": "Black" + }, + "Focus": { + "Foreground": "BrightCyan", + "Background": "Black" + }, + "HotNormal": { + "Foreground": "White", + "Background": "Black" + }, + "HotFocus": { + "Foreground": "White", + "Background": "Black" + }, + "Disabled": { + "Foreground": "Black", + "Background": "Gray" + } + } + }, + { + "Menu": { + "Normal": { + "Foreground": "White", + "Background": "DarkGray" + }, + "Focus": { + "Foreground": "White", + "Background": "Black" + }, + "HotNormal": { + "Foreground": "Gray", + "Background": "DarkGray" + }, + "HotFocus": { + "Foreground": "White", + "Background": "Black" + }, + "Disabled": { + "Foreground": "Gray", + "Background": "Black" + } + } + }, + { + "Error": { + "Normal": { + "Foreground": "BrightYellow", + "Background": "DarkGray" + }, + "Focus": { + "Foreground": "DarkGray", + "Background": "BrightYellow" + }, + "HotNormal": { + "Foreground": "BrightYellow", + "Background": "DarkGray" + }, + "HotFocus": { + "Foreground": "Red", + "Background": "BrightYellow" + }, + "Disabled": { + "Foreground": "DarkGray", + "Background": "Gray" + } + } + } + ] + } + }, + { + "Light": { + "ColorSchemes": [ + { + "TopLevel": { + "Normal": { + "Foreground": "DarkGray", + "Background": "White" + }, + "Focus": { + "Foreground": "Black", + "Background": "White" + }, + "HotNormal": { + "Foreground": "BrightGreen", + "Background": "White" + }, + "HotFocus": { + "Foreground": "Cyan", + "Background": "White" + }, + "Disabled": { + "Foreground": "Gray", + "Background": "White" + } + } + }, + { + "Base": { + "Normal": { + "Foreground": "DarkGray", + "Background": "White" + }, + "Focus": { + "Foreground": "BrightRed", + "Background": "Gray" + }, + "HotNormal": { + "Foreground": "Red", + "Background": "White" + }, + "HotFocus": { + "Foreground": "Cyan", + "Background": "DarkGray" + }, + "Disabled": { + "Foreground": "Black", + "Background": "Gray" + } + } + }, + { + "Dialog": { + "Normal": { + "Foreground": "Black", + "Background": "Gray" + }, + "Focus": { + "Foreground": "Blue", + "Background": "Gray" + }, + "HotNormal": { + "Foreground": "Black", + "Background": "Gray" + }, + "HotFocus": { + "Foreground": "BrightBlue", + "Background": "Gray" + }, + "Disabled": { + "Foreground": "Black", + "Background": "Gray" + } + } + }, + { + "Menu": { + "Normal": { + "Foreground": "DarkGray", + "Background": "White" + }, + "Focus": { + "Foreground": "DarkGray", + "Background": "Gray" + }, + "HotNormal": { + "Foreground": "BrightRed", + "Background": "White" + }, + "HotFocus": { + "Foreground": "BrightRed", + "Background": "Gray" + }, + "Disabled": { + "Foreground": "Gray", + "Background": "White" + } + } + }, + { + "Error": { + "Normal": { + "Foreground": "BrightYellow", + "Background": "DarkGray" + }, + "Focus": { + "Foreground": "DarkGray", + "Background": "BrightYellow" + }, + "HotNormal": { + "Foreground": "BrightYellow", + "Background": "DarkGray" + }, + "HotFocus": { + "Foreground": "Red", + "Background": "BrightYellow" + }, + "Disabled": { + "Foreground": "DarkGray", + "Background": "Gray" + } + } + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Terminal.Gui/Terminal.Gui.csproj b/Terminal.Gui/Terminal.Gui.csproj index e92806346..b44caba37 100644 --- a/Terminal.Gui/Terminal.Gui.csproj +++ b/Terminal.Gui/Terminal.Gui.csproj @@ -15,6 +15,12 @@ 1.0 1.0 + + + + + + @@ -53,11 +59,13 @@ + net472;netstandard2.0;net6.0 Terminal.Gui Terminal.Gui + 8 bin\Release\Terminal.Gui.xml true @@ -83,4 +91,5 @@ See: https://github.com/gui-cs/Terminal.Gui/releases + \ No newline at end of file diff --git a/Terminal.Gui/Types/Point.cs b/Terminal.Gui/Types/Point.cs index a3263feed..ff4f07fd8 100644 --- a/Terminal.Gui/Types/Point.cs +++ b/Terminal.Gui/Types/Point.cs @@ -21,11 +21,13 @@ namespace Terminal.Gui /// /// Gets or sets the x-coordinate of this Point. /// + [System.Text.Json.Serialization.JsonInclude] public int X; /// /// Gets or sets the y-coordinate of this Point. /// + [System.Text.Json.Serialization.JsonInclude] public int Y; // ----------------------- @@ -159,6 +161,7 @@ namespace Terminal.Gui /// /// Indicates if both X and Y are zero. /// + [System.Text.Json.Serialization.JsonIgnore] public bool IsEmpty { get { return ((X == 0) && (Y == 0)); diff --git a/Terminal.Gui/Views/FrameView.cs b/Terminal.Gui/Views/FrameView.cs index f7e4549fe..eb63f2f26 100644 --- a/Terminal.Gui/Views/FrameView.cs +++ b/Terminal.Gui/Views/FrameView.cs @@ -11,15 +11,51 @@ using System; using System.Linq; +using System.Text.Json.Serialization; using NStack; using Terminal.Gui.Graphs; +using static Terminal.Gui.Configuration.ConfigurationManager; namespace Terminal.Gui { + /// /// The FrameView is a container frame that draws a frame around the contents. It is similar to /// a GroupBox in Windows. /// public class FrameView : View { + + //internal class FrameViewConfig : Configuration.Config { + + // /// + // /// + // /// + // /// + // [JsonConverter (typeof (JsonStringEnumConverter))] + // public BorderStyle? DefaultBorderStyle { get; set; } + + // public override void Apply () + // { + // if (DefaultBorderStyle.HasValue) { + // FrameView.DefaultBorderStyle = DefaultBorderStyle.Value; + // } + // } + + // public override void CopyUpdatedProperitesFrom (FrameViewConfig changedConfig) + // { + // if (changedConfig.DefaultBorderStyle.HasValue) { + // DefaultBorderStyle = changedConfig.DefaultBorderStyle; + // } + // } + + // public override void GetHardCodedDefaults () + // { + // DefaultBorderStyle = FrameView.DefaultBorderStyle; + // } + //} + + //[Configuration.ConfigProperty] + //internal static FrameViewConfig Config { get; set; } = new FrameViewConfig (); + View contentView; ustring title; @@ -108,13 +144,22 @@ namespace Terminal.Gui { /// public FrameView () : this (title: string.Empty) { } + /// + /// The default for . The default is . + /// + /// + /// This property can be set in a Theme to change the default for all s. + /// + [SerializableConfigurationProperty (Scope = typeof (ThemeScope)), JsonConverter (typeof (JsonStringEnumConverter))] + public static BorderStyle DefaultBorderStyle { get; set; } = BorderStyle.Single; + void Initialize (Rect frame, ustring title, View [] views = null, Border border = null) { if (title == null) title = ustring.Empty; this.Title = title; if (border == null) { Border = new Border () { - BorderStyle = BorderStyle.Single + BorderStyle = DefaultBorderStyle }; } else { Border = border; diff --git a/Terminal.Gui/Views/ListView.cs b/Terminal.Gui/Views/ListView.cs index 67d0e88c4..498c494fd 100644 --- a/Terminal.Gui/Views/ListView.cs +++ b/Terminal.Gui/Views/ListView.cs @@ -76,8 +76,8 @@ namespace Terminal.Gui { /// /// /// can display any object that implements the interface. - /// values are converted into values before rendering, and other values are - /// converted into by calling and then converting to . + /// values are converted into values before rendering, and other values are + /// converted into by calling and then converting to . /// /// /// To change the contents of the ListView, set the property (when @@ -815,7 +815,10 @@ namespace Terminal.Gui { } } - /// + /// + /// Provides a default implementation of that renders + /// items using . + /// public class ListWrapper : IListDataSource { IList src; BitArray marks; diff --git a/Terminal.Gui/Views/StatusBar.cs b/Terminal.Gui/Views/StatusBar.cs index 5d3ec65f1..df8570e2a 100644 --- a/Terminal.Gui/Views/StatusBar.cs +++ b/Terminal.Gui/Views/StatusBar.cs @@ -37,7 +37,7 @@ namespace Terminal.Gui { /// /// Gets the global shortcut to invoke the action on the menu. /// - public Key Shortcut { get; } + public Key Shortcut { get; set; } /// /// Gets or sets the title. diff --git a/Terminal.Gui/Views/TextField.cs b/Terminal.Gui/Views/TextField.cs index f8c4e23da..fecc2cb42 100644 --- a/Terminal.Gui/Views/TextField.cs +++ b/Terminal.Gui/Views/TextField.cs @@ -51,7 +51,7 @@ namespace Terminal.Gui { /// This event is raised when the changes. /// /// - /// The passed is a containing the old value. + /// The passed is a containing the old value. /// public event Action TextChanged; diff --git a/Terminal.Gui/Windows/Dialog.cs b/Terminal.Gui/Windows/Dialog.cs index e4b5f3361..fa88257a0 100644 --- a/Terminal.Gui/Windows/Dialog.cs +++ b/Terminal.Gui/Windows/Dialog.cs @@ -7,7 +7,10 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; using NStack; +using Terminal.Gui.Configuration; +using static Terminal.Gui.Configuration.ConfigurationManager; namespace Terminal.Gui { /// @@ -20,6 +23,26 @@ namespace Terminal.Gui { /// or buttons added to the dialog calls . /// public class Dialog : Window { + /// + /// The default for . + /// + /// + /// This property can be set in a Theme. + /// + [SerializableConfigurationProperty (Scope = typeof (ThemeScope)), JsonConverter (typeof (JsonStringEnumConverter))] + public static ButtonAlignments DefaultButtonAlignment { get; set; } = ButtonAlignments.Center; + + /// + /// Defines the default border styling for . Can be configured via . + /// + [SerializableConfigurationProperty (Scope = typeof (ThemeScope))] + public static Border DefaultBorder { get; set; } = new Border () { + BorderStyle = BorderStyle.Single, + DrawMarginFrame = false, + Effect3D = true, + Effect3DOffset = new Point (1, 1), + }; + internal List