diff --git a/Examples/UICatalog/Scenarios/ThemeFallback.cs b/Examples/UICatalog/Scenarios/ThemeFallback.cs new file mode 100644 index 0000000000..b93a5706c7 --- /dev/null +++ b/Examples/UICatalog/Scenarios/ThemeFallback.cs @@ -0,0 +1,143 @@ +#nullable enable + +namespace UICatalog.Scenarios; + +/// +/// Demonstrates the SchemeName fallback chain introduced in v2. +/// When a view's is not found in the active theme, the view no longer +/// throws a . Instead, it walks the fallback chain: +/// +/// +/// Named scheme (if found in current theme) +/// +/// +/// SuperView's scheme (recursive) +/// +/// +/// "Base" scheme from the current theme +/// +/// +/// Hard-coded "Base" scheme (always present) +/// +/// +/// +[ScenarioMetadata ("Theme Fallback", "Demonstrates graceful SchemeName fallback when a named scheme is missing from the active theme.")] +[ScenarioCategory ("Colors")] +[ScenarioCategory ("Configuration")] +public sealed class ThemeFallback : Scenario +{ + private const string CUSTOM_SCHEME_NAME = "CustomHighlight"; + private const string MISSING_SCHEME_NAME = "NonExistentScheme"; + + public override void Main () + { + ConfigurationManager.Enable (ConfigLocations.All); + + using IApplication app = Application.Create (); + app.Init (); + + // Extend the Default theme with a custom scheme that has TextStyle.Blink + // so it stands out visually. Other built-in themes do NOT contain this scheme, + // so switching themes lets you watch the fallback chain activate in real time. + SchemeManager.AddScheme (CUSTOM_SCHEME_NAME, new () { Normal = new Attribute (Color.BrightYellow, Color.Blue, TextStyle.Blink) }); + + using Window appWindow = new (); + appWindow.Title = GetQuitKeyAndName (); + + // --- Theme selector --- + string [] themeLabels = ThemeManager.GetThemeNames ().Select (n => "_" + n).ToArray (); + + OptionSelector themeSelector = new () + { + Title = "_Theme", + BorderStyle = LineStyle.Rounded, + X = 1, + Y = 1, + Width = Dim.Auto (), + Height = Dim.Auto (), + Labels = themeLabels, + Value = ThemeManager.GetThemeNames ().IndexOf (ThemeManager.Theme) + }; + + themeSelector.ValueChanged += (sender, args) => + { + if (sender is not OptionSelector sel) + { + return; + } + + string rawLabel = sel.Labels! [(int)args.NewValue!]; + + // Strip the leading underscore added for keyboard shortcut. + ThemeManager.Theme = rawLabel [1..]; + ConfigurationManager.Apply (); + + // Re-add the custom scheme to the newly-active theme so the + // "Default" theme always demonstrates the found case. + if (ThemeManager.Theme == ThemeManager.DEFAULT_THEME_NAME) + { + SchemeManager.AddScheme (CUSTOM_SCHEME_NAME, + new () { Normal = new Attribute (Color.BrightYellow, Color.Blue, TextStyle.Blink) }); + } + }; + + // --- Explanation --- + Label intro = new () + { + X = Pos.Right (themeSelector) + 1, + Y = 1, + Width = Dim.Fill (1), + Text = $"Switch to a non-Default theme to see the fallback activate.\n" + + $" • \"{CUSTOM_SCHEME_NAME}\" is only in the Default theme.\n" + + $" • \"{MISSING_SCHEME_NAME}\" is never in any theme.\n" + + $"In both missing cases the view falls back gracefully instead of throwing." + }; + + // --- View 1: scheme FOUND in the active theme --- + FrameView foundFrame = new () + { + Title = $"SchemeName = \"{CUSTOM_SCHEME_NAME}\"", + X = Pos.Right (themeSelector) + 1, + Y = Pos.Bottom (intro) + 1, + Width = Dim.Fill (1), + Height = 5, + SchemeName = CUSTOM_SCHEME_NAME + }; + + Label foundLabel = new () + { + X = 1, + Y = 1, + Width = Dim.Fill (2), + Text = $"On the Default theme this scheme exists (BrightYellow/Blue + Blink).\n" + + $"On any other theme the scheme is missing → fallback chain activates." + }; + foundFrame.Add (foundLabel); + + // --- View 2: scheme NEVER found — fallback always activates --- + FrameView missingFrame = new () + { + Title = $"SchemeName = \"{MISSING_SCHEME_NAME}\"", + X = Pos.Right (themeSelector) + 1, + Y = Pos.Bottom (foundFrame) + 1, + Width = Dim.Fill (1), + Height = 5, + SchemeName = MISSING_SCHEME_NAME + }; + + Label missingLabel = new () + { + X = 1, + Y = 1, + Width = Dim.Fill (2), + Text = $"This scheme does not exist in any theme.\n" + + $"The view silently falls back to its SuperView's scheme (no exception).\n" + + $"A warning is written to the debug log." + }; + missingFrame.Add (missingLabel); + + appWindow.Add (themeSelector, intro, foundFrame, missingFrame); + + app.Run (appWindow); + } +} diff --git a/Examples/UICatalog/UICatalogRunnable.cs b/Examples/UICatalog/UICatalogRunnable.cs index b4b60f1e31..0fb8a60f06 100644 --- a/Examples/UICatalog/UICatalogRunnable.cs +++ b/Examples/UICatalog/UICatalogRunnable.cs @@ -641,6 +641,7 @@ private ListView CreateCategoryList () SuperViewRendersLineCanvas = true, Source = new ListWrapper (CachedCategories) }; + //categoryList.Border.Settings = BorderSettings.Title | BorderSettings.Tab; //categoryList.Border.TabSide = Side.Top; //categoryList.Border.Thickness = new Thickness (1, 2, 1, 1); @@ -721,20 +722,31 @@ public static bool ShowStatusBar /// or assigned to a on a menu item. Falls back to /// if all are taken. /// - private Key GetFirstUnboundFKey () + /// + private Key GetFirstUnboundFKey (HashSet ignoreKeys) { Key [] fKeys = [ - Key.F1, Key.F2, Key.F3, Key.F4, Key.F5, Key.F6, - Key.F7, Key.F8, Key.F9, Key.F10, Key.F11, Key.F12 + Key.F1, + Key.F2, + Key.F3, + Key.F4, + Key.F5, + Key.F6, + Key.F7, + Key.F8, + Key.F9, + Key.F10, + Key.F11, + Key.F12 ]; // Collect keys already bound at the Application level - HashSet boundKeys = []; + HashSet boundKeys = ignoreKeys; foreach (Key fKey in fKeys) { - if (Application.KeyBindings.TryGet (fKey, out _)) + if (App?.Keyboard.KeyBindings.TryGet (fKey, out _) is true) { boundKeys.Add (fKey); } @@ -753,6 +765,16 @@ private Key GetFirstUnboundFKey () } } + // Exclude keys used by StatusBar items + if (_statusBar is { }) + { + foreach (View view in _statusBar.SubViews) + { + var shortcut = (Shortcut)view; + boundKeys.Add (shortcut.Key); + } + } + foreach (Key fKey in fKeys) { if (!boundKeys.Contains (fKey)) @@ -773,7 +795,10 @@ private StatusBar CreateStatusBar () _shVersion = new Shortcut { Title = "Version Info", CanFocus = false }; - Shortcut statusBarShortcut = new () { Key = GetFirstUnboundFKey (), Title = "Show/Hide Status Bar", CanFocus = false, Action = () => ShowStatusBar = !ShowStatusBar }; + Shortcut statusBarShortcut = new () + { + Key = GetFirstUnboundFKey ([]), Title = "Show/Hide Status Bar", CanFocus = false, Action = () => ShowStatusBar = !ShowStatusBar + }; _force16ColorsShortcutCb = new CheckBox { @@ -786,7 +811,7 @@ private StatusBar CreateStatusBar () CommandView = _force16ColorsShortcutCb, HelpText = "", BindKeyToApplication = true, - Key = Key.F7, + Key = GetFirstUnboundFKey ([statusBarShortcut.Key]), Action = () => { Driver.Force16Colors = !Driver.Force16Colors; diff --git a/Terminal.Gui/Configuration/SchemeManager.cs b/Terminal.Gui/Configuration/SchemeManager.cs index f117181805..407839b0c9 100644 --- a/Terminal.Gui/Configuration/SchemeManager.cs +++ b/Terminal.Gui/Configuration/SchemeManager.cs @@ -136,9 +136,67 @@ public static Scheme GetScheme (Schemes schemeName) /// /// /// - /// + /// If is not found in the current theme. public static Scheme GetScheme (string schemeName) { return GetSchemesForCurrentTheme ()! [schemeName]!; } + /// + /// Attempts to get the for the specified name without throwing. + /// Returns and sets to if the scheme is + /// not found, or if the configuration is not in a state where schemes can be resolved. + /// + /// The name of the scheme to retrieve. + /// + /// When this method returns , contains the resolved ; otherwise + /// . + /// + /// if the scheme was found; otherwise . + public static bool TryGetScheme (string schemeName, [NotNullWhen (true)] out Scheme? scheme) + { + lock (_schemesLock) + { + Dictionary schemes; + + if (!ConfigurationManager.IsInitialized ()) + { + // Module initializer / unit-test path — fall back to hard-coded defaults. + ImmutableSortedDictionary? hardCoded = GetHardCodedSchemes (); + + if (hardCoded is null) + { + scheme = null; + + return false; + } + + schemes = hardCoded.ToDictionary (StringComparer.InvariantCultureIgnoreCase); + } + else + { + // Avoid GetSchemesForCurrentTheme() — it throws if the Schemes property is absent. + if (ThemeManager.GetCurrentTheme () ["Schemes"].PropertyValue + is not Dictionary themeSchemes) + { + scheme = null; + + return false; + } + + schemes = themeSchemes; + } + + if (schemes.TryGetValue (schemeName, out Scheme? s) && s is not null) + { + scheme = s; + + return true; + } + + scheme = null; + + return false; + } + } + /// /// Gets the name of the specified . Will throw an exception if /// is not a built-in Scheme. diff --git a/Terminal.Gui/ViewBase/View.Drawing.Scheme.cs b/Terminal.Gui/ViewBase/View.Drawing.Scheme.cs index ee8bc63161..1cc8384ed3 100644 --- a/Terminal.Gui/ViewBase/View.Drawing.Scheme.cs +++ b/Terminal.Gui/ViewBase/View.Drawing.Scheme.cs @@ -134,20 +134,47 @@ public Scheme GetScheme () Scheme DefaultAction () { - if (!HasScheme && !string.IsNullOrEmpty (SchemeName)) + if (HasScheme) { - return SchemeManager.GetScheme (SchemeName); + return _scheme!; } - if (!HasScheme) + if (string.IsNullOrEmpty (SchemeName)) { - return SuperView?.GetScheme () ?? SchemeManager.GetScheme (Schemes.Base); + return ResolveFallbackScheme (); } - return _scheme!; + if (SchemeManager.TryGetScheme (SchemeName, out Scheme? namedScheme)) + { + return namedScheme; + } + + Logging.Warning ($"SchemeName '{SchemeName}' not found in current theme. Falling back."); + + return ResolveFallbackScheme (); } } + /// + /// Resolves a scheme using the fallback chain when no explicit scheme or valid is + /// available: 's scheme → "Base" in the current theme → hard-coded "Base". + /// + private Scheme ResolveFallbackScheme () + { + if (SuperView is { }) + { + return SuperView.GetScheme (); + } + + if (SchemeManager.TryGetScheme ("Base", out Scheme? baseScheme)) + { + return baseScheme; + } + + // Last resort: hard-coded defaults are always available regardless of configuration state. + return SchemeManager.GetHardCodedSchemes ()! ["Base"]!; + } + /// /// Called when the for the is being retrieved. Subclasses can return /// true to stop further processing and optionally set to a different value. diff --git a/Tests/UnitTestsParallelizable/Configuration/SchemeManagerTests.cs b/Tests/UnitTestsParallelizable/Configuration/SchemeManagerTests.cs index 1365d63f0b..2707872253 100644 --- a/Tests/UnitTestsParallelizable/Configuration/SchemeManagerTests.cs +++ b/Tests/UnitTestsParallelizable/Configuration/SchemeManagerTests.cs @@ -77,4 +77,36 @@ public void GetScheme_Throws_On_Invalid_String () Assert.Throws (() => SchemeManager.GetScheme ("NotAScheme")); } + // Copilot + + [Fact] + public void TryGetScheme_ExistingScheme_ReturnsTrueAndScheme () + { + bool found = SchemeManager.TryGetScheme ("Base", out Scheme? scheme); + + Assert.True (found); + Assert.NotNull (scheme); + } + + [Fact] + public void TryGetScheme_MissingScheme_ReturnsFalseAndNull () + { + bool found = SchemeManager.TryGetScheme ("DoesNotExist", out Scheme? scheme); + + Assert.False (found); + Assert.Null (scheme); + } + + [Fact] + public void TryGetScheme_AllBuiltInSchemes_ReturnsTrue () + { + foreach (string name in SchemeManager.GetSchemeNames ()) + { + bool found = SchemeManager.TryGetScheme (name, out Scheme? scheme); + + Assert.True (found, $"Expected TryGetScheme to return true for built-in scheme '{name}'"); + Assert.NotNull (scheme); + } + } + } diff --git a/Tests/UnitTestsParallelizable/Configuration/SourcesManagerTests.cs b/Tests/UnitTestsParallelizable/Configuration/SourcesManagerTests.cs index 4f031c2cc8..1e13c5fd41 100644 --- a/Tests/UnitTestsParallelizable/Configuration/SourcesManagerTests.cs +++ b/Tests/UnitTestsParallelizable/Configuration/SourcesManagerTests.cs @@ -1,5 +1,5 @@ using System.Reflection; -using System.Text.Json; + namespace ConfigurationTests; public class SourcesManagerTests @@ -54,7 +54,6 @@ public void Load_WithValidStream_UpdatesSettingsScope () Assert.Contains (source, sourcesManager.Sources.Values); } - #endregion #region Update (FilePath) @@ -91,7 +90,7 @@ public void Load_WithValidFile_UpdatesSettingsScope () "Driver.Force16Colors": true } """; - var source = Path.GetTempFileName (); + string source = Path.GetTempFileName (); var location = ConfigLocations.HardCoded; File.WriteAllText (source, json); @@ -192,7 +191,6 @@ public void Load_WithValidJson_UpdatesSettingsScope () Assert.Contains (source, sourcesManager.Sources.Values); } - //[Fact] //public void Update_WithValidJson_UpdatesThemeScope () //{ @@ -329,7 +327,7 @@ public void ToStream_WithValidScope_ReturnsStream () settingsScope ["Driver.Force16Colors"].PropertyValue = true; // Act - Stream stream = sourcesManager.ToStream (settingsScope); + var stream = sourcesManager.ToStream (settingsScope); // Assert Assert.NotNull (stream); @@ -381,16 +379,10 @@ public void Load_WithDifferentLocations_AddsAllSourcesToCollection () var sourcesManager = new SourcesManager (); var settingsScope = new SettingsScope (); - ConfigLocations [] locations = - [ - ConfigLocations.LibraryResources, - ConfigLocations.Runtime, - ConfigLocations.AppCurrent, - ConfigLocations.GlobalHome - ]; + ConfigLocations [] locations = [ConfigLocations.LibraryResources, ConfigLocations.Runtime, ConfigLocations.AppCurrent, ConfigLocations.GlobalHome]; // Act - Update with different sources for different locations - foreach (var location in locations) + foreach (ConfigLocations location in locations) { var source = $"config-{location}.json"; sourcesManager.Load (settingsScope, """{"Driver.Force16Colors": true}""", source, location); @@ -398,7 +390,8 @@ public void Load_WithDifferentLocations_AddsAllSourcesToCollection () // Assert Assert.Equal (locations.Length, sourcesManager.Sources.Count); - foreach (var location in locations) + + foreach (ConfigLocations location in locations) { Assert.Contains (location, sourcesManager.Sources.Keys); Assert.Equal ($"config-{location}.json", sourcesManager.Sources [location]); @@ -433,22 +426,21 @@ public void Load_WithNonExistentFileAndDifferentLocations_TracksAllSources () var settingsScope = new SettingsScope (); // Define multiple files and locations - var fileLocations = new Dictionary (StringComparer.InvariantCultureIgnoreCase) - { - { "file1.json", ConfigLocations.AppCurrent }, - { "file2.json", ConfigLocations.GlobalHome }, - { "file3.json", ConfigLocations.AppHome } - }; + Dictionary fileLocations = new (StringComparer.InvariantCultureIgnoreCase) + { + { "file1.json", ConfigLocations.AppCurrent }, { "file2.json", ConfigLocations.GlobalHome }, { "file3.json", ConfigLocations.AppHome } + }; // Act - foreach (var pair in fileLocations) + foreach (KeyValuePair pair in fileLocations) { sourcesManager.Load (settingsScope, pair.Key, pair.Value); } // Assert Assert.Equal (fileLocations.Count, sourcesManager.Sources.Count); - foreach (var pair in fileLocations) + + foreach (KeyValuePair pair in fileLocations) { Assert.Contains (pair.Value, sourcesManager.Sources.Keys); Assert.Equal (pair.Key, sourcesManager.Sources [pair.Value]); @@ -490,5 +482,4 @@ public void Sources_IsPreservedAcrossOperations () } #endregion - } diff --git a/Tests/UnitTestsParallelizable/ViewBase/Draw/SchemeTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Draw/SchemeTests.cs index 13db3b7252..7eff4a2ce5 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Draw/SchemeTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Draw/SchemeTests.cs @@ -1,18 +1,16 @@ -#nullable enable +using System.Collections.Immutable; using UnitTests; -using Xunit; namespace ViewBaseTests.Drawing; [Trait ("Category", "View.Scheme")] public class SchemeTests : TestDriverBase { - [Fact] public void GetScheme_Default_ReturnsBaseScheme () { var view = new View (); - var baseScheme = SchemeManager.GetHardCodedSchemes ()? ["Base"]; + Scheme? baseScheme = SchemeManager.GetHardCodedSchemes ()? ["Base"]; Assert.Equal (baseScheme, view.GetScheme ()); view.Dispose (); @@ -22,7 +20,7 @@ public void GetScheme_Default_ReturnsBaseScheme () public void SetScheme_Explicitly_SetsSchemeCorrectly () { var view = new View (); - var dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; + Scheme? dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; view.SetScheme (dialogScheme); @@ -39,7 +37,7 @@ public void GetScheme_InheritsFromSuperView_WhenNotExplicitlySet () superView.Add (subView); - var dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; + Scheme? dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; superView.SetScheme (dialogScheme); Assert.Equal (dialogScheme, subView.GetScheme ()); @@ -55,7 +53,7 @@ public void SetSchemeName_OverridesInheritedScheme () var view = new View (); view.SchemeName = "Dialog"; - var dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; + Scheme? dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; Assert.Equal (dialogScheme, view.GetScheme ()); view.Dispose (); } @@ -68,7 +66,7 @@ public void GetAttribute_ReturnsCorrectAttribute_Via_Mock () view.Driver.SetAttribute (new Attribute (Color.Red, Color.Green)); // Act - var attribute = view.GetCurrentAttribute (); + Attribute attribute = view.GetCurrentAttribute (); // Assert Assert.Equal (new Attribute (Color.Red, Color.Green), attribute); @@ -107,7 +105,7 @@ public void SetAttributeForRole_SetsCorrectAttribute () view.Driver = CreateTestDriver (); view.Driver.SetAttribute (new Attribute (Color.Red, Color.Green)); - var previousAttribute = view.SetAttributeForRole (VisualRole.Focus); + Attribute? previousAttribute = view.SetAttributeForRole (VisualRole.Focus); Assert.Equal (view.GetScheme ().Focus, view.GetCurrentAttribute ()); Assert.NotEqual (previousAttribute, view.GetCurrentAttribute ()); @@ -118,7 +116,7 @@ public void SetAttributeForRole_SetsCorrectAttribute () public void OnGettingScheme_Override_StopsDefaultBehavior () { var view = new CustomView (); - var customScheme = SchemeManager.GetHardCodedSchemes ()? ["Error"]; + Scheme? customScheme = SchemeManager.GetHardCodedSchemes ()? ["Error"]; Assert.Equal (customScheme, view.GetScheme ()); view.Dispose (); @@ -128,7 +126,7 @@ public void OnGettingScheme_Override_StopsDefaultBehavior () public void OnSettingScheme_Override_PreventsSettingScheme () { var view = new CustomView (); - var dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; + Scheme? dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; view.SetScheme (dialogScheme); @@ -140,7 +138,7 @@ public void OnSettingScheme_Override_PreventsSettingScheme () public void GettingScheme_Event_CanOverrideScheme () { var view = new View (); - var customScheme = SchemeManager.GetHardCodedSchemes ()? ["Error"]! with { Normal = Attribute.Default }; + Scheme customScheme = SchemeManager.GetHardCodedSchemes ()? ["Error"]! with { Normal = Attribute.Default }; Assert.NotEqual (Attribute.Default, view.GetScheme ().Normal); @@ -159,7 +157,7 @@ public void GettingScheme_Event_CanOverrideScheme () public void SettingScheme_Event_CanCancelSchemeChange () { var view = new View (); - var dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; + Scheme? dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; view.SchemeChanging += (sender, args) => args.Handled = true; @@ -191,7 +189,7 @@ public void GetAttributeForRole_Event_CanOverrideAttribute () [Fact] public void GetHardCodedSchemes_ReturnsExpectedSchemes () { - var schemes = Scheme.GetHardCodedSchemes (); + ImmutableSortedDictionary schemes = Scheme.GetHardCodedSchemes (); Assert.NotNull (schemes); Assert.Contains ("Base", schemes.Keys); @@ -201,7 +199,6 @@ public void GetHardCodedSchemes_ReturnsExpectedSchemes () Assert.Contains ("Runnable", schemes.Keys); } - [Fact] public void SchemeName_OverridesSuperViewScheme () { @@ -212,7 +209,7 @@ public void SchemeName_OverridesSuperViewScheme () subView.SchemeName = "Error"; - var errorScheme = SchemeManager.GetHardCodedSchemes ()? ["Error"]; + Scheme? errorScheme = SchemeManager.GetHardCodedSchemes ()? ["Error"]; Assert.Equal (errorScheme, subView.GetScheme ()); subView.Dispose (); @@ -223,7 +220,7 @@ public void SchemeName_OverridesSuperViewScheme () public void Scheme_DefaultsToBase_WhenNotSet () { var view = new View (); - var baseScheme = SchemeManager.GetHardCodedSchemes ()? ["Base"]; + Scheme? baseScheme = SchemeManager.GetHardCodedSchemes ()? ["Base"]; Assert.Equal (baseScheme, view.GetScheme ()); view.Dispose (); @@ -235,7 +232,7 @@ public void Scheme_HandlesNullSuperViewGracefully () var view = new View (); view.SchemeName = "Dialog"; - var dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; + Scheme? dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; Assert.Equal (dialogScheme, view.GetScheme ()); view.Dispose (); @@ -250,10 +247,7 @@ protected override bool OnGettingScheme (out Scheme? scheme) return true; } - protected override bool OnSettingScheme (ValueChangingEventArgs args) - { - return true; // Prevent setting the scheme - } + protected override bool OnSettingScheme (ValueChangingEventArgs args) => true; // Prevent setting the scheme } [Fact] @@ -265,14 +259,15 @@ public void GetAttributeForRole_SubView_DefersToSuperView_WhenNoExplicitScheme ( // Parent customizes attribute resolution var customAttribute = new Attribute (Color.BrightMagenta, Color.BrightGreen); + parentView.GettingAttributeForRole += (sender, args) => - { - if (args.Role == VisualRole.Normal) - { - args.Result = customAttribute; - args.Handled = true; - } - }; + { + if (args.Role == VisualRole.Normal) + { + args.Result = customAttribute; + args.Handled = true; + } + }; // Child without explicit scheme should get customized attribute from parent Assert.Equal (customAttribute, childView.GetAttributeForRole (VisualRole.Normal)); @@ -289,19 +284,20 @@ public void GetAttributeForRole_SubView_UsesOwnScheme_WhenExplicitlySet () parentView.Add (childView); // Set explicit scheme on child - var childScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; + Scheme? childScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; childView.SetScheme (childScheme); // Parent customizes attribute resolution var customAttribute = new Attribute (Color.BrightMagenta, Color.BrightGreen); + parentView.GettingAttributeForRole += (sender, args) => - { - if (args.Role == VisualRole.Normal) - { - args.Result = customAttribute; - args.Handled = true; - } - }; + { + if (args.Role == VisualRole.Normal) + { + args.Result = customAttribute; + args.Handled = true; + } + }; // Child with explicit scheme should NOT get customized attribute from parent Assert.NotEqual (customAttribute, childView.GetAttributeForRole (VisualRole.Normal)); @@ -315,8 +311,9 @@ public void GetAttributeForRole_SubView_UsesOwnScheme_WhenExplicitlySet () public void GetAttributeForRole_Border_UsesParentScheme () { // Border (an Adornment) doesn't have a SuperView but should use its Parent's scheme - View view = new View { SchemeName = "Dialog" }; + var view = new View { SchemeName = "Dialog" }; Border? border = view.Border; + // Force GetOrCreateView border.LineStyle = LineStyle.Dashed; @@ -344,18 +341,19 @@ public void GetAttributeForRole_SubView_UsesSchemeName_WhenSet () // Parent customizes attribute resolution var customAttribute = new Attribute (Color.BrightMagenta, Color.BrightGreen); + parentView.GettingAttributeForRole += (sender, args) => - { - if (args.Role == VisualRole.Normal) - { - args.Result = customAttribute; - args.Handled = true; - } - }; + { + if (args.Role == VisualRole.Normal) + { + args.Result = customAttribute; + args.Handled = true; + } + }; // Child with SchemeName should NOT get customized attribute from parent // It should use the Dialog scheme instead - var dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; + Scheme? dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; Assert.NotEqual (customAttribute, childView.GetAttributeForRole (VisualRole.Normal)); Assert.Equal (dialogScheme!.Normal, childView.GetAttributeForRole (VisualRole.Normal)); @@ -378,14 +376,15 @@ public void GetAttributeForRole_NestedHierarchy_DefersCorrectly () // Grandparent customizes attributes var customAttribute = new Attribute (Color.BrightYellow, Color.BrightBlue); + grandparentView.GettingAttributeForRole += (sender, args) => - { - if (args.Role == VisualRole.Normal) - { - args.Result = customAttribute; - args.Handled = true; - } - }; + { + if (args.Role == VisualRole.Normal) + { + args.Result = customAttribute; + args.Handled = true; + } + }; // Child should get attribute from grandparent through parent Assert.Equal (customAttribute, childView.GetAttributeForRole (VisualRole.Normal)); @@ -413,17 +412,18 @@ public void GetAttributeForRole_ParentWithSchemeNameBreaksChain () // Grandparent customizes attributes var customAttribute = new Attribute (Color.BrightYellow, Color.BrightBlue); + grandparentView.GettingAttributeForRole += (sender, args) => - { - if (args.Role == VisualRole.Normal) - { - args.Result = customAttribute; - args.Handled = true; - } - }; + { + if (args.Role == VisualRole.Normal) + { + args.Result = customAttribute; + args.Handled = true; + } + }; // Parent should NOT get grandparent's customization (it has SchemeName) - var dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; + Scheme? dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; Assert.NotEqual (customAttribute, parentView.GetAttributeForRole (VisualRole.Normal)); Assert.Equal (dialogScheme!.Normal, parentView.GetAttributeForRole (VisualRole.Normal)); @@ -447,14 +447,15 @@ public void GetAttributeForRole_OnGettingAttributeForRole_TakesPrecedence () // Parent customizes attributes var parentAttribute = new Attribute (Color.BrightYellow, Color.BrightBlue); + parentView.GettingAttributeForRole += (sender, args) => - { - if (args.Role == VisualRole.Normal) - { - args.Result = parentAttribute; - args.Handled = true; - } - }; + { + if (args.Role == VisualRole.Normal) + { + args.Result = parentAttribute; + args.Handled = true; + } + }; // Child's own override should take precedence var childOverrideAttribute = new Attribute (Color.BrightRed, Color.BrightCyan); @@ -481,23 +482,28 @@ public void GetAttributeForRole_MultipleRoles_DeferCorrectly () var hotNormalAttr = new Attribute (Color.Magenta, Color.Cyan); parentView.GettingAttributeForRole += (sender, args) => - { - switch (args.Role) - { - case VisualRole.Normal: - args.Result = normalAttr; - args.Handled = true; - break; - case VisualRole.Focus: - args.Result = focusAttr; - args.Handled = true; - break; - case VisualRole.HotNormal: - args.Result = hotNormalAttr; - args.Handled = true; - break; - } - }; + { + switch (args.Role) + { + case VisualRole.Normal: + args.Result = normalAttr; + args.Handled = true; + + break; + + case VisualRole.Focus: + args.Result = focusAttr; + args.Handled = true; + + break; + + case VisualRole.HotNormal: + args.Result = hotNormalAttr; + args.Handled = true; + + break; + } + }; // All roles should defer to parent Assert.Equal (normalAttr, childView.GetAttributeForRole (VisualRole.Normal)); @@ -517,10 +523,94 @@ protected override bool OnGettingAttributeForRole (in VisualRole role, ref Attri if (OverrideAttribute.HasValue && role == VisualRole.Normal) { currentAttribute = OverrideAttribute.Value; + return true; } + return base.OnGettingAttributeForRole (role, ref currentAttribute); } } -} \ No newline at end of file + // Copilot - fallback chain tests for discussion #4457 + + [Fact] + public void GetScheme_SchemeName_MissingScheme_FallsBackToSuperView () + { + // A view with SchemeName set to a non-existent scheme should fall back to SuperView's scheme, + // not throw a KeyNotFoundException. + View superView = new (); + View subView = new (); + superView.Add (subView); + + Scheme? dialogScheme = SchemeManager.GetHardCodedSchemes ()? ["Dialog"]; + superView.SetScheme (dialogScheme); + + subView.SchemeName = "NonExistentScheme"; + + // Should not throw; should fall back to superView's Dialog scheme + Scheme resolved = subView.GetScheme (); + + Assert.Equal (dialogScheme, resolved); + + subView.Dispose (); + superView.Dispose (); + } + + [Fact] + public void GetScheme_SchemeName_MissingScheme_NoSuperView_FallsBackToBase () + { + // A view with SchemeName set to a non-existent scheme and no SuperView should fall back + // to the "Base" scheme, not throw a KeyNotFoundException. + View view = new (); + view.SchemeName = "NonExistentScheme"; + + Scheme? baseScheme = SchemeManager.GetHardCodedSchemes ()? ["Base"]; + + Scheme resolved = view.GetScheme (); + + Assert.Equal (baseScheme, resolved); + + view.Dispose (); + } + + [Fact] + public void GetScheme_SchemeName_ExistingScheme_NoFallback () + { + // Regression: a view with SchemeName pointing to an existing scheme should still + // return that scheme and not be affected by the fallback logic. + View view = new (); + view.SchemeName = "Error"; + + Scheme? errorScheme = SchemeManager.GetHardCodedSchemes ()? ["Error"]; + + Assert.Equal (errorScheme, view.GetScheme ()); + + view.Dispose (); + } + + [Fact] + public void GetScheme_SchemeName_MissingScheme_SuperViewAlsoMissingScheme_FallsBackToBase () + { + // A view whose SchemeName is missing AND whose SuperView has no scheme either + // should ultimately fall back all the way to "Base". + View grandparent = new (); + View parent = new (); + View child = new (); + + grandparent.Add (parent); + parent.Add (child); + + // Neither grandparent nor parent have explicit schemes + child.SchemeName = "NonExistentScheme"; + + Scheme? baseScheme = SchemeManager.GetHardCodedSchemes ()? ["Base"]; + + Scheme resolved = child.GetScheme (); + + Assert.Equal (baseScheme, resolved); + + child.Dispose (); + parent.Dispose (); + grandparent.Dispose (); + } +}