ThemeManager grows a test seam — its singleton ctor now delegates to
three internal seams (isSystemDark / loadPreference / savePreference)
that the production singleton fills with the real registry +
UIPreferences calls. Tests construct via the internal ctor with
stubs so they never touch HKCU or %LOCALAPPDATA% (which would
otherwise flake on CI or pollute the dev's UI state). Apply() and
the SystemEvents subscription are intentionally NOT exercised
here — both require Application.Current and a real dispatcher.
CommandPaletteViewModel.Matches changes from `private static` to
`internal static` — the predicate is the unit worth pinning, and
building a full CommandPaletteViewModel would require a fake
IIsoController + Dispatcher for one test.
New tests:
* src/tests/TeamsISO.App.Tests/Services/ThemeManagerTests.cs (11 cases):
- Set Dark → Light round-trips Preference + ResolveTheme and
persists via the savePreference seam.
- ResolveTheme follows the system probe when Preference is
System (true → Dark, false → Light).
- Toggle from System pins to the opposite of the currently-
resolved theme (not back to System) — explicit click should
have visible effect.
- Toggle from Dark flips Light; Toggle from Light flips Dark.
- Set rejects invalid preferences (case-sensitive: lowercase
"dark", "LIGHT", "", "invalid" all throw ArgumentException
with ParamName=preference).
- Constructor defaults to System when loadPreference returns
null (fresh install / missing prefs file) or an invalid value
(future schema collision).
- Constructor swallows a load exception so the app doesn't lose
theming when ui-prefs.json faults on read.
* src/tests/TeamsISO.App.Tests/ViewModels/CommandPaletteMatchesTests.cs
(16 cases): Theory pinning case-insensitive label / category /
keyword Contains, plus a full-vocabulary spread test counting
hits for "theme" (3), "stop" (1), "ndi" (2), "App" (5 — four
App-category cmds + the Apply transcoder topology substring
match, called out in the assertion because a future move to a
stricter algo has to re-decide that affordance deliberately),
and "xyzzy" (0).
Tests: 56 → 83 in App.Tests; Engine.Tests unchanged at 103.
Total green: 186. Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
241 lines
8.9 KiB
C#
241 lines
8.9 KiB
C#
using System;
|
|
using System.Linq;
|
|
using System.Windows;
|
|
using Microsoft.Win32;
|
|
|
|
namespace TeamsISO.App.Services;
|
|
|
|
/// <summary>
|
|
/// Owns the active theme for the WPF host. Three preferences:
|
|
/// <list type="bullet">
|
|
/// <item><c>System</c> — follows the Windows app-mode setting (default for new
|
|
/// users; re-reads on <see cref="SystemEvents.UserPreferenceChanged"/>).</item>
|
|
/// <item><c>Dark</c> — pin dark regardless of OS.</item>
|
|
/// <item><c>Light</c> — pin light regardless of OS.</item>
|
|
/// </list>
|
|
/// The two color files (<c>Theme.Dark.xaml</c> + <c>Theme.Light.xaml</c>) are
|
|
/// kept in lockstep on the same set of brush keys; this manager swaps the
|
|
/// MergedDictionaries entry at runtime. Styles + control templates in
|
|
/// <c>WildDragonTheme.xaml</c> reach the brushes via <see langword="DynamicResource"/>,
|
|
/// so the visual tree re-resolves without an app restart.
|
|
///
|
|
/// Preference is persisted via <see cref="UIPreferences"/>'s <c>Theme</c> field,
|
|
/// reserved on disk during the v1 → v2 rollout so the rebuild doesn't lose the
|
|
/// operator's choice.
|
|
/// </summary>
|
|
public sealed class ThemeManager
|
|
{
|
|
public static ThemeManager Current { get; } = new(
|
|
isSystemDark: ReadSystemDarkFromRegistry,
|
|
loadPreference: TryLoadPreferenceFromDisk,
|
|
savePreference: TrySavePreferenceToDisk,
|
|
subscribeToSystemPreference: true);
|
|
|
|
private const string DarkUri = "/Themes/Theme.Dark.xaml";
|
|
private const string LightUri = "/Themes/Theme.Light.xaml";
|
|
private const string PreferenceKeySystem = "System";
|
|
private const string PreferenceKeyDark = "Dark";
|
|
private const string PreferenceKeyLight = "Light";
|
|
|
|
// Test seams. The production singleton wires these to the real
|
|
// registry / UIPreferences. Tests construct via the internal ctor
|
|
// with their own stubs so they don't touch HKCU or %LOCALAPPDATA%.
|
|
private readonly Func<bool> _isSystemDark;
|
|
private readonly Action<string> _savePreference;
|
|
|
|
internal ThemeManager(
|
|
Func<bool> isSystemDark,
|
|
Func<string?> loadPreference,
|
|
Action<string> savePreference,
|
|
bool subscribeToSystemPreference)
|
|
{
|
|
_isSystemDark = isSystemDark;
|
|
_savePreference = savePreference;
|
|
|
|
// Hydrate preference from the seam on first access. Disk / load
|
|
// failures fall back to defaults so the app always boots into a
|
|
// deterministic theme.
|
|
try
|
|
{
|
|
var loaded = loadPreference();
|
|
if (IsValidPreference(loaded))
|
|
{
|
|
_preference = loaded!;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Defensive — ctor must not throw or the app loses theming.
|
|
}
|
|
|
|
// Re-evaluate when Windows app-mode flips, but only when the
|
|
// operator hasn't pinned a preference. The explicit choice wins.
|
|
// Tests opt out so they don't latch into a process-wide event.
|
|
if (subscribeToSystemPreference)
|
|
{
|
|
SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged;
|
|
}
|
|
}
|
|
|
|
private string _preference = PreferenceKeySystem;
|
|
|
|
/// <summary>Current preference. One of "System", "Dark", "Light".</summary>
|
|
public string Preference => _preference;
|
|
|
|
/// <summary>Fires after a theme swap with the resolved (absolute) theme.</summary>
|
|
public event EventHandler<string>? Themed;
|
|
|
|
/// <summary>
|
|
/// Resolve the preference to an absolute theme name ("Dark" or "Light")
|
|
/// suitable for the dictionary lookup. "System" resolves to the OS
|
|
/// app-mode at the time of the call.
|
|
/// </summary>
|
|
public string ResolveTheme() => _preference switch
|
|
{
|
|
PreferenceKeyDark => PreferenceKeyDark,
|
|
PreferenceKeyLight => PreferenceKeyLight,
|
|
_ => _isSystemDark() ? PreferenceKeyDark : PreferenceKeyLight,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Set the operator's preference, persist, and apply the resolved theme.
|
|
/// </summary>
|
|
public void Set(string preference)
|
|
{
|
|
if (!IsValidPreference(preference))
|
|
{
|
|
throw new ArgumentException(
|
|
"Preference must be 'System', 'Dark', or 'Light'.",
|
|
nameof(preference));
|
|
}
|
|
|
|
_preference = preference;
|
|
try { _savePreference(preference); }
|
|
catch { /* persistence is best-effort */ }
|
|
Apply();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cycle the theme between Dark and Light (one-click toggle from the header
|
|
/// theme icon). If the current preference is "System", the cycle pins to
|
|
/// the OPPOSITE of the currently-resolved theme so the click has a
|
|
/// visible effect.
|
|
/// </summary>
|
|
public void Toggle()
|
|
{
|
|
var current = ResolveTheme();
|
|
Set(current == PreferenceKeyDark ? PreferenceKeyLight : PreferenceKeyDark);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply the current resolved theme. Should be called once during app
|
|
/// startup (after Application.Current.Resources is initialized) and
|
|
/// whenever <see cref="Preference"/> changes — <see cref="Set"/> already
|
|
/// does the latter for you.
|
|
/// </summary>
|
|
public void Apply()
|
|
{
|
|
var theme = ResolveTheme();
|
|
var uri = theme == PreferenceKeyDark ? DarkUri : LightUri;
|
|
SwapColorDictionary(uri);
|
|
Themed?.Invoke(this, theme);
|
|
}
|
|
|
|
private static void SwapColorDictionary(string newUri)
|
|
{
|
|
var app = Application.Current;
|
|
if (app is null) return;
|
|
var dicts = app.Resources.MergedDictionaries;
|
|
|
|
// Find the existing theme color dictionary by source URI. We
|
|
// distinguish "color" dictionaries from "WildDragonTheme" by name —
|
|
// the color files are at Theme.Dark.xaml / Theme.Light.xaml; the
|
|
// styles file is at WildDragonTheme.xaml. Replace in place to
|
|
// preserve merge order so DynamicResource refs resolve to the new
|
|
// brushes.
|
|
ResourceDictionary? old = null;
|
|
for (var i = 0; i < dicts.Count; i++)
|
|
{
|
|
var src = dicts[i].Source?.OriginalString ?? string.Empty;
|
|
if (src.EndsWith("Theme.Dark.xaml", StringComparison.OrdinalIgnoreCase) ||
|
|
src.EndsWith("Theme.Light.xaml", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
old = dicts[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Relative) };
|
|
if (old is null)
|
|
{
|
|
dicts.Insert(0, fresh);
|
|
}
|
|
else
|
|
{
|
|
var idx = dicts.IndexOf(old);
|
|
dicts.RemoveAt(idx);
|
|
dicts.Insert(idx, fresh);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read Windows' AppsUseLightTheme registry value. 1 = light, 0 = dark.
|
|
/// Returns true (dark) on any read failure — the dark scene is the
|
|
/// default per DESIGN.md so a missing value still lands somewhere
|
|
/// sensible. Backs the singleton's _isSystemDark seam.
|
|
/// </summary>
|
|
private static bool ReadSystemDarkFromRegistry()
|
|
{
|
|
try
|
|
{
|
|
using var key = Registry.CurrentUser.OpenSubKey(
|
|
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize");
|
|
if (key?.GetValue("AppsUseLightTheme") is int value)
|
|
{
|
|
return value == 0;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Registry access can fail under unusual security contexts.
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Load the operator's persisted theme preference from
|
|
/// %LOCALAPPDATA%\TeamsISO\ui-prefs.json. Returns null on any read
|
|
/// failure (missing file, corrupt JSON, schema mismatch) so the
|
|
/// caller falls back to the in-memory default of "System". Backs
|
|
/// the singleton's loadPreference seam.
|
|
/// </summary>
|
|
private static string? TryLoadPreferenceFromDisk()
|
|
{
|
|
try { return UIPreferences.Load().Theme; }
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Persist the operator's theme preference to ui-prefs.json. Errors
|
|
/// are swallowed — persistence is best-effort and a single failed
|
|
/// save shouldn't break the in-session UI experience. Backs the
|
|
/// singleton's savePreference seam.
|
|
/// </summary>
|
|
private static void TrySavePreferenceToDisk(string preference)
|
|
{
|
|
try { UIPreferences.SetTheme(preference); }
|
|
catch { /* best-effort */ }
|
|
}
|
|
|
|
private void OnSystemPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
|
|
{
|
|
if (e.Category != UserPreferenceCategory.General) return;
|
|
if (_preference != PreferenceKeySystem) return;
|
|
// Marshal to the UI thread — registry events fire on a system pool
|
|
// thread and resource dictionary mutations require dispatcher access.
|
|
Application.Current?.Dispatcher.BeginInvoke(new Action(Apply));
|
|
}
|
|
|
|
private static bool IsValidPreference(string? value) =>
|
|
value is PreferenceKeySystem or PreferenceKeyDark or PreferenceKeyLight;
|
|
}
|