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 @@ + + + + + + + + + + + + + +