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/App.xaml.cs b/src/App/App.xaml.cs
index 46aa685..2bd03a9 100644
--- a/src/App/App.xaml.cs
+++ b/src/App/App.xaml.cs
@@ -1,7 +1,13 @@
+using ShowWhatProcessLocksFile.Gui.Theme;
using System.Windows;
namespace ShowWhatProcessLocksFile;
public partial class App : Application
{
+ protected override void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+ ThemeManager.Initialize();
+ }
}
diff --git a/src/App/Gui/Icons.xaml b/src/App/Gui/Icons.xaml
index a408a12..ceedc27 100644
--- a/src/App/Gui/Icons.xaml
+++ b/src/App/Gui/Icons.xaml
@@ -7,7 +7,6 @@
-
@@ -17,7 +16,7 @@
-
@@ -33,8 +32,6 @@
-
-
0.75
@@ -46,14 +43,18 @@
-
-
+
+
+
-
-
+
+
+
@@ -69,8 +70,6 @@
-
-
0.75
@@ -82,14 +81,18 @@
-
-
+
+
+
-
-
+
+
+
@@ -104,7 +107,6 @@
-
@@ -114,7 +116,7 @@
-
@@ -129,7 +131,6 @@
-
@@ -139,7 +140,7 @@
-
@@ -154,7 +155,6 @@
-
@@ -164,7 +164,7 @@
-
@@ -179,7 +179,6 @@
-
@@ -192,7 +191,7 @@
-
+
diff --git a/src/App/Gui/MainWindow.xaml.cs b/src/App/Gui/MainWindow.xaml.cs
index 33c85ab..8ddefec 100644
--- a/src/App/Gui/MainWindow.xaml.cs
+++ b/src/App/Gui/MainWindow.xaml.cs
@@ -1,4 +1,5 @@
using System.Windows;
+using ShowWhatProcessLocksFile.Gui.Theme;
using ShowWhatProcessLocksFile.Gui.Utils;
using ShowWhatProcessLocksFile.Utils;
@@ -9,6 +10,7 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
+ ThemeManager.Attach(this);
try
{
diff --git a/src/App/Gui/Theme/Controls.xaml b/src/App/Gui/Theme/Controls.xaml
new file mode 100644
index 0000000..ebfbb28
--- /dev/null
+++ b/src/App/Gui/Theme/Controls.xaml
@@ -0,0 +1,274 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App/Gui/Utils/ErrorDialog.xaml.cs b/src/App/Gui/Utils/ErrorDialog.xaml.cs
new file mode 100644
index 0000000..1a150cf
--- /dev/null
+++ b/src/App/Gui/Utils/ErrorDialog.xaml.cs
@@ -0,0 +1,29 @@
+using ShowWhatProcessLocksFile.Gui.Theme;
+using System.Media;
+using System.Windows;
+
+namespace ShowWhatProcessLocksFile.Gui.Utils;
+
+public partial class ErrorDialog : Window
+{
+ public static void Show(string text)
+ {
+ var dialog = new ErrorDialog { Message = { Text = text } };
+
+ // A Window can only own another Window once it has been shown; startup errors happen before that.
+ if (Application.Current.MainWindow is { IsVisible: true } owner)
+ {
+ dialog.Owner = owner;
+ dialog.WindowStartupLocation = WindowStartupLocation.CenterOwner;
+ }
+
+ SystemSounds.Hand.Play();
+ _ = dialog.ShowDialog();
+ }
+
+ private ErrorDialog()
+ {
+ InitializeComponent();
+ ThemeManager.Attach(this);
+ }
+}