Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions DesktopClock.Tests/ClockThemeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Windows;
using System.Windows.Media;
using DesktopClock.Properties;
using DesktopClock.Utilities;

namespace DesktopClock.Tests;

Expand Down Expand Up @@ -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}");
}
}
}
41 changes: 37 additions & 4 deletions DesktopClock/ClockTheme.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Windows.Media;
using DesktopClock.Properties;
Expand Down Expand Up @@ -45,9 +46,7 @@ public static IReadOnlyList<ClockTheme> 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),
Expand All @@ -69,6 +68,40 @@ public static IReadOnlyList<ClockTheme> GetBuiltInThemes()
};
}

/// <summary>
/// The Accent look: the Windows accent as the background, with whichever text color reads best on it.
/// </summary>
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);

/// <summary>
/// White or near-black text, whichever stands out more on the given background, so a light accent like yellow still gets readable text.
/// </summary>
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;
}

/// <summary>
/// The WCAG contrast ratio between two colors, from 1 for the same color to 21 for black and white.
/// </summary>
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);
}

/// <summary>
/// Applies this look to the given settings; the clock updates immediately.
/// </summary>
Expand All @@ -90,7 +123,7 @@ public void Apply(Settings settings)
}

/// <summary>
/// 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.
/// </summary>
private static ClockTheme CreateSystemTheme()
{
Expand Down
16 changes: 13 additions & 3 deletions DesktopClock/Utilities/SystemThemeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -23,7 +25,7 @@ public static class SystemThemeService
private const string ColorizationColorValueName = "ColorizationColor";

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Returns <c>false</c> when the system theme cannot be read (older Windows or missing keys).
Expand All @@ -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;
}

/// <summary>
/// The default text and background colors for the light or dark theme.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static (Color Text, Color Outer) GetThemeDefaults(bool isLightTheme) =>
isLightTheme ? (LightThemeTextColor, LightThemeOuterColor) : (DarkThemeTextColor, DarkThemeOuterColor);

/// <summary>
/// Whether Windows is using the light app theme; defaults to light when it can't be read.
/// </summary>
Expand Down
Loading