Skip to content
147 changes: 147 additions & 0 deletions Examples/UICatalog/Scenarios/ThemeFallback.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#nullable enable

namespace UICatalog.Scenarios;

/// <summary>
/// Demonstrates the SchemeName fallback chain introduced in v2.
///
/// When a view's <see cref="View.SchemeName"/> is not found in the active theme, the view no longer
/// throws a <see cref="KeyNotFoundException"/>. Instead it walks the fallback chain:
/// <list type="number">
/// <item><description>Named scheme (if found in current theme)</description></item>
/// <item><description>SuperView's scheme (recursive)</description></item>
/// <item><description>"Base" scheme from the current theme</description></item>
/// <item><description>Hard-coded "Base" scheme (always present)</description></item>
/// </list>
/// </summary>
[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 CustomSchemeName = "CustomHighlight";
private const string MissingSchemeName = "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 (
CustomSchemeName,
new ()
{
Normal = new Attribute (Color.BrightYellow, Color.Blue, TextStyle.Blink)
});

using Window appWindow = new () { 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 (
CustomSchemeName,
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" +
$" • \"{CustomSchemeName}\" is only in the Default theme.\n" +
$" • \"{MissingSchemeName}\" 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 = \"{CustomSchemeName}\"",
X = Pos.Right (themeSelector) + 1,
Y = Pos.Bottom (intro) + 1,
Width = Dim.Fill (1),
Height = 5,
SchemeName = CustomSchemeName
};

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 = \"{MissingSchemeName}\"",
X = Pos.Right (themeSelector) + 1,
Y = Pos.Bottom (foundFrame) + 1,
Width = Dim.Fill (1),
Height = 5,
SchemeName = MissingSchemeName
};

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);
}
}
60 changes: 59 additions & 1 deletion Terminal.Gui/Configuration/SchemeManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,67 @@ public static Scheme GetScheme (Schemes schemeName)
/// </summary>
/// <param name="schemeName"></param>
/// <returns></returns>
Comment thread
tig marked this conversation as resolved.
/// <exception cref="ArgumentException"></exception>
/// <exception cref="KeyNotFoundException">If <paramref name="schemeName"/> is not found in the current theme.</exception>
public static Scheme GetScheme (string schemeName) { return GetSchemesForCurrentTheme ()! [schemeName]!; }

/// <summary>
/// Attempts to get the <see cref="Scheme"/> for the specified name without throwing.
/// Returns <see langword="false"/> and sets <paramref name="scheme"/> to <see langword="null"/> if the scheme is
/// not found, or if the configuration is not in a state where schemes can be resolved.
/// </summary>
/// <param name="schemeName">The name of the scheme to retrieve.</param>
/// <param name="scheme">
/// When this method returns <see langword="true"/>, contains the resolved <see cref="Scheme"/>; otherwise
/// <see langword="null"/>.
/// </param>
/// <returns><see langword="true"/> if the scheme was found; otherwise <see langword="false"/>.</returns>
public static bool TryGetScheme (string schemeName, [NotNullWhen (true)] out Scheme? scheme)
{
lock (_schemesLock)
{
Dictionary<string, Scheme?> schemes;

if (!ConfigurationManager.IsInitialized ())
{
// Module initializer / unit-test path — fall back to hard-coded defaults.
ImmutableSortedDictionary<string, Scheme?>? 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<string, Scheme?> 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;
}
}

/// <summary>
/// Gets the name of the specified <see cref="Schemes"/>. Will throw an exception if <paramref name="schemeName"/>
/// is not a built-in Scheme.
Expand Down
35 changes: 30 additions & 5 deletions Terminal.Gui/ViewBase/View.Drawing.Scheme.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,18 +134,43 @@ 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);
if (SchemeManager.TryGetScheme (SchemeName, out Scheme? namedScheme))
{
return namedScheme;
}

Logging.Warning ($"SchemeName '{SchemeName}' not found in current theme. Falling back.");
}

return _scheme!;
return ResolveFallbackScheme ();
Comment thread
tig marked this conversation as resolved.
}
}

/// <summary>
/// Resolves a scheme using the fallback chain when no explicit scheme or valid <see cref="SchemeName"/> is
/// available: <see cref="SuperView"/>'s scheme → "Base" in the current theme → hard-coded "Base".
/// </summary>
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"]!;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,36 @@ public void GetScheme_Throws_On_Invalid_String ()
Assert.Throws<KeyNotFoundException> (() => 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);
}
}

}
83 changes: 83 additions & 0 deletions Tests/UnitTestsParallelizable/ViewBase/Draw/SchemeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -523,4 +523,87 @@ protected override bool OnGettingAttributeForRole (in VisualRole role, ref Attri
}
}

// 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 ();
}

}
Loading