diff --git a/.github/images/demo-wpf-customcolors.png b/.github/images/demo-wpf-customcolors.png new file mode 100644 index 0000000..a8e0ce4 Binary files /dev/null and b/.github/images/demo-wpf-customcolors.png differ diff --git a/DarkNet/DarkNet.cs b/DarkNet/DarkNet.cs index b1c4353..201233c 100644 --- a/DarkNet/DarkNet.cs +++ b/DarkNet/DarkNet.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.ComponentModel; using System.Diagnostics; +using System.Drawing; using System.IO; using System.Runtime.InteropServices; using System.Security; @@ -34,12 +35,24 @@ public class DarkNet: IDarkNet { /// public static IDarkNet Instance => LazyInstance.Value; + /// + /// Mapping from a window handle to the most recently set for that window, used to correctly reapply themes when a parent theme (process or OS) changes. + /// protected readonly ConcurrentDictionary PreferredWindowModes = new(); - private bool? _userDefaultAppThemeIsDark; - private bool? _userTaskbarThemeIsDark; + /// + /// The most recently set theme for this process, used to correctly reapply themes to this process's windows when the user changes their OS settings. + /// protected Theme? PreferredAppTheme; - protected bool? EffectiveProcessThemeIsDark; + + /// + /// Most recent value for whether the process's theme is dark, after taking into account high contrast mode. Null if never set. Used to back the property and fire events. + /// + protected bool? EffectiveProcessThemeIsDark; + + private bool? _userDefaultAppThemeIsDark; + private bool? _userTaskbarThemeIsDark; + private ThemeOptions? _processThemeOptions; private volatile int _processThemeChanged; // int instead of bool to support Interlocked atomic operations @@ -63,7 +76,7 @@ public DarkNet() { } /// - public virtual void SetCurrentProcessTheme(Theme theme) { + public virtual void SetCurrentProcessTheme(Theme theme, ThemeOptions? options = null) { _processThemeChanged = 1; try { @@ -83,7 +96,8 @@ public virtual void SetCurrentProcessTheme(Theme theme) { } } - PreferredAppTheme = theme; + PreferredAppTheme = theme; + _processThemeOptions = options; RefreshTitleBarThemeColor(); @@ -99,7 +113,7 @@ public virtual void SetCurrentProcessTheme(Theme theme) { /// // Not overloading one method to cover both WPF and Forms so that consumers don't have to add references to both PresentationFramework and System.Windows.Forms just to use one overloaded variant - public virtual void SetWindowThemeWpf(Window window, Theme theme) { + public virtual void SetWindowThemeWpf(Window window, Theme theme, ThemeOptions? options = null) { bool isWindowInitialized = PresentationSource.FromVisual(window) != null; if (!isWindowInitialized) { ImplicitlySetProcessThemeIfFirstCall(theme); @@ -107,13 +121,13 @@ public virtual void SetWindowThemeWpf(Window window, Theme theme) { void OnSourceInitialized(object? _, EventArgs eventArgs) { window.SourceInitialized -= OnSourceInitialized; - SetWindowThemeWpf(window, theme); + SetWindowThemeWpf(window, theme, options); } } else { IntPtr windowHandle = new WindowInteropHelper(window).Handle; try { - SetModeForWindow(windowHandle, theme); + SetModeForWindow(windowHandle, theme, options); } catch (DarkNetException.LifecycleException) { throw new InvalidOperationException($"Called {nameof(SetWindowThemeWpf)}() too late, call it in OnSourceInitialized or the Window subclass's constructor"); } @@ -128,9 +142,9 @@ void OnClosing(object _, CancelEventArgs args) { } /// - public virtual void SetWindowThemeForms(Form window, Theme theme) { + public virtual void SetWindowThemeForms(Form window, Theme theme, ThemeOptions? options = null) { try { - SetModeForWindow(window.Handle, theme); + SetModeForWindow(window.Handle, theme, options); } catch (DarkNetException.LifecycleException) { throw new InvalidOperationException($"Called {nameof(SetWindowThemeForms)}() too late, call it before Form.Show() or Application.Run(), and after " + $"{nameof(IDarkNet)}.{nameof(SetCurrentProcessTheme)}()"); @@ -145,9 +159,9 @@ void OnClosing(object _, CancelEventArgs args) { } /// - public virtual void SetWindowThemeRaw(IntPtr windowHandle, Theme theme) { + public virtual void SetWindowThemeRaw(IntPtr windowHandle, Theme theme, ThemeOptions? options = null) { try { - SetModeForWindow(windowHandle, theme); + SetModeForWindow(windowHandle, theme, options); } catch (DarkNetException.LifecycleException) { throw new InvalidOperationException($"Called {nameof(SetWindowThemeRaw)}() too late, call it before the window is visible."); } @@ -165,7 +179,7 @@ private void ImplicitlySetProcessThemeIfFirstCall(Theme theme) { /// if GetWindowInfo().style.WS_VISIBLE == true then it was called too late /// /// if it is called too late - protected virtual void SetModeForWindow(IntPtr windowHandle, Theme windowTheme) { + protected virtual void SetModeForWindow(IntPtr windowHandle, Theme windowTheme, ThemeOptions? options = null) { ImplicitlySetProcessThemeIfFirstCall(windowTheme); bool isFirstRunForWindow = true; @@ -179,9 +193,13 @@ protected virtual void SetModeForWindow(IntPtr windowHandle, Theme windowTheme) } Win32.AllowDarkModeForWindow(windowHandle, windowTheme != Theme.Light); - RefreshTitleBarThemeColor(windowHandle); + RefreshTitleBarThemeColor(windowHandle, options); } + /// + /// Fired when a WPF or Forms window is about to close, so that we can release the entry in the map and free its memory. + /// + /// protected void OnWindowClosing(IntPtr windowHandle) { PreferredWindowModes.TryRemove(windowHandle, out _); } @@ -198,7 +216,12 @@ private void RefreshTitleBarThemeColor() { } } - protected virtual void RefreshTitleBarThemeColor(IntPtr windowHandle) { + /// + /// Apply all of the theme fallback/override logic and call the OS methods to apply the window theme. Handles the window theme, app theme, OS theme, high contrast, different Windows versions, Windows 11 colors, repainting visible windows, and updating context menus. + /// + /// A pointer to the window to update + /// Windows 11 DWM color overrides + protected virtual void RefreshTitleBarThemeColor(IntPtr windowHandle, ThemeOptions? options = null) { if (!PreferredWindowModes.TryGetValue(windowHandle, out Theme windowTheme)) { windowTheme = Theme.Auto; } @@ -244,6 +267,18 @@ protected virtual void RefreshTitleBarThemeColor(IntPtr windowHandle) { Marshal.FreeHGlobal(attributeValueBuffer); } + if ((options?.TitleBarBackgroundColor ?? _processThemeOptions?.TitleBarBackgroundColor) is { } titleBarBackgroundColor) { + SetDwmWindowColor(windowHandle, DwmWindowAttribute.DwmwaCaptionColor, titleBarBackgroundColor); + } + + if ((options?.TitleBarTextColor ?? _processThemeOptions?.TitleBarTextColor) is { } titleBarTextColor) { + SetDwmWindowColor(windowHandle, DwmWindowAttribute.DwmwaTextColor, titleBarTextColor); + } + + if ((options?.WindowBorderColor ?? _processThemeOptions?.WindowBorderColor) is { } windowBorderColor) { + SetDwmWindowColor(windowHandle, DwmWindowAttribute.DwmwaBorderColor, windowBorderColor); + } + /* * Needed for subsequent (after the window has already been shown) theme changes, otherwise the title bar will only update after you later hide, blur, or resize the window. * Not needed when changing the theme for the first time, before the window has ever been shown. @@ -261,6 +296,23 @@ protected virtual void RefreshTitleBarThemeColor(IntPtr windowHandle) { Win32.FlushMenuThemes(); } + // Windows 11 and later + private static int SetDwmWindowColor(IntPtr windowHandle, DwmWindowAttribute attribute, Color color) { + int attributeValueBufferSize = Marshal.SizeOf(); + IntPtr attributeValueBuffer = Marshal.AllocHGlobal(attributeValueBufferSize); + ColorRef colorRef = new(color, ThemeOptions.DefaultColor.Equals(color) || (attribute == DwmWindowAttribute.DwmwaBorderColor && ThemeOptions.NoWindowBorder.Equals(color))); + + Marshal.StructureToPtr(colorRef, attributeValueBuffer, false); + try { + return Win32.DwmSetWindowAttribute(windowHandle, attribute, attributeValueBuffer, attributeValueBufferSize); + } catch (Exception e) when (e is not OutOfMemoryException) { + Trace.TraceInformation("Failed to set custom title bar color for window: {0}", e.Message); + return 1; + } finally { + Marshal.FreeHGlobal(attributeValueBuffer); + } + } + /// public virtual bool UserDefaultAppThemeIsDark { get { diff --git a/DarkNet/DarkNet.csproj b/DarkNet/DarkNet.csproj index 81274ea..bc81cf8 100644 --- a/DarkNet/DarkNet.csproj +++ b/DarkNet/DarkNet.csproj @@ -3,7 +3,7 @@ netcoreapp3.1;net452 - 2.2.0 + 2.3.0 latest enable true diff --git a/DarkNet/IDarkNet.cs b/DarkNet/IDarkNet.cs index d388cf2..9fb627f 100644 --- a/DarkNet/IDarkNet.cs +++ b/DarkNet/IDarkNet.cs @@ -29,7 +29,8 @@ public interface IDarkNet: IDisposable { /// This method doesn't actually make your title bars dark. It defines the default theme to use if you set a window's theme to using /. /// /// The theme that windows of your process should use. This theme overrides the user's settings and is overridden by the window theme you set later, unless you set the theme to , in which case it inherits from the user's settings. - void SetCurrentProcessTheme(Theme theme); + /// Optional extra parameters that can override the colors in the non-client areas for all of this process's windows. May also be specified on a per-window basis with the SetWindowTheme*() methods. Only affects Windows 11 and later. + void SetCurrentProcessTheme(Theme theme, ThemeOptions? options = null); /// /// Turn on dark mode for a window. @@ -39,8 +40,9 @@ public interface IDarkNet: IDisposable { /// The correct time to call this method is when the window has already been constructed, it has an HWND, but it has not yet been shown (i.e. its Win32 window style must not be visible yet). You can call this directly after the call to Window.InitializeComponent in the Window's constructor. Alternatively, a handler for the event will be fired at the correct point in the window lifecycle to call this method. /// A WPF window which has been constructed and is being SourceInitialized, but has not yet been shown. /// The theme to use for this window. Can be to inherit from the app (defined by the theme passed to ), or from the user's default app settings if you also set the app to (defined in Settings › Personalization › Colors). + /// Optional extra parameters that can override the colors in the non-client area of this window. May also be specified on a per-process basis with . Only affects Windows 11 and later. /// If this method was called too early (such as right after the Window constructor), or too late (such as after returns). - void SetWindowThemeWpf(Window window, Theme theme); + void SetWindowThemeWpf(Window window, Theme theme, ThemeOptions? options = null); /// /// Turn on dark mode for a window. @@ -50,8 +52,9 @@ public interface IDarkNet: IDisposable { /// The correct time to call this method is when the window has already been constructed, but it has not yet been shown (i.e. its Win32 window style must not be visible yet). You can call this after the constructor returns, but before . /// A Windows Forms window which has been constructed but has not yet been shown. /// The theme to use for this window. Can be to inherit from the app (defined by the theme passed to ), or from the user's default app settings if you also set the app to (defined in Settings › Personalization › Colors). + /// /// Optional extra parameters that can override the colors in the non-client area of this window. May also be specified on a per-process basis with . Only affects Windows 11 and later. /// If this method was called too late (such as after calling returns). - void SetWindowThemeForms(Form window, Theme theme); + void SetWindowThemeForms(Form window, Theme theme, ThemeOptions? options = null); /// /// Turn on dark mode for a window. @@ -62,8 +65,9 @@ public interface IDarkNet: IDisposable { /// The correct time to call this method is when the window has already been constructed, but it has not yet been shown (i.e. its Win32 window style must not be visible yet). /// A HWND handle to a Win32 window, which has been constructed but has not yet been shown. /// The theme to use for this window. Can be to inherit from the app (defined by the theme passed to ), or from the user's default app settings if you also set the app to (defined in Settings › Personalization › Colors). + /// /// Optional extra parameters that can override the colors in the non-client area of this window. May also be specified on a per-process basis with . Only affects Windows 11 and later. /// If this method was called too late. - void SetWindowThemeRaw(IntPtr windowHandle, Theme theme); + void SetWindowThemeRaw(IntPtr windowHandle, Theme theme, ThemeOptions? options = null); /// /// Whether windows which follow the user's default operating system theme, such as Windows Explorer, Command Prompt, and Settings, will use dark mode in their title bars, context menus, and other themed areas. Also known as "app mode" or "default app mode". diff --git a/DarkNet/ThemeOptions.cs b/DarkNet/ThemeOptions.cs new file mode 100644 index 0000000..d241365 --- /dev/null +++ b/DarkNet/ThemeOptions.cs @@ -0,0 +1,45 @@ +using System.Drawing; + +namespace Dark.Net; + +/// +/// Extra parameters that override the non-client area colors of a window in Windows 11 or later. On earlier versions of Windows, these have no effect. +/// +public class ThemeOptions { + + /// + /// Override the background color of the title bar in Windows 11 or later. + /// If , the title bar color will be left unchanged, although it will still be affected by the chosen and the previous value of this property. + /// If you previously set this property and want to undo it, setting this to will revert it to the standard OS color for your chosen . + /// Setting this property has no effect on Windows 10 and earlier versions. + /// + public Color? TitleBarBackgroundColor { get; set; } + + /// + /// Override the text color of the title bar in Windows 11 or later. Does not affect the minimize, maximize, or close buttons, just the caption text. + /// If , the title bar will be left unchanged, although it will still be affected by the chosen and the previous value of this property. + /// If you previously set this property and want to undo it, setting this to will revert it to the standard OS color for your chosen . + /// Setting this property has no effect on Windows 10 and earlier versions. + /// + public Color? TitleBarTextColor { get; set; } + + /// + /// Override the border color of the window in Windows 11 or later. The border goes all the way around the entire window, not just around the title bar. + /// To remove the window border entirely, set this to . + /// If , the window's border color will be left unchanged, although it will still be affected by the chosen and the previous value of this property. + /// If you previously set this property and want to undo it, setting this to will revert it to the standard OS color for your chosen . + /// Setting this property has no effect on Windows 10 and earlier versions. + /// + public Color? WindowBorderColor { get; set; } + + /// + /// When set as the value of , removes the window border. Windows 11 or later only. + /// + public static readonly Color NoWindowBorder = Color.FromArgb(0xFF, 0xFE, 0xFF, 0xFF); + + /// + /// When set as the value of , , or , reverts the color to the standard Os light or dark color for the active . Useful if you previously set a custom color, and then want to reset it. + /// + public static readonly Color DefaultColor = Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF); + +} \ No newline at end of file diff --git a/DarkNet/Win32.cs b/DarkNet/Win32.cs index 5216112..dc3e59f 100644 --- a/DarkNet/Win32.cs +++ b/DarkNet/Win32.cs @@ -1,4 +1,5 @@ using System; +using System.Drawing; using System.Runtime.InteropServices; namespace Dark.Net; @@ -214,6 +215,9 @@ internal enum AppMode { } +/// +/// https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +/// internal enum DwmWindowAttribute { DwmwaNcrenderingEnabled, @@ -231,8 +235,16 @@ internal enum DwmWindowAttribute { DwmwaCloak, DwmwaCloaked, DwmwaFreezeRepresentation, + DwmwaPassiveUpdateMode, + DwmwaUseHostBackdropBrush, DwmwaUseImmersiveDarkModeBefore20H1 = 19, - DwmwaUseImmersiveDarkMode = 20 + DwmwaUseImmersiveDarkMode = 20, + DwmwaWindowCornerPreference = 33, + DwmwaBorderColor, //ColorRef + DwmwaCaptionColor, //ColorRef + DwmwaTextColor, //ColorRef + DwmwaVisibleFrameBorderThickness, + DwmwaSystemBackdropType } @@ -282,4 +294,27 @@ public WindowCompositionAttributeData(WindowCompositionAttribute attribute, IntP this.size = size; } +} + +/// +/// https://learn.microsoft.com/en-us/windows/win32/gdi/colorref +/// +[StructLayout(LayoutKind.Sequential)] +internal readonly struct ColorRef { + + public readonly byte red; + public readonly byte green; + public readonly byte blue; + public readonly byte alpha = 0; + + public ColorRef(Color color, bool includeAlpha) { + red = color.R; + green = color.G; + blue = color.B; + + if (includeAlpha) { + alpha = color.A; + } + } + } \ No newline at end of file diff --git a/Readme.md b/Readme.md index ecb18f8..714292f 100644 --- a/Readme.md +++ b/Readme.md @@ -31,6 +31,7 @@ Enable native Windows dark mode for your WPF and Windows Forms title bars. - [HWND](#hwnd) - [Effective application theme](#effective-application-theme) - [Taskbar theme](#taskbar-theme) + - [Custom colors](#custom-colors) - [Demos](#demos) - [WPF](#wpf-1) - [Windows Forms](#windows-forms-1) @@ -78,9 +79,9 @@ The top-level interface of this library is **`Dark.Net.IDarkNet`**, which is imp If you don't call this method, any window on which you call `SetWindowTheme*(myWindow, Theme.Auto)` will inherit its theme from the operating system's default app theme, skipping this app-level default. 2. Next, you must call one of the **`SetWindowTheme*`** methods to actually apply a theme to each window. There are three methods to choose from, depending on what kind of window you have: - - **WPF:** `SetWindowThemeWpf(Window, Theme)` - - **Forms:** `SetWindowThemeForms(Form, Theme)` - - **HWND:** `SetWindowThemeRaw(IntPtr, Theme)` + - **WPF:** `SetWindowThemeWpf(Window, Theme, ThemeOptions?)` + - **Forms:** `SetWindowThemeForms(Form, Theme, ThemeOptions?)` + - **HWND:** `SetWindowThemeRaw(IntPtr, Theme, ThemeOptions?)` If you don't call one of these methods on a given window, that window will always use the light theme, even if you called `SetCurrentProcessTheme` and set the OS default app mode to dark. @@ -166,9 +167,11 @@ Try the [demo apps](#demos) to see this behavior in action. DarkNet does not give controls in the client area of your windows a dark skin. It only changes the theme of the title bar and system context menu. It is up to you to make the inside of your windows dark. -However, this library can help you switch your WPF application resource dictionaries to apply different styles when the process title bar theme changes. This requires you to create two resource dictionary XAML files, one for the light theme and one for dark. +However, this library can help you switch your WPF application resource dictionaries to apply different styles when the process title bar theme changes. -To tell DarkNet to switch between the resource dictionaries when the process theme changes, register them with [**`SkinManager`**](https://github.com/Aldaviva/DarkNet/blob/master/DarkNet/Wpf/SkinManager.cs): +This limited class currently only handles process theme changes, and does not handle individual windows using different themes in the same process. For more fine-grained skin management, see [pull request #8](https://github.com/Aldaviva/DarkNet/pull/8). + +This requires you to create two resource dictionary XAML files, one for the light theme and one for dark. To tell DarkNet to switch between the resource dictionaries when the process theme changes, register them with [**`SkinManager`**](https://github.com/Aldaviva/DarkNet/blob/master/DarkNet/Wpf/SkinManager.cs): ```cs new Dark.Net.Wpf.SkinManager().RegisterSkins( @@ -290,6 +293,32 @@ Windows introduced a preference to choose a dark or light taskbar in Windows 10 DarkNet exposes the value of this preference with the **`UserTaskbarThemeIsDark`** property, as well as the change event **`UserTaskbarThemeIsDarkChanged`**. You can use these to render a tray icon in the notification area that matches the taskbar's theme, and re-render it when the user preference changes. +### Custom colors + +Windows 11 introduced the ability to override colors in the non-client area of individual windows. You can change the title bar's text color and background color, as well as the window's border color. You cannot change the color of the minimize, maximize/restore, or close buttons. + +To specify custom colors for a WPF, Forms, or HWND window, pass the optional parameter `ThemeOptions? options` to one of the `SetWindowTheme*()` methods. For example, this invocation gives a WPF window a blue title bar theme. + +```cs +DarkNet.Instance.SetWindowThemeWpf(this, Theme.Dark, new ThemeOptions { + TitleBarTextColor = Color.MidnightBlue, + TitleBarBackgroundColor = Color.PowderBlue, + WindowBorderColor = Color.DarkBlue +}); +``` + +![custom colors](.github/images/demo-wpf-customcolors.png) + +You can pass any or all of the three properties `TitleBarTextColor`, `TitleBarBackgroundColor`, and `WindowBorderColor`. Any properties that you omit or leave `null` will use the standard OS light and dark colors from the `Theme` you passed. You can pass a custom RGB value using `Color.FromArgb(red: 255, green: 127, blue: 0)`. Alpha values are ignored. + +To apply the same custom colors to all of the windows in your process, you may instead pass the `ThemeOptions` to `SetCurrentProcessTheme(Theme, ThemeOptions?)`, then omit the `options` parameter when you call `SetWindowTheme*(window, theme, options)`. Alternatively, you may set some of the properties at the process level and set others at the window level. You may also set a property at both the process and window level, and the window level value will take precedence. + +To remove the window border entirely, set `WindowBorderColor` to `ThemeOptions.NoWindowBorder`. + +If you previously set any of these properties to a custom color, and want to revert it to the standard OS color for your chosen `Theme`, set the property to `ThemeOptions.DefaultColor`. + +On Windows 10 and earlier versions, these options will have no effect. + ## Demos You can download the following precompiled demos, or clone this repository and build the demo projects yourself using Visual Studio Community 2022.