From a759413a298531124c7586d77204720ab51048b5 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 3 Jul 2026 18:54:51 +0100 Subject: [PATCH] Add dark mode support --- src/SwitchifyPc.App/App.xaml | 5 + src/SwitchifyPc.App/App.xaml.cs | 6 + .../Chrome/SwitchifyTitleBar.xaml | 16 +- src/SwitchifyPc.App/MainWindow.xaml | 119 ++++++------- src/SwitchifyPc.App/SettingsWindow.xaml | 92 +++++----- src/SwitchifyPc.App/Themes/AppTheme.cs | 158 ++++++++++++++++++ src/SwitchifyPc.App/Themes/Theme.Dark.xaml | 25 +++ src/SwitchifyPc.App/Themes/Theme.Light.xaml | 25 +++ src/SwitchifyPc.Tests/AppThemeManagerTests.cs | 122 ++++++++++++++ src/SwitchifyPc.Tests/ButtonStyleTests.cs | 13 +- src/SwitchifyPc.Tests/MainWindowTests.cs | 28 ++++ src/SwitchifyPc.Tests/SettingsWindowTests.cs | 29 ++++ .../SwitchifyTitleBarTests.cs | 37 ++++ src/SwitchifyPc.Tests/ThemeResourceTests.cs | 125 ++++++++++++++ src/SwitchifyPc.Tests/ThemeXamlGuardTests.cs | 56 +++++++ .../WindowsAppThemeProviderTests.cs | 38 +++++ src/SwitchifyPc.Tests/WpfTestCollection.cs | 35 ++++ 17 files changed, 802 insertions(+), 127 deletions(-) create mode 100644 src/SwitchifyPc.App/Themes/AppTheme.cs create mode 100644 src/SwitchifyPc.App/Themes/Theme.Dark.xaml create mode 100644 src/SwitchifyPc.App/Themes/Theme.Light.xaml create mode 100644 src/SwitchifyPc.Tests/AppThemeManagerTests.cs create mode 100644 src/SwitchifyPc.Tests/ThemeResourceTests.cs create mode 100644 src/SwitchifyPc.Tests/ThemeXamlGuardTests.cs create mode 100644 src/SwitchifyPc.Tests/WindowsAppThemeProviderTests.cs diff --git a/src/SwitchifyPc.App/App.xaml b/src/SwitchifyPc.App/App.xaml index a3307d5..f8b4886 100644 --- a/src/SwitchifyPc.App/App.xaml +++ b/src/SwitchifyPc.App/App.xaml @@ -2,5 +2,10 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + + + + + diff --git a/src/SwitchifyPc.App/App.xaml.cs b/src/SwitchifyPc.App/App.xaml.cs index 22c1559..003aff7 100644 --- a/src/SwitchifyPc.App/App.xaml.cs +++ b/src/SwitchifyPc.App/App.xaml.cs @@ -14,6 +14,7 @@ using SwitchifyPc.Core.Startup; using SwitchifyPc.Core.Ui; using SwitchifyPc.Core.Updates; +using SwitchifyPc.App.Themes; using SwitchifyPc.Windows.AppLifecycle; using SwitchifyPc.Windows.Bluetooth; using SwitchifyPc.Windows.CursorOverlay; @@ -44,6 +45,7 @@ public partial class App : System.Windows.Application private MouseRepeatController? mouseRepeatController; private WindowsCursorOverlayNotifier? cursorOverlay; private DispatcherTimer? pairingExpiryTimer; + private AppThemeManager? themeManager; private bool isQuitting; protected override void OnStartup(StartupEventArgs e) @@ -75,6 +77,8 @@ protected override void OnStartup(StartupEventArgs e) existingInstanceSignal.Start( () => Dispatcher.BeginInvoke(ShowMainWindow), () => Dispatcher.BeginInvoke(QuitApplication)); + themeManager = AppThemeManager.ForCurrentApplication(new WindowsAppThemeProvider()); + themeManager.Start(); updateService = CreateUpdateService(); pairingApprovalManager = CreatePairingApprovalManager(); bluetoothStatusTracker = new BluetoothStatusTracker(onStatusChanged: UpdateBluetoothState); @@ -113,6 +117,8 @@ protected override void OnExit(ExitEventArgs e) bluetoothServer = null; cursorOverlay?.Dispose(); cursorOverlay = null; + themeManager?.Dispose(); + themeManager = null; commandExecutor = null; bluetoothFrameProcessor = null; bluetoothStatusTracker = null; diff --git a/src/SwitchifyPc.App/Chrome/SwitchifyTitleBar.xaml b/src/SwitchifyPc.App/Chrome/SwitchifyTitleBar.xaml index c1e9948..605dc3d 100644 --- a/src/SwitchifyPc.App/Chrome/SwitchifyTitleBar.xaml +++ b/src/SwitchifyPc.App/Chrome/SwitchifyTitleBar.xaml @@ -3,8 +3,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework" Height="44" - Background="#D32F2F" - Foreground="White" + Background="{DynamicResource ChromeBackground}" + Foreground="{DynamicResource ChromeForeground}" shell:WindowChrome.IsHitTestVisibleInChrome="True" MouseLeftButtonDown="TitleBar_MouseLeftButtonDown"> @@ -16,7 +16,7 @@ - + @@ -31,10 +31,10 @@ - + - + @@ -56,10 +56,10 @@ - + - + @@ -90,7 +90,7 @@ TextTrimming="CharacterEllipsis" FontSize="13" FontWeight="SemiBold" - Foreground="White" /> + Foreground="{DynamicResource ChromeForeground}" /> @@ -29,26 +29,13 @@ - - - - - - - - - - - - - @@ -188,31 +175,31 @@ - + @@ -234,9 +221,9 @@ TitleText="Switchify PC" ShowMinimizeButton="True" /> - + + Background="{DynamicResource AppBackground}" /> @@ -281,7 +268,7 @@ Margin="8,0,0,0" VerticalAlignment="Center" Text="{Binding StatusBadgeLabel}" - Foreground="{StaticResource Text}" + Foreground="{DynamicResource Text}" FontSize="12" FontWeight="SemiBold" /> @@ -294,7 +281,7 @@ @@ -313,7 +300,7 @@ @@ -328,7 +315,7 @@ + Foreground="{DynamicResource Text}" /> @@ -378,8 +365,8 @@ + Foreground="{DynamicResource Text}" /> @@ -415,8 +402,8 @@ + Foreground="{DynamicResource OnAccentContainer}" /> + Background="{DynamicResource Surface}"> @@ -445,10 +432,10 @@ + Foreground="{DynamicResource Text}" /> @@ -476,7 +463,7 @@ diff --git a/src/SwitchifyPc.App/SettingsWindow.xaml b/src/SwitchifyPc.App/SettingsWindow.xaml index c6e586d..1599a7b 100644 --- a/src/SwitchifyPc.App/SettingsWindow.xaml +++ b/src/SwitchifyPc.App/SettingsWindow.xaml @@ -15,7 +15,7 @@ ResizeMode="NoResize" Icon="Assets/icon.ico" FontFamily="Segoe UI" - Background="#D32F2F" + Background="{DynamicResource ChromeBackground}" WindowStyle="None" WindowStartupLocation="CenterScreen" d:DataContext="{d:DesignInstance Type=ui:SettingsViewModel, IsDesignTimeCreatable=True}"> @@ -30,21 +30,13 @@ - - - - - - - - @@ -163,7 +155,7 @@ - + @@ -180,12 +172,12 @@ - + - - - + + + @@ -197,10 +189,10 @@ - - + + - + @@ -219,12 +211,12 @@ - + - - - + + + @@ -241,12 +233,12 @@ - - + + - + @@ -260,16 +252,16 @@ + Background="{DynamicResource AppBackground}" /> + Foreground="{DynamicResource ChromeForeground}" /> @@ -280,8 +272,8 @@ - + Foreground="{DynamicResource Text}" /> @@ -677,7 +669,7 @@ Checked="CursorOverlayColorWhite_Checked" Style="{StaticResource SegmentRadio}"> - + diff --git a/src/SwitchifyPc.App/Themes/AppTheme.cs b/src/SwitchifyPc.App/Themes/AppTheme.cs new file mode 100644 index 0000000..2fb9b77 --- /dev/null +++ b/src/SwitchifyPc.App/Themes/AppTheme.cs @@ -0,0 +1,158 @@ +using System.Windows; +using System.Windows.Threading; +using System.IO.Packaging; +using Microsoft.Win32; +using WpfApplication = System.Windows.Application; + +namespace SwitchifyPc.App.Themes; + +public enum AppTheme +{ + Light, + Dark +} + +public interface IAppThemeProvider +{ + AppTheme GetCurrentTheme(); +} + +public sealed class WindowsAppThemeProvider : IAppThemeProvider +{ + private const string PersonalizeRegistryPath = @"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; + private const string AppsUseLightThemeValueName = "AppsUseLightTheme"; + private readonly Func readAppsUseLightTheme; + + public WindowsAppThemeProvider(Func? readAppsUseLightTheme = null) + { + this.readAppsUseLightTheme = readAppsUseLightTheme ?? (() => + Registry.GetValue(PersonalizeRegistryPath, AppsUseLightThemeValueName, 1)); + } + + public AppTheme GetCurrentTheme() + { + try + { + return readAppsUseLightTheme() switch + { + int value when value == 0 => AppTheme.Dark, + long value when value == 0 => AppTheme.Dark, + string value when string.Equals(value, "0", StringComparison.Ordinal) => AppTheme.Dark, + _ => AppTheme.Light + }; + } + catch + { + return AppTheme.Light; + } + } +} + +public sealed class AppThemeManager : IDisposable +{ + public const string SwitchifyThemeResourceKey = "__SwitchifyTheme"; + public static readonly Uri LightThemeUri = PackUriHelper.Create( + new Uri("application:///", UriKind.Absolute), + new Uri("/Themes/Theme.Light.xaml", UriKind.Relative)); + public static readonly Uri DarkThemeUri = PackUriHelper.Create( + new Uri("application:///", UriKind.Absolute), + new Uri("/Themes/Theme.Dark.xaml", UriKind.Relative)); + + private readonly ResourceDictionary resources; + private readonly Dispatcher dispatcher; + private readonly IAppThemeProvider themeProvider; + private readonly Func createDictionary; + private bool isListening; + + public AppThemeManager( + ResourceDictionary resources, + Dispatcher dispatcher, + IAppThemeProvider themeProvider, + Func? createDictionary = null) + { + this.resources = resources; + this.dispatcher = dispatcher; + this.themeProvider = themeProvider; + this.createDictionary = createDictionary ?? CreateThemeDictionary; + } + + public static AppThemeManager ForCurrentApplication(IAppThemeProvider themeProvider) + { + WpfApplication application = WpfApplication.Current ?? throw new InvalidOperationException("No WPF application is available."); + return new AppThemeManager(application.Resources, application.Dispatcher, themeProvider); + } + + public void Start() + { + ApplyCurrentTheme(); + if (isListening) return; + + SystemEvents.UserPreferenceChanged += SystemEvents_UserPreferenceChanged; + isListening = true; + } + + public void ApplyCurrentTheme() + { + ApplyTheme(themeProvider.GetCurrentTheme()); + } + + public void ApplyTheme(AppTheme theme) + { + ResourceDictionary next; + try + { + next = createDictionary(theme); + } + catch + { + return; + } + + next[SwitchifyThemeResourceKey] = theme.ToString(); + + for (int index = resources.MergedDictionaries.Count - 1; index >= 0; index--) + { + ResourceDictionary dictionary = resources.MergedDictionaries[index]; + if (IsSwitchifyThemeDictionary(dictionary)) + { + resources.MergedDictionaries.RemoveAt(index); + } + } + + resources.MergedDictionaries.Add(next); + } + + public void Dispose() + { + if (!isListening) return; + + SystemEvents.UserPreferenceChanged -= SystemEvents_UserPreferenceChanged; + isListening = false; + } + + private void SystemEvents_UserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e) + { + if (dispatcher.CheckAccess()) + { + ApplyCurrentTheme(); + return; + } + + dispatcher.BeginInvoke(ApplyCurrentTheme); + } + + private static ResourceDictionary CreateThemeDictionary(AppTheme theme) + { + return new ResourceDictionary + { + Source = theme == AppTheme.Dark ? DarkThemeUri : LightThemeUri + }; + } + + private static bool IsSwitchifyThemeDictionary(ResourceDictionary dictionary) + { + return dictionary.Source is not null && + (dictionary.Source == LightThemeUri || dictionary.Source == DarkThemeUri) || + dictionary.Contains(SwitchifyThemeResourceKey); + } +} diff --git a/src/SwitchifyPc.App/Themes/Theme.Dark.xaml b/src/SwitchifyPc.App/Themes/Theme.Dark.xaml new file mode 100644 index 0000000..79123ab --- /dev/null +++ b/src/SwitchifyPc.App/Themes/Theme.Dark.xaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SwitchifyPc.App/Themes/Theme.Light.xaml b/src/SwitchifyPc.App/Themes/Theme.Light.xaml new file mode 100644 index 0000000..14f1ab1 --- /dev/null +++ b/src/SwitchifyPc.App/Themes/Theme.Light.xaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SwitchifyPc.Tests/AppThemeManagerTests.cs b/src/SwitchifyPc.Tests/AppThemeManagerTests.cs new file mode 100644 index 0000000..0141e23 --- /dev/null +++ b/src/SwitchifyPc.Tests/AppThemeManagerTests.cs @@ -0,0 +1,122 @@ +using System.Windows; +using System.Windows.Media; +using SwitchifyPc.App.Themes; + +namespace SwitchifyPc.Tests; + +[Collection(WpfTestCollection.Name)] +public sealed class AppThemeManagerTests +{ + [Fact] + public void AppliesLightThemeDictionary() + { + RunOnSta(() => + { + ResourceDictionary resources = new(); + AppThemeManager manager = Manager(resources, AppTheme.Light); + + manager.ApplyCurrentTheme(); + + ResourceDictionary dictionary = Assert.Single(resources.MergedDictionaries); + Assert.Equal(AppTheme.Light.ToString(), dictionary[AppThemeManager.SwitchifyThemeResourceKey]); + }); + } + + [Fact] + public void AppliesDarkThemeDictionary() + { + RunOnSta(() => + { + ResourceDictionary resources = new(); + AppThemeManager manager = Manager(resources, AppTheme.Dark); + + manager.ApplyCurrentTheme(); + + ResourceDictionary dictionary = Assert.Single(resources.MergedDictionaries); + Assert.Equal(AppTheme.Dark.ToString(), dictionary[AppThemeManager.SwitchifyThemeResourceKey]); + }); + } + + [Fact] + public void SwitchingThemeReplacesPreviousThemeDictionary() + { + RunOnSta(() => + { + ResourceDictionary resources = new(); + MutableThemeProvider provider = new(AppTheme.Light); + AppThemeManager manager = new(resources, System.Windows.Threading.Dispatcher.CurrentDispatcher, provider, FakeDictionary); + + manager.ApplyCurrentTheme(); + provider.Theme = AppTheme.Dark; + manager.ApplyCurrentTheme(); + + ResourceDictionary dictionary = Assert.Single(resources.MergedDictionaries); + Assert.Equal(AppTheme.Dark.ToString(), dictionary[AppThemeManager.SwitchifyThemeResourceKey]); + }); + } + + [Fact] + public void DoesNotRemoveNonThemeMergedDictionaries() + { + RunOnSta(() => + { + ResourceDictionary resources = new(); + ResourceDictionary unrelated = new() + { + ["OtherBrush"] = new SolidColorBrush(Colors.Magenta) + }; + resources.MergedDictionaries.Add(unrelated); + AppThemeManager manager = Manager(resources, AppTheme.Dark); + + manager.ApplyCurrentTheme(); + + Assert.Contains(unrelated, resources.MergedDictionaries); + Assert.Contains(resources.MergedDictionaries, dictionary => + dictionary.Contains(AppThemeManager.SwitchifyThemeResourceKey) && + string.Equals(dictionary[AppThemeManager.SwitchifyThemeResourceKey]?.ToString(), AppTheme.Dark.ToString(), StringComparison.Ordinal)); + }); + } + + private static AppThemeManager Manager(ResourceDictionary resources, AppTheme theme) + { + return new AppThemeManager( + resources, + System.Windows.Threading.Dispatcher.CurrentDispatcher, + new MutableThemeProvider(theme), + FakeDictionary); + } + + private static ResourceDictionary FakeDictionary(AppTheme theme) + { + return new ResourceDictionary(); + } + + private static void RunOnSta(Action action) + { + Exception? exception = null; + Thread thread = new(() => + { + try + { + action(); + } + catch (Exception error) + { + exception = error; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (exception is not null) throw exception; + } + + private sealed class MutableThemeProvider(AppTheme theme) : IAppThemeProvider + { + public AppTheme Theme { get; set; } = theme; + + public AppTheme GetCurrentTheme() => Theme; + } +} diff --git a/src/SwitchifyPc.Tests/ButtonStyleTests.cs b/src/SwitchifyPc.Tests/ButtonStyleTests.cs index e4ea3cc..0eb40fc 100644 --- a/src/SwitchifyPc.Tests/ButtonStyleTests.cs +++ b/src/SwitchifyPc.Tests/ButtonStyleTests.cs @@ -16,6 +16,7 @@ public void MainWindowPrimaryButtonStyleUsesRedHoverBrush() { RunOnSta(() => { + WpfTestApplication.ApplyTheme(SwitchifyPc.App.Themes.AppTheme.Light); MainWindow window = new(); try { @@ -33,6 +34,7 @@ public void SettingsWindowPrimaryButtonStyleUsesRedHoverBrush() { RunOnSta(() => { + WpfTestApplication.ApplyTheme(SwitchifyPc.App.Themes.AppTheme.Light); SettingsWindow window = new(); try { @@ -50,7 +52,6 @@ private static void AssertPrimaryButtonStyleUsesRedHoverBrush(FrameworkElement e Style style = Assert.IsType