diff --git a/DesktopClock.Tests/ClockThemeTests.cs b/DesktopClock.Tests/ClockThemeTests.cs index 0484365..4a85d13 100644 --- a/DesktopClock.Tests/ClockThemeTests.cs +++ b/DesktopClock.Tests/ClockThemeTests.cs @@ -3,6 +3,7 @@ using System.Windows; using System.Windows.Media; using DesktopClock.Properties; +using DesktopClock.Utilities; namespace DesktopClock.Tests; @@ -82,4 +83,51 @@ public void Apply_SetsAppearanceFromTheme() Assert.Equal(1.5, settings.OutlineThickness); Assert.Equal(1, settings.TextOpacity); } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ThemeDefaults_AreReadableInLightAndDarkMode(bool isLightTheme) + { + // A fresh install and the System preset follow light or dark mode but not the accent, which can be any shade. + var (text, outer) = SystemThemeService.GetThemeDefaults(isLightTheme); + + Assert.True(ClockTheme.Contrast(text, outer) >= 7, $"{text} on {outer}"); + } + + [Theory] + [InlineData("#3B003B", "#FFFFFF")] // Plum + [InlineData("#0078D4", "#FFFFFF")] // Default Windows blue keeps white text. + [InlineData("#D50056", "#FFFFFF")] // Dark pink + [InlineData("#FFB900", "#1A1A1A")] // Gold + [InlineData("#00CC6A", "#1A1A1A")] // Light green + [InlineData("#FFFFFF", "#1A1A1A")] + [InlineData("#000000", "#FFFFFF")] + public void ReadableTextOn_PicksWhiteOrDarkByContrast(string background, string expected) + { + var text = ClockTheme.ReadableTextOn((Color)ColorConverter.ConvertFromString(background)); + + Assert.Equal((Color)ColorConverter.ConvertFromString(expected), text); + } + + [Theory] + [InlineData("#3B003B", "#FFFFFF")] // Plum keeps white text. + [InlineData("#FFB900", "#1A1A1A")] // Gold gets dark text. + public void AccentTheme_PicksReadableTextForTheAccent(string accent, string expectedText) + { + var theme = ClockTheme.CreateAccentTheme((Color)ColorConverter.ConvertFromString(accent)); + + Assert.Equal((Color)ColorConverter.ConvertFromString(accent), theme.OuterColor); + Assert.Equal((Color)ColorConverter.ConvertFromString(expectedText), theme.TextColor); + } + + [Fact] + public void ReadableTextOn_IsAtLeastFourToOneOnEveryGray() + { + for (var v = 0; v <= 255; v++) + { + var background = Color.FromRgb((byte)v, (byte)v, (byte)v); + Assert.True(ClockTheme.Contrast(ClockTheme.ReadableTextOn(background), background) >= 4, $"gray {v}"); + } + } } diff --git a/DesktopClock/ClockTheme.cs b/DesktopClock/ClockTheme.cs index 5a09b61..f48e47b 100644 --- a/DesktopClock/ClockTheme.cs +++ b/DesktopClock/ClockTheme.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Windows.Media; using DesktopClock.Properties; @@ -45,9 +46,7 @@ public static IReadOnlyList GetBuiltInThemes() return new[] { CreateSystemTheme(), - new ClockTheme(Loc.Get("ThemeAccent"), "Segoe UI", "SemiBold", - Color.FromRgb(0xFF, 0xFF, 0xFF), SystemThemeService.GetSystemAccentColor(), - backgroundEnabled: true, backgroundOpacity: 1, backgroundCornerRadius: 1, outlineThickness: 0.2), + CreateAccentTheme(SystemThemeService.GetSystemAccentColor()), new ClockTheme(Loc.Get("ThemeSmoke"), "Segoe UI", "Normal", Color.FromRgb(0xF2, 0xF2, 0xF2), Color.FromRgb(0x0A, 0x0A, 0x10), backgroundEnabled: true, backgroundOpacity: 0.55, backgroundCornerRadius: 1, outlineThickness: 0.2), @@ -69,6 +68,40 @@ public static IReadOnlyList GetBuiltInThemes() }; } + /// + /// The Accent look: the Windows accent as the background, with whichever text color reads best on it. + /// + public static ClockTheme CreateAccentTheme(Color accent) => + new(Loc.Get("ThemeAccent"), "Segoe UI", "SemiBold", ReadableTextOn(accent), accent, + backgroundEnabled: true, backgroundOpacity: 1, backgroundCornerRadius: 1, outlineThickness: 0.2); + + /// + /// White or near-black text, whichever stands out more on the given background, so a light accent like yellow still gets readable text. + /// + public static Color ReadableTextOn(Color background) + { + var white = Color.FromRgb(0xFF, 0xFF, 0xFF); + var black = Color.FromRgb(0x1A, 0x1A, 0x1A); + return Contrast(white, background) >= Contrast(black, background) ? white : black; + } + + /// + /// The WCAG contrast ratio between two colors, from 1 for the same color to 21 for black and white. + /// + public static double Contrast(Color a, Color b) + { + static double Channel(byte value) + { + var c = value / 255.0; + return c <= 0.03928 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4); + } + + static double Luminance(Color c) => (0.2126 * Channel(c.R)) + (0.7152 * Channel(c.G)) + (0.0722 * Channel(c.B)); + + var (lighter, darker) = (Math.Max(Luminance(a), Luminance(b)), Math.Min(Luminance(a), Luminance(b))); + return (lighter + 0.05) / (darker + 0.05); + } + /// /// Applies this look to the given settings; the clock updates immediately. /// @@ -90,7 +123,7 @@ public void Apply(Settings settings) } /// - /// The factory look: accent-colored text seeded from the Windows theme, like a fresh install. + /// The factory look: neutral text matching the Windows light or dark theme, like a fresh install. /// private static ClockTheme CreateSystemTheme() { diff --git a/DesktopClock/Utilities/SystemThemeService.cs b/DesktopClock/Utilities/SystemThemeService.cs index 69f74f2..65e300f 100644 --- a/DesktopClock/Utilities/SystemThemeService.cs +++ b/DesktopClock/Utilities/SystemThemeService.cs @@ -12,7 +12,9 @@ public static class SystemThemeService { // Fallbacks match the app's existing default colors. private static readonly Color DefaultAccentColor = Color.FromRgb(0, 120, 215); + private static readonly Color LightThemeTextColor = Color.FromRgb(33, 33, 33); private static readonly Color LightThemeOuterColor = Color.FromRgb(247, 247, 247); + private static readonly Color DarkThemeTextColor = Color.FromRgb(242, 242, 242); private static readonly Color DarkThemeOuterColor = Color.FromRgb(32, 32, 32); // Theme and colorization values stored under HKCU. @@ -23,7 +25,7 @@ public static class SystemThemeService private const string ColorizationColorValueName = "ColorizationColor"; /// - /// Tries to build default text/outer colors from the system theme and accent color. + /// Tries to build default text/outer colors that match the system's light or dark theme. /// /// /// Returns false when the system theme cannot be read (older Windows or missing keys). @@ -36,11 +38,19 @@ public static bool TryGetThemeDefaults(out Color textColor, out Color outerColor if (!TryGetSystemThemeIsLight(out var isLightTheme)) return false; - textColor = GetSystemAccentColor(); - outerColor = isLightTheme ? LightThemeOuterColor : DarkThemeOuterColor; + (textColor, outerColor) = GetThemeDefaults(isLightTheme); return true; } + /// + /// The default text and background colors for the light or dark theme. + /// + /// + /// The text doesn't use the accent color, since Windows allows any shade and a dark accent in dark mode or a light one in light mode can't be read. + /// + public static (Color Text, Color Outer) GetThemeDefaults(bool isLightTheme) => + isLightTheme ? (LightThemeTextColor, LightThemeOuterColor) : (DarkThemeTextColor, DarkThemeOuterColor); + /// /// Whether Windows is using the light app theme; defaults to light when it can't be read. ///