Some checks failed
CI / build-and-test (push) Failing after 31s
- Theme split: Theme.Dark.xaml + Theme.Light.xaml + ThemeManager - New shell: 32px header (mark + wordmark + 3 icons), 40px transport strip, conditional meeting bar, slide-over settings drawer - Removed: 72px rail, 380px permanent settings panel, 6-column footer, custom chromeless title bar buttons - Ctrl+T toggles theme; follows Windows app-mode by default - Shape doc at docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md
194 lines
7.1 KiB
C#
194 lines
7.1 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();
|
|
|
|
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";
|
|
|
|
private ThemeManager()
|
|
{
|
|
// Hydrate preference from disk on first access. UIPreferences.Load()
|
|
// is best-effort — disk failures fall back to defaults so the app
|
|
// always boots into a deterministic theme.
|
|
try
|
|
{
|
|
var prefs = UIPreferences.Load();
|
|
if (IsValidPreference(prefs.Theme))
|
|
{
|
|
_preference = prefs.Theme;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Defensive — singleton 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.
|
|
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 { UIPreferences.SetTheme(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.
|
|
/// </summary>
|
|
private static bool IsSystemDark()
|
|
{
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|