teamsiso/src/TeamsISO.App/Services/ThemeManager.cs
Zac Gaetano fbcc56289e test: ThemeManager + CommandPaletteViewModel.Matches coverage
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>
2026-05-15 20:47:25 -04:00

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;
}