diff --git a/examples/ted/EditorSettings.cs b/examples/ted/EditorSettings.cs index 71b7a806..04d85a1f 100644 --- a/examples/ted/EditorSettings.cs +++ b/examples/ted/EditorSettings.cs @@ -1,54 +1,123 @@ +using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; +using Microsoft.Extensions.Configuration; using Terminal.Gui.App; using Terminal.Gui.Configuration; namespace Ted; +#pragma warning disable CS0618 // Keep legacy CM attributes until Terminal.Gui fully removes CM. + /// -/// ted's persisted editor settings. These are real Terminal.Gui configuration properties -/// (, ), so -/// is the single authority for reading them: enabling -/// CM (see Program.cs) loads ~/.tui/ted.config.json and applies the values to these -/// static properties. ted does no parsing of its own. +/// ted's persisted editor settings. Microsoft.Extensions.Configuration is the primary read path: +/// startup loads ~/.tui/ted.config.json and applies the values to these static properties +/// before is constructed. Legacy CM attributes are retained only so older +/// Terminal.Gui builds can still apply the previous format. /// -/// is hand-rolled only because Terminal.Gui exposes no API for -/// writing a user config file. It emits the exact shape CM reads: app-defined -/// () properties live nested under a top-level -/// "AppSettings" object, keyed DeclaringType.PropertyName. Other top-level keys -/// a user may have added (e.g. "Theme") are preserved; JSONC comments are not. -/// Any legacy flat root-level "EditorSettings.*" keys from the pre-CM format are -/// dropped on save (migration). +/// writes the MEC-native shape: +/// "EditorSettings": { "WordWrap": true }. Other top-level keys a user may have added +/// are preserved; JSONC comments are not. Legacy flat root-level +/// "EditorSettings.*" keys and old CM "AppSettings" entries are dropped on save +/// once migrated. /// /// internal static class EditorSettings { + internal const string SectionName = "EditorSettings"; + [ConfigurationProperty (Scope = typeof (AppSettingsScope))] - public static bool LineNumbers { get; set; } = true; + public static bool LineNumbers + { + get => Defaults.LineNumbers; + set => Defaults.LineNumbers = value; + } [ConfigurationProperty (Scope = typeof (AppSettingsScope))] - public static bool FoldIndicators { get; set; } = true; + public static bool FoldIndicators + { + get => Defaults.FoldIndicators; + set => Defaults.FoldIndicators = value; + } [ConfigurationProperty (Scope = typeof (AppSettingsScope))] - public static bool WordWrap { get; set; } + public static bool WordWrap + { + get => Defaults.WordWrap; + set => Defaults.WordWrap = value; + } [ConfigurationProperty (Scope = typeof (AppSettingsScope))] - public static bool ShowTabs { get; set; } + public static bool ShowTabs + { + get => Defaults.ShowTabs; + set => Defaults.ShowTabs = value; + } [ConfigurationProperty (Scope = typeof (AppSettingsScope))] - public static int IndentSize { get; set; } = 4; + public static int IndentSize + { + get => Defaults.IndentSize; + set => Defaults.IndentSize = value; + } [ConfigurationProperty (Scope = typeof (AppSettingsScope))] - public static bool ConvertTabsToSpaces { get; set; } = true; + public static bool ConvertTabsToSpaces + { + get => Defaults.ConvertTabsToSpaces; + set => Defaults.ConvertTabsToSpaces = value; + } [ConfigurationProperty (Scope = typeof (AppSettingsScope))] - public static bool AutoIndent { get; set; } = true; + public static bool AutoIndent + { + get => Defaults.AutoIndent; + set => Defaults.AutoIndent = value; + } [ConfigurationProperty (Scope = typeof (AppSettingsScope))] - public static bool Scrollbars { get; set; } = true; + public static bool Scrollbars + { + get => Defaults.Scrollbars; + set => Defaults.Scrollbars = value; + } [ConfigurationProperty (Scope = typeof (AppSettingsScope))] - public static bool AutoComplete { get; set; } + public static bool AutoComplete + { + get => Defaults.AutoComplete; + set => Defaults.AutoComplete = value; + } + + internal static EditorSettingsValues Defaults { get; set; } = new (); + + internal static IConfiguration BuildConfiguration (string path) + { + return new ConfigurationBuilder () + .AddJsonFile (path, true, false) + .Build (); + } + + internal static void Load (string path) + { + Apply (BuildConfiguration (path)); + } + + internal static void Apply (IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull (configuration); + + EditorSettingsValues settings = new (); + ApplyLegacyDottedKeys (configuration, settings); + ApplyLegacyDottedKeys (configuration.GetSection ("AppSettings"), settings); + configuration.GetSection (SectionName).Bind (settings); + Defaults = settings; + } + + internal static void ResetDefaults () + { + Defaults = new EditorSettingsValues (); + } internal static void Save (string path) { @@ -56,37 +125,30 @@ internal static void Save (string path) { JsonObject root = ReadRoot (path); - // Migration: drop legacy flat root-level "EditorSettings.*" keys (the pre-CM - // hand-rolled format). CM reads ted's settings only from the "AppSettings" object. - foreach (var legacyKey in root - .Where (kvp => kvp.Key.StartsWith ("EditorSettings.", StringComparison.Ordinal)) - .Select (kvp => kvp.Key) - .ToList ()) - { - root.Remove (legacyKey); - } - - JsonObject appSettings; + RemoveLegacyDottedKeys (root); - if (root["AppSettings"] is JsonObject existing) + if (root["AppSettings"] is JsonObject appSettings) { - appSettings = existing; - } - else - { - appSettings = new JsonObject (); - root["AppSettings"] = appSettings; + RemoveLegacyDottedKeys (appSettings); + + if (appSettings.Count == 0) + { + root.Remove ("AppSettings"); + } } - appSettings["EditorSettings.LineNumbers"] = LineNumbers; - appSettings["EditorSettings.FoldIndicators"] = FoldIndicators; - appSettings["EditorSettings.WordWrap"] = WordWrap; - appSettings["EditorSettings.ShowTabs"] = ShowTabs; - appSettings["EditorSettings.IndentSize"] = IndentSize; - appSettings["EditorSettings.ConvertTabsToSpaces"] = ConvertTabsToSpaces; - appSettings["EditorSettings.AutoIndent"] = AutoIndent; - appSettings["EditorSettings.AutoComplete"] = AutoComplete; - appSettings["EditorSettings.Scrollbars"] = Scrollbars; + root[SectionName] = new JsonObject + { + [nameof (LineNumbers)] = LineNumbers, + [nameof (FoldIndicators)] = FoldIndicators, + [nameof (WordWrap)] = WordWrap, + [nameof (ShowTabs)] = ShowTabs, + [nameof (IndentSize)] = IndentSize, + [nameof (ConvertTabsToSpaces)] = ConvertTabsToSpaces, + [nameof (AutoIndent)] = AutoIndent, + [nameof (AutoComplete)] = AutoComplete, + [nameof (Scrollbars)] = Scrollbars + }; var directory = Path.GetDirectoryName (path); @@ -137,4 +199,89 @@ private static JsonObject ReadRoot (string path) return node as JsonObject ?? new JsonObject (); } + + private static void ApplyLegacyDottedKeys (IConfiguration configuration, EditorSettingsValues settings) + { + ApplyLegacyBoolean (configuration, nameof (LineNumbers), value => settings.LineNumbers = value); + ApplyLegacyBoolean (configuration, nameof (FoldIndicators), value => settings.FoldIndicators = value); + ApplyLegacyBoolean (configuration, nameof (WordWrap), value => settings.WordWrap = value); + ApplyLegacyBoolean (configuration, nameof (ShowTabs), value => settings.ShowTabs = value); + ApplyLegacyInt32 (configuration, nameof (IndentSize), value => settings.IndentSize = value); + ApplyLegacyBoolean (configuration, nameof (ConvertTabsToSpaces), value => settings.ConvertTabsToSpaces = value); + ApplyLegacyBoolean (configuration, nameof (AutoIndent), value => settings.AutoIndent = value); + ApplyLegacyBoolean (configuration, nameof (Scrollbars), value => settings.Scrollbars = value); + ApplyLegacyBoolean (configuration, nameof (AutoComplete), value => settings.AutoComplete = value); + } + + private static void ApplyLegacyBoolean (IConfiguration configuration, string propertyName, Action apply) + { + var value = configuration[$"{SectionName}.{propertyName}"]; + + if (value is null) + { + return; + } + + if (bool.TryParse (value, out var parsed)) + { + apply (parsed); + + return; + } + + Logging.Error ($"EditorSettings.Load: invalid legacy {SectionName}.{propertyName} value '{value}'."); + } + + private static void ApplyLegacyInt32 (IConfiguration configuration, string propertyName, Action apply) + { + var value = configuration[$"{SectionName}.{propertyName}"]; + + if (value is null) + { + return; + } + + if (int.TryParse (value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) + { + apply (parsed); + + return; + } + + Logging.Error ($"EditorSettings.Load: invalid legacy {SectionName}.{propertyName} value '{value}'."); + } + + private static void RemoveLegacyDottedKeys (JsonObject json) + { + foreach (var legacyKey in json + .Where (kvp => kvp.Key.StartsWith ($"{SectionName}.", StringComparison.Ordinal)) + .Select (kvp => kvp.Key) + .ToList ()) + { + json.Remove (legacyKey); + } + } + + internal sealed class EditorSettingsValues + { + public bool LineNumbers { get; set; } = true; + + public bool FoldIndicators { get; set; } = true; + + public bool WordWrap { get; set; } + + public bool ShowTabs { get; set; } + + public int IndentSize { get; set; } = 4; + + public bool ConvertTabsToSpaces { get; set; } = true; + + public bool AutoIndent { get; set; } = true; + + public bool Scrollbars { get; set; } = true; + + public bool AutoComplete { get; set; } + } } + +#pragma warning restore CS0618 diff --git a/examples/ted/Program.cs b/examples/ted/Program.cs index c7130b0c..124ea959 100644 --- a/examples/ted/Program.cs +++ b/examples/ted/Program.cs @@ -2,17 +2,14 @@ using Ted; using Terminal.Gui.App; -using Terminal.Gui.Configuration; // ReSharper disable AccessToDisposedClosure Hosting.ConfigureLogging (); Hosting.EnableTracing (); -// ConfigurationManager is the single authority for reading settings: Enable (All) loads -// ~/.tui/ted.config.json (ConfigLocations.AppHome) and applies the values to the -// EditorSettings [ConfigurationProperty] statics before TedApp is constructed. -ConfigurationManager.Enable (ConfigLocations.All); +// Prefer Terminal.Gui's MEC builder when present; fall back to CM for released Terminal.Gui. +TerminalGuiConfigurationBootstrap.Apply (); using IApplication app = Application.Create (); diff --git a/examples/ted/TerminalGuiConfigurationBootstrap.cs b/examples/ted/TerminalGuiConfigurationBootstrap.cs new file mode 100644 index 00000000..235de621 --- /dev/null +++ b/examples/ted/TerminalGuiConfigurationBootstrap.cs @@ -0,0 +1,15 @@ +using Terminal.Gui.Configuration; +using Terminal.Gui.Editor.Configuration; + +namespace Ted; + +internal static class TerminalGuiConfigurationBootstrap +{ + internal static void Apply () + { + TuiConfigurationBuilder builder = new ("ted"); + builder.ApplyToStaticFacades (); + EditorConfiguration.Apply (builder.Configuration); + EditorSettings.Apply (builder.Configuration); + } +} diff --git a/examples/ted/ted.csproj b/examples/ted/ted.csproj index 06d5c66b..4016bb0e 100644 --- a/examples/ted/ted.csproj +++ b/examples/ted/ted.csproj @@ -26,6 +26,8 @@ + + diff --git a/src/Terminal.Gui.Editor/Configuration/EditorConfiguration.cs b/src/Terminal.Gui.Editor/Configuration/EditorConfiguration.cs new file mode 100644 index 00000000..9501c543 --- /dev/null +++ b/src/Terminal.Gui.Editor/Configuration/EditorConfiguration.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.Configuration; +using Terminal.Gui.Input; + +namespace Terminal.Gui.Editor.Configuration; + +/// +/// Applies Microsoft.Extensions.Configuration values to . +/// +public static class EditorConfiguration +{ + private const string LegacyDefaultKeyBindingsSectionName = "Terminal.Gui.Editor.Editor.DefaultKeyBindings"; + + /// The default configuration section for settings. + public const string SectionName = "Editor"; + + /// + /// Applies editor settings from to + /// . + /// + /// The configuration root to read. + /// The section containing editor settings. + public static void Apply (IConfiguration configuration, string sectionName = SectionName) + { + ArgumentNullException.ThrowIfNull (configuration); + ArgumentException.ThrowIfNullOrWhiteSpace (sectionName); + + EditorSettings settings = new (); + ApplyDefaultKeyBindings (configuration.GetSection (LegacyDefaultKeyBindingsSectionName), settings); + ApplyDefaultKeyBindings ( + configuration.GetSection (sectionName).GetSection (nameof (EditorSettings.DefaultKeyBindings)), settings); + EditorSettings.Defaults = settings; + } + + private static void ApplyDefaultKeyBindings (IConfigurationSection section, EditorSettings settings) + { + if (!section.Exists ()) + { + return; + } + + settings.DefaultKeyBindings ??= []; + + foreach (IConfigurationSection commandSection in section.GetChildren ()) + { + if (!Enum.TryParse (commandSection.Key, true, out Command command)) + { + throw new FormatException ($"Unknown editor command in configuration: '{commandSection.Key}'."); + } + + settings.DefaultKeyBindings[command] = ReadPlatformKeyBinding (commandSection); + } + } + + private static PlatformKeyBinding ReadPlatformKeyBinding (IConfigurationSection section) + { + return new PlatformKeyBinding + { + All = ReadKeys (section.GetSection (nameof (PlatformKeyBinding.All))), + Windows = ReadKeys (section.GetSection (nameof (PlatformKeyBinding.Windows))), + Linux = ReadKeys (section.GetSection (nameof (PlatformKeyBinding.Linux))), + Macos = ReadKeys (section.GetSection (nameof (PlatformKeyBinding.Macos))) + }; + } + + private static Key[]? ReadKeys (IConfigurationSection section) + { + if (!section.Exists ()) + { + return null; + } + + List keys = []; + + foreach (IConfigurationSection keySection in section.GetChildren ()) + { + var keyText = keySection.Value; + + if (string.IsNullOrWhiteSpace (keyText)) + { + throw new FormatException ($"Empty key in configuration section '{section.Path}'."); + } + + if (!Key.TryParse (keyText, out Key key)) + { + throw new FormatException ($"Invalid key '{keyText}' in configuration section '{section.Path}'."); + } + + keys.Add (key); + } + + return keys.Count == 0 ? null : keys.ToArray (); + } +} diff --git a/src/Terminal.Gui.Editor/Configuration/EditorKeyBindingDefaults.cs b/src/Terminal.Gui.Editor/Configuration/EditorKeyBindingDefaults.cs new file mode 100644 index 00000000..91cbbaf4 --- /dev/null +++ b/src/Terminal.Gui.Editor/Configuration/EditorKeyBindingDefaults.cs @@ -0,0 +1,44 @@ +using Terminal.Gui.Input; + +namespace Terminal.Gui.Editor.Configuration; + +internal static class EditorKeyBindingDefaults +{ + internal static Dictionary Create () + { + return new Dictionary + { + [Command.Start] = Bind.All (Key.Home.WithCtrl), + [Command.End] = Bind.All (Key.End.WithCtrl), + [Command.NewLine] = Bind.All (Key.Enter), + [Command.DeleteCharLeft] = Bind.All (Key.Backspace), + [Command.DeleteCharRight] = Bind.All (Key.Delete), + [Command.Undo] = Bind.All (Key.Z.WithCtrl), + [Command.Redo] = Bind.All (Key.Y.WithCtrl, Key.Z.WithCtrl.WithShift), + [Command.Cut] = Bind.All (Key.X.WithCtrl), + [Command.Copy] = Bind.All (Key.C.WithCtrl), + [Command.Paste] = Bind.All (Key.V.WithCtrl), + [Command.Collapse] = Bind.All (Key.M.WithCtrl), + [Command.InsertTab] = Bind.All (Key.Tab), + [Command.Unindent] = Bind.All (Key.Tab.WithShift), + [Command.FindNext] = Bind.All (Key.F3), + [Command.FindPrevious] = Bind.All (Key.F3.WithShift), + [Command.Find] = Bind.All (Key.F.WithCtrl), + [Command.Replace] = Bind.All (Key.H.WithCtrl), + + // Vertical multi-caret — VS Code parity (Ctrl+Alt+Up/Down). A PlatformKeyBinding, so a + // user whose terminal/WM grabs the chord overrides it via Editor:DefaultKeyBindings config; + // no editor-specific fallback chord. macOS uses the same chord pending real-terminal + // validation (specs/decisions.md DEC-006). + [Command.InsertCaretAbove] = Bind.All (Key.CursorUp.WithCtrl.WithAlt), + [Command.InsertCaretBelow] = Bind.All (Key.CursorDown.WithCtrl.WithAlt), + [Command.WordLeft] = Bind.All (Key.CursorLeft.WithCtrl), + [Command.WordRight] = Bind.All (Key.CursorRight.WithCtrl), + [Command.WordLeftExtend] = Bind.All (Key.CursorLeft.WithCtrl.WithShift), + [Command.WordRightExtend] = Bind.All (Key.CursorRight.WithCtrl.WithShift), + [Command.KillWordLeft] = Bind.All (Key.Backspace.WithCtrl), + [Command.KillWordRight] = Bind.All (Key.Delete.WithCtrl), + [Command.ToggleOverwrite] = Bind.All (Key.InsertChar) + }; + } +} diff --git a/src/Terminal.Gui.Editor/Configuration/EditorSettings.cs b/src/Terminal.Gui.Editor/Configuration/EditorSettings.cs new file mode 100644 index 00000000..6c088170 --- /dev/null +++ b/src/Terminal.Gui.Editor/Configuration/EditorSettings.cs @@ -0,0 +1,22 @@ +using Terminal.Gui.Input; + +namespace Terminal.Gui.Editor.Configuration; + +/// +/// MEC-friendly settings POCO for defaults. +/// +public class EditorSettings +{ + /// + /// Gets or sets editor-specific default key bindings layered on top of + /// . + /// + public Dictionary? DefaultKeyBindings { get; set; } = + EditorKeyBindingDefaults.Create (); + + /// + /// The static facade instance. Applications update this from Microsoft.Extensions.Configuration + /// before constructing instances. + /// + public static EditorSettings Defaults { get; set; } = new (); +} diff --git a/src/Terminal.Gui.Editor/Editor.Commands.cs b/src/Terminal.Gui.Editor/Editor.Commands.cs index 03f02078..91f2b9df 100644 --- a/src/Terminal.Gui.Editor/Editor.Commands.cs +++ b/src/Terminal.Gui.Editor/Editor.Commands.cs @@ -1,6 +1,6 @@ using System.Globalization; using Terminal.Gui.App; -using Terminal.Gui.Configuration; +using Terminal.Gui.Editor.Configuration; using Terminal.Gui.Editor.Document; using Terminal.Gui.Editor.Document.Folding; using Terminal.Gui.Input; @@ -21,41 +21,11 @@ public partial class Editor /// Process-wide static. Do not mutate from parallel tests — see Terminal.Gui's same convention /// on . /// - [ConfigurationProperty (Scope = typeof (SettingsScope))] - public new static Dictionary? DefaultKeyBindings { get; set; } = new () + public new static Dictionary? DefaultKeyBindings { - [Command.Start] = Bind.All (Key.Home.WithCtrl), - [Command.End] = Bind.All (Key.End.WithCtrl), - [Command.NewLine] = Bind.All (Key.Enter), - [Command.DeleteCharLeft] = Bind.All (Key.Backspace), - [Command.DeleteCharRight] = Bind.All (Key.Delete), - [Command.Undo] = Bind.All (Key.Z.WithCtrl), - [Command.Redo] = Bind.All (Key.Y.WithCtrl, Key.Z.WithCtrl.WithShift), - [Command.Cut] = Bind.All (Key.X.WithCtrl), - [Command.Copy] = Bind.All (Key.C.WithCtrl), - [Command.Paste] = Bind.All (Key.V.WithCtrl), - [Command.Collapse] = Bind.All (Key.M.WithCtrl), - [Command.InsertTab] = Bind.All (Key.Tab), - [Command.Unindent] = Bind.All (Key.Tab.WithShift), - [Command.FindNext] = Bind.All (Key.F3), - [Command.FindPrevious] = Bind.All (Key.F3.WithShift), - [Command.Find] = Bind.All (Key.F.WithCtrl), - [Command.Replace] = Bind.All (Key.H.WithCtrl), - - // Vertical multi-caret — VS Code parity (Ctrl+Alt+Up/Down). A PlatformKeyBinding, so a - // user whose terminal/WM grabs the chord overrides it via View.ViewKeyBindings config; - // no editor-specific fallback chord. macOS uses the same chord pending real-terminal - // validation (specs/decisions.md DEC-006). - [Command.InsertCaretAbove] = Bind.All (Key.CursorUp.WithCtrl.WithAlt), - [Command.InsertCaretBelow] = Bind.All (Key.CursorDown.WithCtrl.WithAlt), - [Command.WordLeft] = Bind.All (Key.CursorLeft.WithCtrl), - [Command.WordRight] = Bind.All (Key.CursorRight.WithCtrl), - [Command.WordLeftExtend] = Bind.All (Key.CursorLeft.WithCtrl.WithShift), - [Command.WordRightExtend] = Bind.All (Key.CursorRight.WithCtrl.WithShift), - [Command.KillWordLeft] = Bind.All (Key.Backspace.WithCtrl), - [Command.KillWordRight] = Bind.All (Key.Delete.WithCtrl), - [Command.ToggleOverwrite] = Bind.All (Key.InsertChar) - }; + get => EditorSettings.Defaults.DefaultKeyBindings; + set => EditorSettings.Defaults.DefaultKeyBindings = value; + } private void CreateCommandsAndBindings () { diff --git a/src/Terminal.Gui.Editor/Terminal.Gui.Editor.csproj b/src/Terminal.Gui.Editor/Terminal.Gui.Editor.csproj index e69e06f1..4b3ad1e3 100644 --- a/src/Terminal.Gui.Editor/Terminal.Gui.Editor.csproj +++ b/src/Terminal.Gui.Editor/Terminal.Gui.Editor.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/Terminal.Gui.Editor.ConfigTests/TedConfigurationManagerTests.cs b/tests/Terminal.Gui.Editor.ConfigTests/TedConfigurationManagerTests.cs index a6cda47e..7708f214 100644 --- a/tests/Terminal.Gui.Editor.ConfigTests/TedConfigurationManagerTests.cs +++ b/tests/Terminal.Gui.Editor.ConfigTests/TedConfigurationManagerTests.cs @@ -3,6 +3,8 @@ using Ted; using Terminal.Gui.Configuration; using Xunit; + +#pragma warning disable CS0618 // This project intentionally quarantines legacy ConfigurationManager coverage. using static Terminal.Gui.Configuration.ConfigurationManager; namespace Terminal.Gui.Editor.ConfigTests; @@ -67,15 +69,9 @@ public void ConfigurationManager_Applies_AppSettings_To_TedApp () Disable (true); // Restore declared defaults so a later CM test in this assembly starts clean. - EditorSettings.LineNumbers = true; - EditorSettings.FoldIndicators = true; - EditorSettings.WordWrap = false; - EditorSettings.ShowTabs = false; - EditorSettings.IndentSize = 4; - EditorSettings.ConvertTabsToSpaces = true; - EditorSettings.AutoIndent = true; - EditorSettings.Scrollbars = true; - EditorSettings.AutoComplete = false; + EditorSettings.ResetDefaults (); } } + +#pragma warning restore CS0618 } diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorKeyBindingIntegrationTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorKeyBindingIntegrationTests.cs index 910ae9eb..2383ef74 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorKeyBindingIntegrationTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorKeyBindingIntegrationTests.cs @@ -1,18 +1,19 @@ // CoPilot - claude-sonnet-4-5 -using Terminal.Gui.Configuration; +using Microsoft.Extensions.Configuration; +using Terminal.Gui.Editor.Configuration; using Terminal.Gui.Editor.IntegrationTests.Testing; using Terminal.Gui.Input; using Terminal.Gui.Testing; -using Terminal.Gui.ViewBase; using Xunit; +using MecEditorSettings = Terminal.Gui.Editor.Configuration.EditorSettings; namespace Terminal.Gui.Editor.IntegrationTests; /// /// Serialisation guard: mutates -/// and , both -/// process-wide statics that Terminal.Gui reads during view construction. Using +/// , a process-wide static that Terminal.Gui reads during view construction. +/// Using /// DisableParallelization = true serialises this collection against every other collection. /// [CollectionDefinition (nameof (KeyBindingIntegrationCollection), DisableParallelization = true)] @@ -31,8 +32,8 @@ public sealed class KeyBindingIntegrationCollection; /// /// /// -/// Loading a JSON profile via -/// using the "View.ViewKeyBindings" key. +/// Loading a Microsoft.Extensions.Configuration profile using the +/// "Editor:DefaultKeyBindings" section. /// /// /// @@ -78,15 +79,15 @@ public async Task Override_DefaultKeyBindings_CustomUndoKey_ActuallyUndoes () } /// - /// Proves that loading a JSON profile via - /// with the "View.ViewKeyBindings" key causes the configured custom key to trigger + /// Proves that loading a Microsoft.Extensions.Configuration profile with the + /// "Editor:DefaultKeyBindings" section causes the configured custom key to trigger /// the correct command in a live . /// /// The test JSON mirrors the structure from the issue: /// /// { - /// "View.ViewKeyBindings": { - /// "Editor": { + /// "Editor": { + /// "DefaultKeyBindings": { /// "Undo": { "All": ["Ctrl+U"] } /// } /// } @@ -95,26 +96,20 @@ public async Task Override_DefaultKeyBindings_CustomUndoKey_ActuallyUndoes () /// /// [Fact] - public async Task ConfigurationManager_RuntimeConfig_CustomUndoKey_ActuallyUndoes () + public async Task MecConfiguration_CustomUndoKey_ActuallyUndoes () { - Dictionary>? originalViewKeyBindings = View.ViewKeyBindings; - var originalRuntimeConfig = ConfigurationManager.RuntimeConfig; - var wasEnabled = ConfigurationManager.IsEnabled; + MecEditorSettings original = MecEditorSettings.Defaults; try { - const string json = """ - { - "View.ViewKeyBindings": { - "Editor": { - "Undo": { "All": ["Ctrl+U"] } - } - } - } - """; - - ConfigurationManager.RuntimeConfig = json; - ConfigurationManager.Enable (ConfigLocations.Runtime); + IConfiguration configuration = new ConfigurationBuilder () + .AddInMemoryCollection (new Dictionary + { + ["Editor:DefaultKeyBindings:Undo:All:0"] = "Ctrl+U" + }) + .Build (); + + EditorConfiguration.Apply (configuration); await using AppFixture fx = new (() => new EditorTestHost ("abc")); fx.Top.Editor.SetFocus (); @@ -129,13 +124,7 @@ public async Task ConfigurationManager_RuntimeConfig_CustomUndoKey_ActuallyUndoe } finally { - View.ViewKeyBindings = originalViewKeyBindings; - ConfigurationManager.RuntimeConfig = originalRuntimeConfig; - - if (!wasEnabled) - { - ConfigurationManager.Disable (true); - } + MecEditorSettings.Defaults = original; } } } diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/TedSettingsPersistenceTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/TedSettingsPersistenceTests.cs index 7e2ee0e4..f8146ad6 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/TedSettingsPersistenceTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/TedSettingsPersistenceTests.cs @@ -28,7 +28,8 @@ public void SaveViewSettings_Creates_ConfigFile_And_Persists_IndentSize () InvokeSaveViewSettings (app); Assert.True (File.Exists (scope.ConfigPath)); - Assert.Contains ("\"EditorSettings.IndentSize\": 7", File.ReadAllText (scope.ConfigPath)); + JsonObject editorSettings = ReadEditorSettingsSection (scope.ConfigPath); + Assert.Equal (7, editorSettings[nameof (EditorSettings.IndentSize)]!.GetValue ()); } [Fact] @@ -43,9 +44,8 @@ public void SaveViewSettings_Updates_Existing_IndentSize_Value () app.Editor.IndentationSize = 8; InvokeSaveViewSettings (app); - var text = File.ReadAllText (scope.ConfigPath); - Assert.Contains ("\"EditorSettings.IndentSize\": 8", text); - Assert.DoesNotContain ("\"EditorSettings.IndentSize\": 2", text); + JsonObject editorSettings = ReadEditorSettingsSection (scope.ConfigPath); + Assert.Equal (8, editorSettings[nameof (EditorSettings.IndentSize)]!.GetValue ()); } [Fact] @@ -57,19 +57,93 @@ public void SaveViewSettings_Persists_WordWrap_Changes () InvokeSaveViewSettings (app); Assert.True (File.Exists (scope.ConfigPath)); - Assert.Contains ("\"EditorSettings.WordWrap\": true", File.ReadAllText (scope.ConfigPath)); + JsonObject editorSettings = ReadEditorSettingsSection (scope.ConfigPath); + Assert.True (editorSettings[nameof (EditorSettings.WordWrap)]!.GetValue ()); } - // NOTE: There is deliberately no "ConfigurationManager applies ted.config.json to the Editor" - // end-to-end test here. That exercises Terminal.Gui's ConfigurationManager (CM) — not ted code - // — and CM's [ConfigurationProperty] discovery + load/apply is process-global. In this shared - // multi-test host the discovery runs (triggered by some earlier test's Application) before the - // `ted` assembly's EditorSettings type is registered, so a CM round-trip here is inherently - // order-dependent/flaky (CLAUDE.md: "don't test the framework"; isolate global state). ted's - // own contract — that Save() writes the exact shape CM requires (nested under "AppSettings", - // AppSettingsScope) — is covered deterministically by - // SaveViewSettings_Preserves_Other_TopLevel_Keys_And_Nests_Under_AppSettings. CM's load/apply - // of AppSettingsScope is covered by Terminal.Gui's own ConfigurationManager tests. + [Fact] + public void MecSettings_Applies_To_TedApp () + { + using ConfigPathScope scope = new (); + var configDirectory = Path.GetDirectoryName (scope.ConfigPath); + Assert.NotNull (configDirectory); + Directory.CreateDirectory (configDirectory); + File.WriteAllText ( + scope.ConfigPath, + """ + { + "EditorSettings": { + "WordWrap": true, + "ShowTabs": true, + "LineNumbers": false, + "IndentSize": 2 + } + } + """); + + EditorSettings.Load (scope.ConfigPath); + TedApp app = new (); + + Assert.True (app.Editor.WordWrap); + Assert.True (app.Editor.ShowTabs); + Assert.False (app.Editor.GutterOptions.HasFlag (GutterOptions.LineNumbers)); + Assert.Equal (2, app.Editor.IndentationSize); + } + + [Fact] + public void LegacyCmSettings_Applies_To_TedApp () + { + using ConfigPathScope scope = new (); + var configDirectory = Path.GetDirectoryName (scope.ConfigPath); + Assert.NotNull (configDirectory); + Directory.CreateDirectory (configDirectory); + File.WriteAllText ( + scope.ConfigPath, + """ + { + "AppSettings": { + "EditorSettings.WordWrap": true, + "EditorSettings.ShowTabs": true, + "EditorSettings.LineNumbers": false, + "EditorSettings.IndentSize": 2 + } + } + """); + + EditorSettings.Load (scope.ConfigPath); + TedApp app = new (); + + Assert.True (app.Editor.WordWrap); + Assert.True (app.Editor.ShowTabs); + Assert.False (app.Editor.GutterOptions.HasFlag (GutterOptions.LineNumbers)); + Assert.Equal (2, app.Editor.IndentationSize); + } + + [Fact] + public void LegacyCmSettings_Ignores_Malformed_Dotted_Values () + { + using ConfigPathScope scope = new (); + var configDirectory = Path.GetDirectoryName (scope.ConfigPath); + Assert.NotNull (configDirectory); + Directory.CreateDirectory (configDirectory); + File.WriteAllText ( + scope.ConfigPath, + """ + { + "AppSettings": { + "EditorSettings.WordWrap": "yes", + "EditorSettings.IndentSize": "large" + } + } + """); + + Exception? exception = Record.Exception (() => EditorSettings.Load (scope.ConfigPath)); + + Assert.Null (exception); + TedApp app = new (); + Assert.False (app.Editor.WordWrap); + Assert.Equal (4, app.Editor.IndentationSize); + } [Fact] public async Task ViewMenu_WordWrap_Toggle_Creates_ConfigFile () @@ -172,7 +246,7 @@ public async Task ViewMenu_WordWrap_MouseToggle_Creates_ConfigFile () public async Task ViewMenu_WordWrap_Toggle_Persists_True () { // Reproduces the user-reported bug: toggling Word Wrap via the View menu - // should create ted.config.json AND persist "EditorSettings.WordWrap": true. + // should create ted.config.json AND persist "WordWrap": true in the MEC settings section. // Before the fix, a conflicting ValueChanged handler caused a double-toggle // that reverted WordWrap to false immediately. using ConfigPathScope scope = new (); @@ -219,9 +293,9 @@ public async Task ViewMenu_WordWrap_Toggle_Persists_True () // Assert: config file created Assert.True (File.Exists (scope.ConfigPath), "Config file was not created"); - // Assert: config file contains the correct persisted value - var configContent = File.ReadAllText (scope.ConfigPath); - Assert.Contains ("\"EditorSettings.WordWrap\": true", configContent); + // Assert: config file contains the correct persisted value in the MEC-native section. + JsonObject editorSettings = ReadEditorSettingsSection (scope.ConfigPath); + Assert.True (editorSettings[nameof (EditorSettings.WordWrap)]!.GetValue ()); } [Fact] @@ -237,7 +311,7 @@ public void QuitFile_DoesNotPersist_ViewSettings () } [Fact] - public void SaveViewSettings_Preserves_Other_TopLevel_Keys_And_Nests_Under_AppSettings () + public void SaveViewSettings_Preserves_Other_TopLevel_Keys_And_Nests_Under_EditorSettings () { using ConfigPathScope scope = new (); var configDirectory = Path.GetDirectoryName (scope.ConfigPath); @@ -247,7 +321,7 @@ public void SaveViewSettings_Preserves_Other_TopLevel_Keys_And_Nests_Under_AppSe // Unrelated top-level data + a legacy flat key (pre-CM format) that must be migrated away. File.WriteAllText ( scope.ConfigPath, - "{\n \"Theme\": \"Dark\",\n \"EditorSettings.WordWrap\": false\n}\n"); + "{\n \"Theme\": \"Dark\",\n \"EditorSettings.WordWrap\": false,\n \"AppSettings\": { \"EditorSettings.ShowTabs\": true }\n}\n"); TedApp app = new (); app.Editor.WordWrap = true; @@ -257,12 +331,35 @@ public void SaveViewSettings_Preserves_Other_TopLevel_Keys_And_Nests_Under_AppSe var text = File.ReadAllText (scope.ConfigPath); JsonNode root = JsonNode.Parse (text)!; - // Unrelated key preserved; legacy flat key migrated away; ted settings nested under - // "AppSettings" (the shape ConfigurationManager reads for AppSettingsScope). + // Unrelated key preserved; legacy keys migrated away; ted settings nested under + // "EditorSettings" (the MEC-native shape). Assert.Equal ("Dark", (string?)root["Theme"]); Assert.Null (root["EditorSettings.WordWrap"]); + Assert.Null (root["AppSettings"]); + JsonNode editorSettings = Assert.IsType (root["EditorSettings"]); + Assert.True ((bool)editorSettings["WordWrap"]!); + } + + [Fact] + public void SaveViewSettings_Preserves_NonTed_AppSettings_Keys () + { + using ConfigPathScope scope = new (); + var configDirectory = Path.GetDirectoryName (scope.ConfigPath); + Assert.NotNull (configDirectory); + Directory.CreateDirectory (configDirectory); + + File.WriteAllText ( + scope.ConfigPath, + "{\n \"AppSettings\": { \"OtherApp.Enabled\": true, \"EditorSettings.ShowTabs\": true }\n}\n"); + + TedApp app = new (); + InvokeSaveViewSettings (app); + + JsonNode root = JsonNode.Parse (File.ReadAllText (scope.ConfigPath))!; JsonNode appSettings = Assert.IsType (root["AppSettings"]); - Assert.True ((bool)appSettings["EditorSettings.WordWrap"]!); + + Assert.True ((bool)appSettings["OtherApp.Enabled"]!); + Assert.Null (appSettings["EditorSettings.ShowTabs"]); } [Fact] @@ -307,10 +404,11 @@ public void Save_Appends_Before_Real_Brace_Not_Comment_Brace () InvokeSaveViewSettings (app); var text = File.ReadAllText (scope.ConfigPath); - // The inserted key should be valid JSON — not inside the comment - Assert.Contains ("\"EditorSettings.WordWrap\": true", text); + // The inserted key should be valid JSON — not inside the comment. + JsonObject editorSettings = ReadEditorSettingsSection (scope.ConfigPath); + Assert.True (editorSettings[nameof (EditorSettings.WordWrap)]!.GetValue ()); // Config should still be parseable: the real closing brace should come after our insertion - var wordWrapPos = text.IndexOf ("\"EditorSettings.WordWrap\"", StringComparison.Ordinal); + var wordWrapPos = text.IndexOf ("\"WordWrap\"", StringComparison.Ordinal); var lastRealBrace = text.LastIndexOf ('}'); // The comment line's } may still exist, but our key must be before the real object close Assert.True (wordWrapPos < lastRealBrace, "WordWrap key should appear before the root closing brace"); @@ -334,6 +432,13 @@ private static void InvokeSaveViewSettings (TedApp app) saveViewSettings.Invoke (app, null); } + private static JsonObject ReadEditorSettingsSection (string path) + { + JsonNode root = JsonNode.Parse (File.ReadAllText (path))!; + + return Assert.IsType (root[EditorSettings.SectionName]); + } + private sealed class ConfigPathScope : IDisposable { private readonly string? _existingConfigContent; @@ -363,18 +468,7 @@ internal ConfigPathScope () public void Dispose () { - // Explicitly restore the EditorSettings statics to their declared defaults. CM is the - // read authority and may have mutated them at app startup elsewhere; reset so this - // serialized collection stays deterministic regardless of CM internals. - EditorSettings.LineNumbers = true; - EditorSettings.FoldIndicators = true; - EditorSettings.WordWrap = false; - EditorSettings.ShowTabs = false; - EditorSettings.IndentSize = 4; - EditorSettings.ConvertTabsToSpaces = true; - EditorSettings.AutoIndent = true; - EditorSettings.Scrollbars = true; - EditorSettings.AutoComplete = false; + EditorSettings.ResetDefaults (); if (_hadExistingConfig) { diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/Terminal.Gui.Editor.IntegrationTests.csproj b/tests/Terminal.Gui.Editor.IntegrationTests/Terminal.Gui.Editor.IntegrationTests.csproj index df11379a..dd8af3aa 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/Terminal.Gui.Editor.IntegrationTests.csproj +++ b/tests/Terminal.Gui.Editor.IntegrationTests/Terminal.Gui.Editor.IntegrationTests.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/Terminal.Gui.Editor.Tests/EditorKeyBindingConfigTests.cs b/tests/Terminal.Gui.Editor.Tests/EditorKeyBindingConfigTests.cs index 1bda413c..668ff7b1 100644 --- a/tests/Terminal.Gui.Editor.Tests/EditorKeyBindingConfigTests.cs +++ b/tests/Terminal.Gui.Editor.Tests/EditorKeyBindingConfigTests.cs @@ -1,16 +1,18 @@ // CoPilot - claude-sonnet-4-5 -using Terminal.Gui.Configuration; +using Microsoft.Extensions.Configuration; +using Terminal.Gui.Editor.Configuration; using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Xunit; +using MecEditorSettings = Terminal.Gui.Editor.Configuration.EditorSettings; namespace Terminal.Gui.Editor.Tests; /// /// Serialisation guard: mutates -/// and , both -/// process-wide statics that Terminal.Gui reads during view construction. Running these tests +/// , a process-wide static that Terminal.Gui reads during view construction. +/// Running these tests /// concurrently with other tests that create instances would corrupt those /// instances' . /// DisableParallelization = true serialises this collection against every other collection @@ -33,10 +35,8 @@ public sealed class KeyBindingConfigCollection; /// /// /// -/// with the -/// "View.ViewKeyBindings" JSON key — the standard Terminal.Gui per-view -/// override mechanism that works for any view type including external-assembly -/// views such as . +/// Microsoft.Extensions.Configuration with the "Editor" section — the +/// MEC path for external-assembly view defaults such as . /// /// /// @@ -242,17 +242,15 @@ public void Override_DefaultKeyBindings_ExistingEditor_IsNotAffected () } /// - /// Proves that loading a JSON keybinding profile via - /// using the standard Terminal.Gui - /// "View.ViewKeyBindings" key updates the instance's key - /// bindings. This is the CM-native path for per-view binding configuration. + /// Proves that loading a keybinding profile via Microsoft.Extensions.Configuration updates + /// the instance's key bindings. /// /// /// The JSON key format is: /// /// { - /// "View.ViewKeyBindings": { - /// "Editor": { + /// "Editor": { + /// "DefaultKeyBindings": { /// "Cut": { "All": ["Ctrl+W"] }, /// "Copy": { "All": ["Ctrl+Shift+C"] }, /// "Undo": { "All": ["Ctrl+Z"] } @@ -264,33 +262,22 @@ public void Override_DefaultKeyBindings_ExistingEditor_IsNotAffected () /// config→Editor pipeline works. /// [Fact] - public void ConfigurationManager_RuntimeConfig_ViewKeyBindings_UpdatesEditorBindings () + public void MecConfiguration_EditorDefaultKeyBindings_UpdatesEditorBindings () { - Dictionary>? originalViewKeyBindings = View.ViewKeyBindings; - var originalRuntimeConfig = ConfigurationManager.RuntimeConfig; - var wasEnabled = ConfigurationManager.IsEnabled; + MecEditorSettings original = MecEditorSettings.Defaults; try { - const string json = """ - { - "View.ViewKeyBindings": { - "Editor": { - "Cut": { "All": ["Ctrl+W"] }, - "Copy": { "All": ["Ctrl+Shift+C"] }, - "Undo": { "All": ["Ctrl+Z"] } - } - } - } - """; - - ConfigurationManager.RuntimeConfig = json; - ConfigurationManager.Enable (ConfigLocations.Runtime); - - // View.ViewKeyBindings must have been populated for the "Editor" type. - Assert.NotNull (View.ViewKeyBindings); - Assert.True (View.ViewKeyBindings!.ContainsKey ("Editor"), - "ViewKeyBindings must contain an entry for 'Editor'"); + IConfiguration configuration = new ConfigurationBuilder () + .AddInMemoryCollection (new Dictionary + { + ["Editor:DefaultKeyBindings:Cut:All:0"] = "Ctrl+W", + ["Editor:DefaultKeyBindings:Copy:All:0"] = "Ctrl+Shift+C", + ["Editor:DefaultKeyBindings:Undo:All:0"] = "Ctrl+Z" + }) + .Build (); + + EditorConfiguration.Apply (configuration); // A new Editor must include the configured bindings. Editor editor = new (); @@ -301,14 +288,32 @@ public void ConfigurationManager_RuntimeConfig_ViewKeyBindings_UpdatesEditorBind } finally { - // Always restore global state, even when the test fails. - View.ViewKeyBindings = originalViewKeyBindings; - ConfigurationManager.RuntimeConfig = originalRuntimeConfig; + MecEditorSettings.Defaults = original; + } + } - if (!wasEnabled) - { - ConfigurationManager.Disable (true); - } + [Fact] + public void MecConfiguration_LegacyDefaultKeyBindings_UpdatesEditorBindings () + { + MecEditorSettings original = MecEditorSettings.Defaults; + + try + { + IConfiguration configuration = new ConfigurationBuilder () + .AddInMemoryCollection (new Dictionary + { + ["Terminal.Gui.Editor.Editor.DefaultKeyBindings:Cut:All:0"] = "Ctrl+W" + }) + .Build (); + + EditorConfiguration.Apply (configuration); + + Editor editor = new (); + Assert.Contains (Key.W.WithCtrl, editor.KeyBindings.GetAllFromCommands (Command.Cut)); + } + finally + { + MecEditorSettings.Defaults = original; } } } diff --git a/tests/Terminal.Gui.Editor.Tests/Terminal.Gui.Editor.Tests.csproj b/tests/Terminal.Gui.Editor.Tests/Terminal.Gui.Editor.Tests.csproj index fb206f65..198f321d 100644 --- a/tests/Terminal.Gui.Editor.Tests/Terminal.Gui.Editor.Tests.csproj +++ b/tests/Terminal.Gui.Editor.Tests/Terminal.Gui.Editor.Tests.csproj @@ -8,6 +8,7 @@ +