diff --git a/Terminal.Gui/Configuration/ConfigurationManager.cs b/Terminal.Gui/Configuration/ConfigurationManager.cs index 96b7182dc3..e005ec3792 100644 --- a/Terminal.Gui/Configuration/ConfigurationManager.cs +++ b/Terminal.Gui/Configuration/ConfigurationManager.cs @@ -204,7 +204,7 @@ internal static void Initialize () // Some getters (notably ThemeManager.Themes while uninitialized) synthesize composite // objects from other configuration properties, so a single populate-and-clone pass can // observe half-initialized cache entries and persist null/default values into the clone. - hardCodedProperty.Value.PropertyValue = DeepCloner.DeepClone (hardCodedProperty.Value.PropertyValue); + hardCodedProperty.Value.PropertyValue = CloneHardCodedPropertyValue (hardCodedProperty.Value.PropertyValue); hardCodedProperty.Value.Immutable = true; } } @@ -222,6 +222,58 @@ internal static void Initialize () ThemeManager.Themes? [ThemeManager.Theme].Apply (); } + // AOT trimming removes the reflective Dictionary<,> constructor that DeepCloner relies on. + // Each non-trivially-cloneable config property type needs a typed clone path here to stay AOT-safe. + private static object? CloneHardCodedPropertyValue (object? propertyValue) + { + if (propertyValue is Dictionary keyBindings) + { + return CloneKeyBindings (keyBindings); + } + + return DeepCloner.DeepClone (propertyValue); + } + + private static Dictionary CloneKeyBindings (Dictionary source) + { + Dictionary clone = new (source.Comparer); + + foreach (KeyValuePair kvp in source) + { + clone [kvp.Key] = ClonePlatformKeyBinding (kvp.Value); + } + + return clone; + } + + private static PlatformKeyBinding ClonePlatformKeyBinding (PlatformKeyBinding binding) + { + return binding with + { + All = CloneKeyArray (binding.All), + Windows = CloneKeyArray (binding.Windows), + Linux = CloneKeyArray (binding.Linux), + Macos = CloneKeyArray (binding.Macos) + }; + } + + private static Key []? CloneKeyArray (Key []? keys) + { + if (keys is null) + { + return null; + } + + Key [] clonedKeys = new Key [keys.Length]; + + for (var i = 0; i < keys.Length; i++) + { + clonedKeys [i] = new (keys [i]); + } + + return clonedKeys; + } + #endregion Initialization #region Enable/Disable diff --git a/Tests/UnitTests.NonParallelizable/Configuration/ConfigurationMangerTests.cs b/Tests/UnitTests.NonParallelizable/Configuration/ConfigurationMangerTests.cs index cf24139fcf..0ec6189397 100644 --- a/Tests/UnitTests.NonParallelizable/Configuration/ConfigurationMangerTests.cs +++ b/Tests/UnitTests.NonParallelizable/Configuration/ConfigurationMangerTests.cs @@ -54,6 +54,73 @@ public void HardCodedDefaultCache_Properties_Are_Copies () Disable (true); } + [Fact] + public void HardCodedDefaultCache_KeyBindingDictionaries_Are_Typed_Deep_Copies () + { + // Copilot + Assert.False (IsEnabled); + Application.ResetState (true); + + try + { + AssertKeyBindingDictionaryIsDeepCopy ("Application.DefaultKeyBindings", Application.DefaultKeyBindings!); + AssertKeyBindingDictionaryIsDeepCopy ("View.DefaultKeyBindings", View.DefaultKeyBindings!); + } + finally + { + Disable (true); + Application.ResetState (true); + } + + static void AssertKeyBindingDictionaryIsDeepCopy (string propertyName, Dictionary currentBindings) + { + FrozenDictionary cache = GetHardCodedConfigPropertyCache (); + Dictionary cachedBindings = Assert.IsType> (cache [propertyName].PropertyValue); + + Assert.NotSame (currentBindings, cachedBindings); + Assert.NotEmpty (cachedBindings); + + KeyValuePair cachedEntry = cachedBindings.First (kvp => HasAnyKeys (kvp.Value)); + PlatformKeyBinding currentBinding = currentBindings [cachedEntry.Key]; + + Assert.NotSame (currentBinding, cachedEntry.Value); + AssertKeyArraysAreDeepCopies (currentBinding.All, cachedEntry.Value.All); + AssertKeyArraysAreDeepCopies (currentBinding.Windows, cachedEntry.Value.Windows); + AssertKeyArraysAreDeepCopies (currentBinding.Linux, cachedEntry.Value.Linux); + AssertKeyArraysAreDeepCopies (currentBinding.Macos, cachedEntry.Value.Macos); + } + + static bool HasAnyKeys (PlatformKeyBinding binding) + { + return binding.All is { Length: > 0 } + || binding.Windows is { Length: > 0 } + || binding.Linux is { Length: > 0 } + || binding.Macos is { Length: > 0 }; + } + +#pragma warning disable CS8632 // Nullable annotations document the nullable PlatformKeyBinding properties in this non-nullable test file. + static void AssertKeyArraysAreDeepCopies (Key []? currentKeys, Key []? cachedKeys) +#pragma warning restore CS8632 + { + if (currentKeys is null) + { + Assert.Null (cachedKeys); + + return; + } + + Assert.NotNull (cachedKeys); + Assert.NotSame (currentKeys, cachedKeys); + Assert.Equal (currentKeys.Length, cachedKeys.Length); + + for (var i = 0; i < currentKeys.Length; i++) + { + Assert.NotSame (currentKeys [i], cachedKeys [i]); + Assert.Equal (currentKeys [i], cachedKeys [i]); + } + } + } + [Fact] public void HardCoded_Default_Theme_Uses_Fully_Populated_Cache_Values () {