From 4e1321560096f28b0b238110f6be62a1865fc088 Mon Sep 17 00:00:00 2001 From: KamilDev Date: Wed, 2 Sep 2026 20:54:23 +1000 Subject: [PATCH] Add dark mode: follow the Windows app mode setting (light/dark), including live switching. Theme all controls, icons and the title bar. Paint the theme background before WPF's first frame so the window does not flash white on startup. Replace the Win32 error MessageBox with a themed WPF dialog. --- README.md | 1 + src/App/App.xaml | 8 +- src/App/App.xaml.cs | 6 + src/App/Gui/Icons.xaml | 51 ++-- src/App/Gui/MainWindow.xaml.cs | 2 + src/App/Gui/Theme/Controls.xaml | 274 +++++++++++++++++++ src/App/Gui/Theme/Dark.xaml | 30 ++ src/App/Gui/Theme/DwmApi.cs | 23 ++ src/App/Gui/Theme/GdiApi.cs | 64 +++++ src/App/Gui/Theme/Light.xaml | 30 ++ src/App/Gui/Theme/ThemeManager.cs | 79 ++++++ src/App/Gui/Theme/WindowBackgroundPainter.cs | 39 +++ src/App/Gui/Utils/ErrorDialog.cs | 11 - src/App/Gui/Utils/ErrorDialog.xaml | 28 ++ src/App/Gui/Utils/ErrorDialog.xaml.cs | 29 ++ 15 files changed, 631 insertions(+), 44 deletions(-) create mode 100644 src/App/Gui/Theme/Controls.xaml create mode 100644 src/App/Gui/Theme/Dark.xaml create mode 100644 src/App/Gui/Theme/DwmApi.cs create mode 100644 src/App/Gui/Theme/GdiApi.cs create mode 100644 src/App/Gui/Theme/Light.xaml create mode 100644 src/App/Gui/Theme/ThemeManager.cs create mode 100644 src/App/Gui/Theme/WindowBackgroundPainter.cs delete mode 100644 src/App/Gui/Utils/ErrorDialog.cs create mode 100644 src/App/Gui/Utils/ErrorDialog.xaml create mode 100644 src/App/Gui/Utils/ErrorDialog.xaml.cs diff --git a/README.md b/README.md index 017e1b0..83146cc 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A simple clone of [PowerToys File Locksmith](https://learn.microsoft.com/en-us/w * Supports older versions of Windows * Doesn't require admin rights * Also shows locks on network shares and mounted drives +* Follows the Windows light/dark app mode ## Screenshots diff --git a/src/App/App.xaml b/src/App/App.xaml index 539719a..8f2c253 100644 --- a/src/App/App.xaml +++ b/src/App/App.xaml @@ -6,14 +6,8 @@ + - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App/Gui/Theme/Dark.xaml b/src/App/Gui/Theme/Dark.xaml new file mode 100644 index 0000000..0014a0c --- /dev/null +++ b/src/App/Gui/Theme/Dark.xaml @@ -0,0 +1,30 @@ + + #202020 + #FFFFFF + #7A7A7A + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App/Gui/Theme/DwmApi.cs b/src/App/Gui/Theme/DwmApi.cs new file mode 100644 index 0000000..4214ad5 --- /dev/null +++ b/src/App/Gui/Theme/DwmApi.cs @@ -0,0 +1,23 @@ +using System.Runtime.InteropServices; + +namespace ShowWhatProcessLocksFile.Gui.Theme; + +internal static class DwmApi +{ + // DWMWA_USE_IMMERSIVE_DARK_MODE is 20 since Windows 10 20H1 (build 18985). + // Windows 10 1809 - 1909 expose the same attribute under 19. + private const int DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19; + private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; + + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int dwAttribute, ref int pvAttribute, int cbAttribute); + + public static void SetDarkTitleBar(IntPtr hwnd, bool enabled) + { + var value = enabled ? 1 : 0; + if (DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int)) != 0) + { + _ = DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, ref value, sizeof(int)); + } + } +} diff --git a/src/App/Gui/Theme/GdiApi.cs b/src/App/Gui/Theme/GdiApi.cs new file mode 100644 index 0000000..9ff7f4f --- /dev/null +++ b/src/App/Gui/Theme/GdiApi.cs @@ -0,0 +1,64 @@ +using System.Runtime.InteropServices; +using System.Windows.Media; + +namespace ShowWhatProcessLocksFile.Gui.Theme; + +internal static class GdiApi +{ + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect); + + [DllImport("user32.dll")] + private static extern int FillRect(IntPtr hDC, ref RECT lprc, IntPtr hbr); + + [DllImport("user32.dll")] + private static extern IntPtr GetDC(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC); + + [DllImport("gdi32.dll")] + private static extern IntPtr CreateSolidBrush(uint crColor); + + [DllImport("gdi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DeleteObject(IntPtr hObject); + + public static void FillClientArea(IntPtr hwnd, Color color) + { + var hdc = GetDC(hwnd); + try + { + FillClientArea(hwnd, hdc, color); + } + finally + { + _ = ReleaseDC(hwnd, hdc); + } + } + + public static void FillClientArea(IntPtr hwnd, IntPtr hdc, Color color) + { + var colorRef = (uint)(color.R | color.G << 8 | color.B << 16); + var brush = CreateSolidBrush(colorRef); + try + { + _ = GetClientRect(hwnd, out var rect); + _ = FillRect(hdc, ref rect, brush); + } + finally + { + _ = DeleteObject(brush); + } + } +} diff --git a/src/App/Gui/Theme/Light.xaml b/src/App/Gui/Theme/Light.xaml new file mode 100644 index 0000000..71fe538 --- /dev/null +++ b/src/App/Gui/Theme/Light.xaml @@ -0,0 +1,30 @@ + + #FFFFFF + #000000 + #6D6D6D + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App/Gui/Theme/ThemeManager.cs b/src/App/Gui/Theme/ThemeManager.cs new file mode 100644 index 0000000..cde8b76 --- /dev/null +++ b/src/App/Gui/Theme/ThemeManager.cs @@ -0,0 +1,79 @@ +using Microsoft.Win32; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; + +namespace ShowWhatProcessLocksFile.Gui.Theme; + +/// +/// Follows the Windows "Choose your default app mode" setting (light/dark) and reacts to changes at runtime. +/// +internal static class ThemeManager +{ + private static readonly Uri LightThemeUri = new("/Gui/Theme/Light.xaml", UriKind.Relative); + private static readonly Uri DarkThemeUri = new("/Gui/Theme/Dark.xaml", UriKind.Relative); + + private static ResourceDictionary? themeBrushes; + + public static bool IsDark { get; private set; } + + public static void Initialize() + { + ApplyTheme(IsSystemAppModeDark()); + SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged; + } + + public static void Attach(Window window) + { + window.SourceInitialized += (_, _) => + { + var source = (HwndSource)PresentationSource.FromVisual(window); + source.AddHook(new WindowBackgroundPainter().Hook); + DwmApi.SetDarkTitleBar(source.Handle, IsDark); + }; + } + + public static Color WindowBackgroundColor => (Color)Application.Current.Resources["WindowBackgroundColor"]; + + private static void OnUserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e) + { + Application.Current.Dispatcher.BeginInvoke(() => + { + var dark = IsSystemAppModeDark(); + if (dark == IsDark) + { + return; + } + + ApplyTheme(dark); + foreach (Window window in Application.Current.Windows) + { + var hwnd = new WindowInteropHelper(window).Handle; + if (hwnd != IntPtr.Zero) + { + DwmApi.SetDarkTitleBar(hwnd, dark); + } + } + }); + } + + private static void ApplyTheme(bool dark) + { + IsDark = dark; + + var dictionaries = Application.Current.Resources.MergedDictionaries; + if (themeBrushes != null) + { + _ = dictionaries.Remove(themeBrushes); + } + + themeBrushes = new ResourceDictionary { Source = dark ? DarkThemeUri : LightThemeUri }; + dictionaries.Add(themeBrushes); + } + + private static bool IsSystemAppModeDark() + { + using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + return key?.GetValue("AppsUseLightTheme") is 0; + } +} diff --git a/src/App/Gui/Theme/WindowBackgroundPainter.cs b/src/App/Gui/Theme/WindowBackgroundPainter.cs new file mode 100644 index 0000000..f850e5c --- /dev/null +++ b/src/App/Gui/Theme/WindowBackgroundPainter.cs @@ -0,0 +1,39 @@ +using System.Runtime.InteropServices; + +namespace ShowWhatProcessLocksFile.Gui.Theme; + +/// +/// WPF answers WM_ERASEBKGND without painting and presents its first frame asynchronously, so DWM would display +/// the window's untouched white surface from the moment it becomes visible until that frame arrives. ShowWindow +/// makes the window visible before running activation and only then erases, so the erase alone is tens of +/// milliseconds late. This hook paints the theme background with GDI on the first message that arrives while the +/// window is visible, and again on every erase. Public HwndSource hooks run before WPF's own handler. +/// +internal sealed class WindowBackgroundPainter +{ + private const int WM_ERASEBKGND = 0x0014; + + private bool paintedOnFirstShow; + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindowVisible(IntPtr hWnd); + + public IntPtr Hook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (!paintedOnFirstShow && IsWindowVisible(hwnd)) + { + paintedOnFirstShow = true; + GdiApi.FillClientArea(hwnd, ThemeManager.WindowBackgroundColor); + } + + if (msg == WM_ERASEBKGND) + { + GdiApi.FillClientArea(hwnd, hdc: wParam, ThemeManager.WindowBackgroundColor); + handled = true; + return new IntPtr(1); + } + + return IntPtr.Zero; + } +} diff --git a/src/App/Gui/Utils/ErrorDialog.cs b/src/App/Gui/Utils/ErrorDialog.cs deleted file mode 100644 index 258b85e..0000000 --- a/src/App/Gui/Utils/ErrorDialog.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Windows; - -namespace ShowWhatProcessLocksFile.Gui.Utils; - -internal static class ErrorDialog -{ - public static void Show(string text) - { - _ = MessageBox.Show(text, "Error", MessageBoxButton.OK, MessageBoxImage.Error); - } -} diff --git a/src/App/Gui/Utils/ErrorDialog.xaml b/src/App/Gui/Utils/ErrorDialog.xaml new file mode 100644 index 0000000..dbf130f --- /dev/null +++ b/src/App/Gui/Utils/ErrorDialog.xaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + +