teamsiso/src/TeamsISO.App.WinUI/Services/ThemeManager.cs
Zac Gaetano f7249c31c2
Some checks failed
CI / build-and-test (push) Failing after 26s
feat(winui3): persist theme preference to UIPreferences
Services/UIPreferences.cs — mirror of the WPF host's UIPreferences,
sharing %LOCALAPPDATA%\TeamsISO\ui-prefs.json on disk. Adds a Theme
field ("System" / "Dark" / "Light") that the WPF host's UIPreferences
will pick up when its theme system lands (JSON deserialization is
forward-compatible — extra fields are ignored, missing fields fall
back to defaults).

ThemeManager hydration:
  * Constructor reads UIPreferences.Theme on first .Current access.
  * Defaults to "System" when the file is missing, the value is
    invalid, or load throws (defensive — ThemeManager.Current is a
    static singleton, a throw would break theme resolution app-wide).
ThemeManager.Set persistence:
  * Calls UIPreferences.SetTheme(preference) which does a read-modify-
    write of the JSON (so other fields aren't trampled).
  * Persistence is best-effort wrapped in try/catch — disk full,
    permission denied, etc. fall through and the in-memory state still
    holds for the session.

End-to-end now: title-bar sun/moon toggle → ThemeManager.Toggle →
.Set("Dark"/"Light") → JSON write → next launch reads the preference
and applies before the first frame. Operator's theme choice survives
across launches and across host swaps once the WPF host learns the
field.
2026-05-13 21:35:31 -04:00

127 lines
4.6 KiB
C#

using System;
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Windows.UI;
using Windows.UI.ViewManagement;
namespace TeamsISO.App.WinUI.Services;
/// <summary>
/// Owns the active theme for the WinUI 3 host. Three preferences:
/// <c>System</c> follows the Windows app-mode setting (default for new
/// users); <c>Dark</c> and <c>Light</c> pin one regardless of the OS choice.
/// The persistence path will land alongside the existing UIPreferences in
/// the next commit — for now state lives in-process.
///
/// All public mutations push <see cref="Themed"/> to subscribers so the
/// host (MainWindow) can update the AppWindow title-bar button colors
/// (system buttons aren't part of the visual tree and need a separate
/// poke when ElementTheme changes).
/// </summary>
public sealed class ThemeManager
{
public static ThemeManager Current { get; } = new();
private ThemeManager()
{
_uiSettings = new UISettings();
_uiSettings.ColorValuesChanged += OnSystemColorsChanged;
// Hydrate the preference from disk so the operator's choice
// survives across launches. Defaults to "System" if the prefs
// file is missing or unreadable (Load() catches its own errors).
try
{
var prefs = UIPreferences.Load();
if (prefs.Theme == "System" || prefs.Theme == "Dark" || prefs.Theme == "Light")
{
_preference = prefs.Theme;
}
}
catch
{
// Defensive — ThemeManager.Current is a static singleton; a
// throw here would prevent the app from getting any theme.
}
}
private readonly UISettings _uiSettings;
private string _preference = "System";
public string Preference => _preference;
public event EventHandler<ElementTheme>? Themed;
/// <summary>
/// Resolve the preference to an absolute <see cref="ElementTheme"/>
/// suitable for <see cref="FrameworkElement.RequestedTheme"/>.
/// <c>System</c> resolves to the OS app-mode.
/// </summary>
public ElementTheme ResolveTheme() => _preference switch
{
"Dark" => ElementTheme.Dark,
"Light" => ElementTheme.Light,
_ => IsSystemDark() ? ElementTheme.Dark : ElementTheme.Light,
};
public bool PreferenceMatches(string value) => string.Equals(_preference, value, StringComparison.Ordinal);
/// <summary>
/// Cycle dark ↔ light from the title-bar toggle. If the current
/// preference is <c>System</c>, the cycle pins to the opposite of the
/// currently-resolved theme so the click has a visible effect.
/// </summary>
public ElementTheme Toggle()
{
var current = ResolveTheme();
Set(current == ElementTheme.Dark ? "Light" : "Dark");
return ResolveTheme();
}
/// <summary>Set the preference, persist to disk, broadcast the resolved theme.</summary>
public void Set(string preference)
{
if (preference != "System" && preference != "Dark" && preference != "Light")
{
throw new ArgumentException("Preference must be System, Dark, or Light.", nameof(preference));
}
_preference = preference;
try { UIPreferences.SetTheme(preference); }
catch { /* persistence is best-effort */ }
Themed?.Invoke(this, ResolveTheme());
}
private bool IsSystemDark()
{
// UISettings.GetColorValue(UIColorType.Background) returns
// black-ish in dark mode, white-ish in light mode — the most
// reliable cross-version check for app mode on desktop WinUI 3.
var bg = _uiSettings.GetColorValue(UIColorType.Background);
return ((5 * bg.G) + (2 * bg.R) + bg.B) < 8 * 128;
}
private void OnSystemColorsChanged(UISettings sender, object args)
{
// Only re-broadcast if the operator hasn't pinned a preference —
// otherwise the explicit choice wins regardless of what the OS does.
if (_preference == "System")
{
Themed?.Invoke(this, ResolveTheme());
}
}
/// <summary>
/// Compute the AppWindow title-bar foreground for the given resolved
/// theme so the system min/max/close buttons stay readable.
/// </summary>
public static Color TitleBarForegroundFor(ElementTheme theme) =>
theme == ElementTheme.Dark
? Color.FromArgb(0xFF, 0xF4, 0xF4, 0xF6)
: Color.FromArgb(0xFF, 0x0A, 0x0A, 0x0A);
public static Color TitleBarHoverBgFor(ElementTheme theme) =>
theme == ElementTheme.Dark
? Color.FromArgb(0xFF, 0x33, 0x34, 0x3A)
: Color.FromArgb(0xFF, 0xEC, 0xEE, 0xF1);
}