Skip to content
143 changes: 143 additions & 0 deletions Examples/UICatalog/Scenarios/ThemeFallback.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#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 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);
}
}
39 changes: 32 additions & 7 deletions Examples/UICatalog/UICatalogRunnable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ private ListView CreateCategoryList ()
SuperViewRendersLineCanvas = true,
Source = new ListWrapper<string> (CachedCategories)
};

//categoryList.Border.Settings = BorderSettings.Title | BorderSettings.Tab;
//categoryList.Border.TabSide = Side.Top;
//categoryList.Border.Thickness = new Thickness (1, 2, 1, 1);
Expand Down Expand Up @@ -721,20 +722,31 @@ public static bool ShowStatusBar
/// or assigned to a <see cref="MenuItem.Key"/> on a menu item. Falls back to
/// <see cref="Key.F12"/> if all are taken.
/// </summary>
private Key GetFirstUnboundFKey ()
/// <param name="ignoreKeys"></param>
private Key GetFirstUnboundFKey (HashSet<Key> 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<Key> boundKeys = [];
HashSet<Key> boundKeys = ignoreKeys;
Comment thread
tig marked this conversation as resolved.

foreach (Key fKey in fKeys)
{
if (Application.KeyBindings.TryGet (fKey, out _))
if (App?.Keyboard.KeyBindings.TryGet (fKey, out _) is true)
{
boundKeys.Add (fKey);
}
Expand All @@ -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);
Comment thread
tig marked this conversation as resolved.
}
}

foreach (Key fKey in fKeys)
{
if (!boundKeys.Contains (fKey))
Expand All @@ -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
{
Expand All @@ -786,7 +811,7 @@ private StatusBar CreateStatusBar ()
CommandView = _force16ColorsShortcutCb,
HelpText = "",
BindKeyToApplication = true,
Key = Key.F7,
Key = GetFirstUnboundFKey ([statusBarShortcut.Key]),
Action = () =>
{
Driver.Force16Colors = !Driver.Force16Colors;
Expand Down
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
37 changes: 32 additions & 5 deletions Terminal.Gui/ViewBase/View.Drawing.Scheme.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ();
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>
/// Called when the <see cref="Scheme"/> for the <see cref="View"/> is being retrieved. Subclasses can return
/// true to stop further processing and optionally set <paramref name="scheme"/> to a different value.
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);
}
}

}
Loading
Loading