5
0
mirror of https://github.com/wailsapp/wails.git synced 2025-05-02 08:41:41 +08:00

[Feature/1149] Dark mode (#1281)

* Add Windows version helper

* Initial theme support

* Support custom themes

* Update docs

* Honour HighContrast theme. Remove import "C". Refactor

* Small refactor

* Support inactive theme

* Update Docs
This commit is contained in:
Lea Anthony 2022-03-27 22:57:45 +11:00 committed by GitHub
parent 2e21f25182
commit 48254b73e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 463 additions and 39 deletions

View File

@ -27,7 +27,7 @@ require (
github.com/leaanthony/idgen v1.0.0
github.com/leaanthony/slicer v1.5.0
github.com/leaanthony/typescriptify-golang-structs v0.1.7
github.com/leaanthony/winc v0.0.0-20220208061147-37b059b9dc3b
github.com/leaanthony/winc v0.0.0-20220323084916-ea5df694ec1f
github.com/leaanthony/winicon v1.0.0
github.com/matryer/is v1.4.0
github.com/olekukonko/tablewriter v0.0.4

View File

@ -130,6 +130,8 @@ github.com/leaanthony/typescriptify-golang-structs v0.1.7 h1:yoznzWzyxkO/iWdlpq+
github.com/leaanthony/typescriptify-golang-structs v0.1.7/go.mod h1:cWtOkiVhMF77e6phAXUcfNwYmMwCJ67Sij24lfvi9Js=
github.com/leaanthony/winc v0.0.0-20220208061147-37b059b9dc3b h1:cJ+VfVwX3GkRGSy0SiOyZ7FjSGMPAY/rS/wJzilo23I=
github.com/leaanthony/winc v0.0.0-20220208061147-37b059b9dc3b/go.mod h1:OPfk8SNMAKRcSv8Vw1QL0yupmwcRtJyXZUgtMoaHUGc=
github.com/leaanthony/winc v0.0.0-20220323084916-ea5df694ec1f h1:RM0TNQXGTt06ZrSysdo+r9E9fk1ObACFBOww+W1zOiU=
github.com/leaanthony/winc v0.0.0-20220323084916-ea5df694ec1f/go.mod h1:OPfk8SNMAKRcSv8Vw1QL0yupmwcRtJyXZUgtMoaHUGc=
github.com/leaanthony/winicon v1.0.0 h1:ZNt5U5dY71oEoKZ97UVwJRT4e+5xo5o/ieKuHuk8NqQ=
github.com/leaanthony/winicon v1.0.0/go.mod h1:en5xhijl92aphrJdmRPlh4NI1L6wq3gEm0LpXAPghjU=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=

View File

@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/wailsapp/wails/v2/internal/system/operatingsystem"
"log"
"runtime"
"strconv"
@ -45,10 +46,16 @@ type Frontend struct {
servingFromDisk bool
hasStarted bool
// Windows build number
versionInfo *operatingsystem.WindowsVersionInfo
}
func NewFrontend(ctx context.Context, appoptions *options.App, myLogger *logger.Logger, appBindings *binding.Bindings, dispatcher frontend.Dispatcher) *Frontend {
// Get Windows build number
versionInfo, _ := operatingsystem.GetWindowsVersionInfo()
result := &Frontend{
frontendOptions: appoptions,
logger: myLogger,
@ -56,6 +63,7 @@ func NewFrontend(ctx context.Context, appoptions *options.App, myLogger *logger.
dispatcher: dispatcher,
ctx: ctx,
startURL: "http://wails.localhost/",
versionInfo: versionInfo,
}
bindingsJSON, err := appBindings.ToJSON()
@ -99,7 +107,7 @@ func (f *Frontend) Run(ctx context.Context) error {
f.ctx = context.WithValue(ctx, "frontend", f)
mainWindow := NewWindow(nil, f.frontendOptions)
mainWindow := NewWindow(nil, f.frontendOptions, f.versionInfo)
f.mainWindow = mainWindow
var _debug = ctx.Value("debug")

View File

@ -48,8 +48,10 @@ func processMenu(window *Window, menu *menu.Menu) {
mainMenu := window.NewMenu()
for _, menuItem := range menu.Items {
submenu := mainMenu.AddSubMenu(menuItem.Label)
for _, menuItem := range menuItem.SubMenu.Items {
processMenuItem(submenu, menuItem)
if menuItem.SubMenu != nil {
for _, menuItem := range menuItem.SubMenu.Items {
processMenuItem(submenu, menuItem)
}
}
}
mainMenu.Show()

View File

@ -0,0 +1,62 @@
package windows
import (
"github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/win32"
"github.com/wailsapp/wails/v2/pkg/options/windows"
)
func (w *Window) updateTheme() {
if win32.IsCurrentlyHighContrastMode() {
return
}
if !win32.SupportsThemes() {
return
}
// Only process if there's a theme change
isDarkMode := win32.IsCurrentlyDarkMode()
w.isDarkMode = isDarkMode
// Default use system theme
winOptions := w.frontendOptions.Windows
var customTheme *windows.ThemeSettings
if winOptions != nil {
customTheme = winOptions.CustomTheme
if winOptions.Theme == windows.Dark {
isDarkMode = true
}
if winOptions.Theme == windows.Light {
isDarkMode = false
}
}
win32.SetTheme(w.Handle(), isDarkMode)
// Custom theme
if win32.SupportsCustomThemes() && customTheme != nil {
if w.isActive {
if isDarkMode {
println("1")
win32.SetTitleBarColour(w.Handle(), customTheme.DarkModeTitleBar)
win32.SetTitleTextColour(w.Handle(), customTheme.DarkModeTitleText)
win32.SetBorderColour(w.Handle(), customTheme.DarkModeBorder)
} else {
println("2")
win32.SetTitleBarColour(w.Handle(), customTheme.LightModeTitleBar)
win32.SetTitleTextColour(w.Handle(), customTheme.LightModeTitleText)
win32.SetBorderColour(w.Handle(), customTheme.LightModeBorder)
}
} else {
if isDarkMode {
win32.SetTitleBarColour(w.Handle(), customTheme.DarkModeTitleBarInactive)
win32.SetTitleTextColour(w.Handle(), customTheme.DarkModeTitleTextInactive)
win32.SetBorderColour(w.Handle(), customTheme.DarkModeBorderInactive)
} else {
win32.SetTitleBarColour(w.Handle(), customTheme.LightModeTitleBarInactive)
win32.SetTitleTextColour(w.Handle(), customTheme.LightModeTitleTextInactive)
win32.SetBorderColour(w.Handle(), customTheme.LightModeBorderInactive)
}
}
}
}

View File

@ -0,0 +1,27 @@
package win32
import (
"github.com/wailsapp/wails/v2/internal/system/operatingsystem"
"syscall"
)
type HRESULT int32
type HANDLE uintptr
var (
moduser32 = syscall.NewLazyDLL("user32.dll")
procSystemParametersInfo = moduser32.NewProc("SystemParametersInfoW")
procGetWindowLong = moduser32.NewProc("GetWindowLongW")
)
var (
moddwmapi = syscall.NewLazyDLL("dwmapi.dll")
procDwmSetWindowAttribute = moddwmapi.NewProc("DwmSetWindowAttribute")
procDwmExtendFrameIntoClientArea = moddwmapi.NewProc("DwmExtendFrameIntoClientArea")
)
var windowsVersion, _ = operatingsystem.GetWindowsVersionInfo()
func IsWindowsVersionAtLeast(major, minor, buildNumber int) bool {
return windowsVersion.Major >= major &&
windowsVersion.Minor >= minor &&
windowsVersion.Build >= buildNumber
}

View File

@ -0,0 +1,92 @@
package win32
import (
"golang.org/x/sys/windows/registry"
"unsafe"
)
type DWMWINDOWATTRIBUTE int32
const DwmwaUseImmersiveDarkModeBefore20h1 DWMWINDOWATTRIBUTE = 19
const DwmwaUseImmersiveDarkMode DWMWINDOWATTRIBUTE = 20
const DwmwaBorderColor DWMWINDOWATTRIBUTE = 34
const DwmwaCaptionColor DWMWINDOWATTRIBUTE = 35
const DwmwaTextColor DWMWINDOWATTRIBUTE = 36
const SPI_GETHIGHCONTRAST = 0x0042
const HCF_HIGHCONTRASTON = 0x00000001
func dwmSetWindowAttribute(hwnd uintptr, dwAttribute DWMWINDOWATTRIBUTE, pvAttribute unsafe.Pointer, cbAttribute uintptr) {
ret, _, err := procDwmSetWindowAttribute.Call(
hwnd,
uintptr(dwAttribute),
uintptr(pvAttribute),
cbAttribute)
if ret != 0 {
_ = err
// println(err.Error())
}
}
func SupportsThemes() bool {
// We can't support Windows versions before 17763
return IsWindowsVersionAtLeast(10, 0, 17763)
}
func SupportsCustomThemes() bool {
return IsWindowsVersionAtLeast(10, 0, 17763)
}
func SetTheme(hwnd uintptr, useDarkMode bool) {
if IsWindowsVersionAtLeast(10, 0, 17763) {
attr := DwmwaUseImmersiveDarkModeBefore20h1
if IsWindowsVersionAtLeast(10, 0, 18985) {
attr = DwmwaUseImmersiveDarkMode
}
dwmSetWindowAttribute(hwnd, attr, unsafe.Pointer(&useDarkMode), unsafe.Sizeof(&useDarkMode))
}
}
func SetTitleBarColour(hwnd uintptr, titleBarColour int32) {
dwmSetWindowAttribute(hwnd, DwmwaCaptionColor, unsafe.Pointer(&titleBarColour), unsafe.Sizeof(titleBarColour))
}
func SetTitleTextColour(hwnd uintptr, titleTextColour int32) {
dwmSetWindowAttribute(hwnd, DwmwaTextColor, unsafe.Pointer(&titleTextColour), unsafe.Sizeof(titleTextColour))
}
func SetBorderColour(hwnd uintptr, titleBorderColour int32) {
dwmSetWindowAttribute(hwnd, DwmwaBorderColor, unsafe.Pointer(&titleBorderColour), unsafe.Sizeof(titleBorderColour))
}
func IsCurrentlyDarkMode() bool {
key, err := registry.OpenKey(registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize`, registry.QUERY_VALUE)
if err != nil {
return false
}
defer key.Close()
AppsUseLightTheme, _, err := key.GetIntegerValue("AppsUseLightTheme")
if err != nil {
return false
}
return AppsUseLightTheme == 0
}
type highContrast struct {
CbSize uint32
DwFlags uint32
LpszDefaultScheme *int16
}
func IsCurrentlyHighContrastMode() bool {
var result highContrast
result.CbSize = uint32(unsafe.Sizeof(result))
res, _, err := procSystemParametersInfo.Call(SPI_GETHIGHCONTRAST, uintptr(result.CbSize), uintptr(unsafe.Pointer(&result)), 0)
if res == 0 {
_ = err
return false
}
return result.DwFlags&HCF_HIGHCONTRASTON == HCF_HIGHCONTRASTON
}

View File

@ -0,0 +1,56 @@
package win32
import (
"fmt"
"log"
"syscall"
"unsafe"
)
const (
WS_MAXIMIZE = 0x01000000
GWL_STYLE = -16
)
// http://msdn.microsoft.com/en-us/library/windows/desktop/bb773244.aspx
type MARGINS struct {
CxLeftWidth, CxRightWidth, CyTopHeight, CyBottomHeight int32
}
func ExtendFrameIntoClientArea(hwnd uintptr) {
// -1: Adds the default frame styling (aero shadow and e.g. rounded corners on Windows 11)
// Also shows the caption buttons if transparent ant translucent but they don't work.
// 0: Adds the default frame styling but no aero shadow, does not show the caption buttons.
// 1: Adds the default frame styling (aero shadow and e.g. rounded corners on Windows 11) but no caption buttons
// are shown if transparent ant translucent.
margins := &MARGINS{1, 1, 1, 1} // Only extend 1 pixel to have the default frame styling but no caption buttons
if err := dwmExtendFrameIntoClientArea(hwnd, margins); err != nil {
log.Fatal(fmt.Errorf("DwmExtendFrameIntoClientArea failed: %s", err))
}
}
func IsWindowMaximised(hwnd uintptr) bool {
style := uint32(getWindowLong(hwnd, GWL_STYLE))
return style&WS_MAXIMIZE != 0
}
func dwmExtendFrameIntoClientArea(hwnd uintptr, margins *MARGINS) error {
ret, _, _ := procDwmExtendFrameIntoClientArea.Call(
hwnd,
uintptr(unsafe.Pointer(margins)))
if ret != 0 {
return syscall.GetLastError()
}
return nil
}
func getWindowLong(hwnd uintptr, index int) int32 {
ret, _, _ := procGetWindowLong.Call(
hwnd,
uintptr(index))
return int32(ret)
}

View File

@ -3,9 +3,8 @@
package windows
import (
"fmt"
"log"
"syscall"
"github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/win32"
"github.com/wailsapp/wails/v2/internal/system/operatingsystem"
"unsafe"
"github.com/leaanthony/winc"
@ -20,15 +19,20 @@ type Window struct {
applicationMenu *menu.Menu
notifyParentWindowPositionChanged func() error
minWidth, minHeight, maxWidth, maxHeight int
versionInfo *operatingsystem.WindowsVersionInfo
isDarkMode bool
isActive bool
}
func NewWindow(parent winc.Controller, appoptions *options.App) *Window {
func NewWindow(parent winc.Controller, appoptions *options.App, versionInfo *operatingsystem.WindowsVersionInfo) *Window {
result := &Window{
frontendOptions: appoptions,
minHeight: appoptions.MinHeight,
minWidth: appoptions.MinWidth,
maxHeight: appoptions.MaxHeight,
maxWidth: appoptions.MaxWidth,
versionInfo: versionInfo,
isActive: true,
}
result.SetIsForm(true)
@ -46,7 +50,8 @@ func NewWindow(parent winc.Controller, appoptions *options.App) *Window {
var dwStyle = w32.WS_OVERLAPPEDWINDOW
winc.RegClassOnlyOnce("wailsWindow")
result.SetHandle(winc.CreateWindow("wailsWindow", parent, uint(exStyle), uint(dwStyle)))
handle := winc.CreateWindow("wailsWindow", parent, uint(exStyle), uint(dwStyle))
result.SetHandle(handle)
winc.RegMsgHandler(result)
result.SetParent(parent)
@ -69,6 +74,8 @@ func NewWindow(parent winc.Controller, appoptions *options.App) *Window {
result.SetMaxSize(appoptions.MaxWidth, appoptions.MaxHeight)
}
result.updateTheme()
if appoptions.Windows != nil {
if appoptions.Windows.WindowIsTranslucent {
result.SetTranslucentBackground()
@ -125,12 +132,26 @@ func (w *Window) SetMaxSize(maxWidth int, maxHeight int) {
func (w *Window) WndProc(msg uint32, wparam, lparam uintptr) uintptr {
switch msg {
case w32.WM_SETTINGCHANGE:
settingChanged := w32.UTF16PtrToString((*uint16)(unsafe.Pointer(lparam)))
if settingChanged == "ImmersiveColorSet" {
w.updateTheme()
}
return 0
case w32.WM_NCLBUTTONDOWN:
w32.SetFocus(w.Handle())
case w32.WM_MOVE, w32.WM_MOVING:
if w.notifyParentWindowPositionChanged != nil {
w.notifyParentWindowPositionChanged()
}
case w32.WM_ACTIVATE:
if int(wparam) == w32.WA_INACTIVE {
w.isActive = false
w.updateTheme()
} else {
w.isActive = true
w.updateTheme()
}
// TODO move WM_DPICHANGED handling into winc
case 0x02E0: //w32.WM_DPICHANGED
@ -152,15 +173,7 @@ func (w *Window) WndProc(msg uint32, wparam, lparam uintptr) uintptr {
// As a result we have hidden the titlebar but still have the default window frame styling.
// See: https://docs.microsoft.com/en-us/windows/win32/api/dwmapi/nf-dwmapi-dwmextendframeintoclientarea#remarks
if winoptions := w.frontendOptions.Windows; winoptions == nil || !winoptions.DisableFramelessWindowDecorations {
// -1: Adds the default frame styling (aero shadow and e.g. rounded corners on Windows 11)
// Also shows the caption buttons if transparent ant translucent but they don't work.
// 0: Adds the default frame styling but no aero shadow, does not show the caption buttons.
// 1: Adds the default frame styling (aero shadow and e.g. rounded corners on Windows 11) but no caption buttons
// are shown if transparent ant translucent.
margins := w32.MARGINS{1, 1, 1, 1} // Only extend 1 pixel to have the default frame styling but no caption buttons
if err := dwmExtendFrameIntoClientArea(w.Handle(), margins); err != nil {
log.Fatal(fmt.Errorf("DwmExtendFrameIntoClientArea failed: %s", err))
}
win32.ExtendFrameIntoClientArea(w.Handle())
}
case w32.WM_NCCALCSIZE:
// Disable the standard frame by allowing the client area to take the full
@ -208,25 +221,6 @@ func (w *Window) WndProc(msg uint32, wparam, lparam uintptr) uintptr {
}
func (w *Window) IsMaximised() bool {
style := uint32(w32.GetWindowLong(w.Handle(), w32.GWL_STYLE))
return style&w32.WS_MAXIMIZE != 0
}
// TODO this should be put into the winc if we are happy with this solution.
var (
modkernel32 = syscall.NewLazyDLL("dwmapi.dll")
procDwmExtendFrameIntoClientArea = modkernel32.NewProc("DwmExtendFrameIntoClientArea")
)
func dwmExtendFrameIntoClientArea(hwnd w32.HWND, margins w32.MARGINS) error {
ret, _, _ := procDwmExtendFrameIntoClientArea.Call(
uintptr(hwnd),
uintptr(unsafe.Pointer(&margins)))
if ret != 0 {
return syscall.GetLastError()
}
return nil
return win32.IsWindowMaximised(w.Handle())
}

View File

@ -0,0 +1,59 @@
package operatingsystem
import (
"golang.org/x/sys/windows/registry"
"strconv"
)
type WindowsVersionInfo struct {
Major int
Minor int
Build int
DisplayVersion string
}
func (w *WindowsVersionInfo) IsWindowsVersionAtLeast(major, minor, buildNumber int) bool {
return w.Major >= major && w.Minor >= minor && w.Build >= buildNumber
}
func GetWindowsVersionInfo() (*WindowsVersionInfo, error) {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE)
if err != nil {
return nil, err
}
return &WindowsVersionInfo{
Major: regDWORDKeyAsInt(key, "CurrentMajorVersionNumber"),
Minor: regDWORDKeyAsInt(key, "CurrentMinorVersionNumber"),
Build: regStringKeyAsInt(key, "CurrentBuildNumber"),
DisplayVersion: regKeyAsString(key, "DisplayVersion"),
}, nil
}
func regDWORDKeyAsInt(key registry.Key, name string) int {
result, _, err := key.GetIntegerValue(name)
if err != nil {
return -1
}
return int(result)
}
func regStringKeyAsInt(key registry.Key, name string) int {
resultStr, _, err := key.GetStringValue(name)
if err != nil {
return -1
}
result, err := strconv.Atoi(resultStr)
if err != nil {
return -1
}
return result
}
func regKeyAsString(key registry.Key, name string) string {
resultStr, _, err := key.GetStringValue(name)
if err != nil {
return ""
}
return resultStr
}

View File

@ -1,5 +1,40 @@
package windows
type Theme int
const (
// SystemDefault will use whatever the system theme is. The application will follow system theme changes.
SystemDefault Theme = 0
// Dark Mode
Dark Theme = 1
// Light Mode
Light Theme = 2
)
func RGB(r, g, b uint8) int32 {
var col = int32(b)
col = col<<8 | int32(g)
col = col<<8 | int32(r)
return col
}
// ThemeSettings contains optional colours to use.
// They may be set using the hex values: 0x00BBGGRR
type ThemeSettings struct {
DarkModeTitleBar int32
DarkModeTitleBarInactive int32
DarkModeTitleText int32
DarkModeTitleTextInactive int32
DarkModeBorder int32
DarkModeBorderInactive int32
LightModeTitleBar int32
LightModeTitleBarInactive int32
LightModeTitleText int32
LightModeTitleTextInactive int32
LightModeBorder int32
LightModeBorderInactive int32
}
// Options are options specific to Windows
type Options struct {
WebviewIsTransparent bool
@ -13,4 +48,10 @@ type Options struct {
// Path where the WebView2 stores the user data. If empty %APPDATA%\[BinaryName.exe] will be used.
// If the path is not valid, a messagebox will be displayed with the error and the app will exit with error code.
WebviewUserDataPath string
// Dark/Light or System Default Theme
Theme Theme
// Custom settings for dark/light mode
CustomTheme *ThemeSettings
}

View File

@ -47,6 +47,15 @@ func main() {
DisableWindowIcon: false,
DisableFramelessWindowDecorations: false,
WebviewUserDataPath: "",
Theme: windows.SystemDefault,
CustomTheme: &windows.ThemeSettings{
DarkModeTitleBar: windows.RGB(20, 20, 20),
DarkModeTitleText: windows.RGB(200, 200, 200),
DarkModeBorder: windows.RGB(20, 0, 20),
LightModeTitleBar: windows.RGB(200, 200, 200),
LightModeTitleText: windows.RGB(20, 20, 20),
LightModeBorder: windows.RGB(200, 200, 200),
},
},
Mac: &mac.Options{
TitleBar: &mac.TitleBar{
@ -375,6 +384,78 @@ Type: string
This defines the path where the WebView2 stores the user data. If empty `%APPDATA%\[BinaryName.exe]` will be used.
### Theme
Name: Theme
Type: `windows.Theme`
Minimum Windows Version: Windows 10 2004/20H1
This defines the theme that the application should use:
| Value | Description |
| --------------- | ----------- |
| SystemDefault | *Default*. The theme will be based on the system default. If the user changes their theme, the application will update to use the new setting |
| Dark | The application will use a dark theme exclusively |
| Light | The application will use a light theme exclusively |
### CustomTheme
Name: CustomTheme
Type: `windows.CustomTheme`
Minimum Windows Version: Windows 10/11 2009/21H2 Build 22000
Allows you to specify custom colours for TitleBar, TitleText and Border for both light and dark mode, as well as
when the window is active or inactive.
#### CustomTheme
The CustomTheme struct uses `int32` to specify the colour values. These are in the standard(!) Windows format of:
`0x00BBGGAA`. A helper function is provided to do RGB conversions into this format: `windows.RGB(r,g,b uint8)`.
NOTE: Any value not provided will default to black.
```go
type ThemeSettings struct {
DarkModeTitleBar int32
DarkModeTitleBarInactive int32
DarkModeTitleText int32
DarkModeTitleTextInactive int32
DarkModeBorder int32
DarkModeBorderInactive int32
LightModeTitleBar int32
LightModeTitleBarInactive int32
LightModeTitleText int32
LightModeTitleTextInactive int32
LightModeBorder int32
LightModeBorderInactive int32
}
```
Example:
```go
CustomTheme: &windows.ThemeSettings{
// Theme to use when window is active
DarkModeTitleBar: windows.RGB(255, 0, 0), // Red
DarkModeTitleText: windows.RGB(0, 255, 0), // Green
DarkModeBorder: windows.RGB(0, 0, 255), // Blue
LightModeTitleBar: windows.RGB(200, 200, 200),
LightModeTitleText: windows.RGB(20, 20, 20),
LightModeBorder: windows.RGB(200, 200, 200),
// Theme to use when window is inactive
DarkModeTitleBarInactive: windows.RGB(128, 0, 0),
DarkModeTitleTextInactive: windows.RGB(0, 128, 0),
DarkModeBorderInactive: windows.RGB(0, 0, 128),
LightModeTitleBarInactive: windows.RGB(100, 100, 100),
LightModeTitleTextInactive: windows.RGB(10, 10, 10),
LightModeBorderInactive: windows.RGB(100, 100, 100),
},
```
## Mac Specific Options
### TitleBar