From 11a3dd2bd827d41dd05b63f50d2f0de854ea6c9a Mon Sep 17 00:00:00 2001 From: Taliesin AI Date: Sat, 16 May 2026 03:35:57 +1000 Subject: [PATCH] feat(v3,windows,macos): introduce application and window theme system Implements AppTheme/WinTheme APIs for Windows and macOS. Includes review fixes: compilation stubs, thread-safety, InvokeSync marshaling, AllowDarkModeForWindow symmetry, unit tests. Closes #4665 --- v3/examples/theme/README.md | 114 ++++++++++++++ v3/examples/theme/WindowService.go | 35 +++++ v3/examples/theme/assets/app.js | 49 ++++++ v3/examples/theme/assets/index.html | 46 ++++++ v3/examples/theme/assets/style.css | 58 ++++++++ v3/examples/theme/main.go | 69 +++++++++ v3/pkg/application/application.go | 10 ++ v3/pkg/application/application_android.go | 4 + .../application/application_android_nocgo.go | 4 + v3/pkg/application/application_darwin.go | 18 +++ v3/pkg/application/application_ios.go | 6 +- v3/pkg/application/application_linux.go | 6 + v3/pkg/application/application_linux_gtk4.go | 6 + v3/pkg/application/application_options.go | 140 +++++++++--------- v3/pkg/application/application_server.go | 74 ++++----- v3/pkg/application/application_windows.go | 14 ++ v3/pkg/application/theme_application.go | 59 ++++++++ .../theme_webview_window_darwin.go | 110 ++++++++++++++ .../theme_webview_window_windows.go | 125 ++++++++++++++++ .../theme_webview_window_windows_test.go | 43 ++++++ v3/pkg/application/theme_window.go | 54 +++++++ v3/pkg/application/theme_window_windows.go | 15 ++ v3/pkg/application/webview_window.go | 5 + v3/pkg/application/webview_window_android.go | 3 +- v3/pkg/application/webview_window_darwin.go | 98 +++++++++++- v3/pkg/application/webview_window_ios.go | 3 +- v3/pkg/application/webview_window_linux.go | 2 + v3/pkg/application/webview_window_options.go | 22 +-- .../webview_window_options_test.go | 21 +-- v3/pkg/application/webview_window_windows.go | 52 ++++--- v3/pkg/application/window.go | 4 + 31 files changed, 1120 insertions(+), 149 deletions(-) create mode 100644 v3/examples/theme/README.md create mode 100644 v3/examples/theme/WindowService.go create mode 100644 v3/examples/theme/assets/app.js create mode 100644 v3/examples/theme/assets/index.html create mode 100644 v3/examples/theme/assets/style.css create mode 100644 v3/examples/theme/main.go create mode 100644 v3/pkg/application/theme_application.go create mode 100644 v3/pkg/application/theme_webview_window_darwin.go create mode 100644 v3/pkg/application/theme_webview_window_windows.go create mode 100644 v3/pkg/application/theme_webview_window_windows_test.go create mode 100644 v3/pkg/application/theme_window.go create mode 100644 v3/pkg/application/theme_window_windows.go diff --git a/v3/examples/theme/README.md b/v3/examples/theme/README.md new file mode 100644 index 00000000000..2b45031bcce --- /dev/null +++ b/v3/examples/theme/README.md @@ -0,0 +1,114 @@ +### Theme System for V3 (Alpha Feature) + +This PR introduces the foundational **Theme system** for Windows and macOS theming in V3. + +--- + +### Theme Architecture: Intent vs. Resolution + +This system implements a strict separation between **Theme Intent** (what the developer/user wants) and **Theme Resolution** (the actual values sent to the operating system APIs, especially on Windows). + +To achieve this cleanly, the architecture introduces three distinct theme enums across the application lifecycle: + +#### 1. `AppTheme` (Application Level Intent) +*Defined in `theme_application.go`* + +`AppTheme` represents the global theme preference for the entire application. It resolves to a string-based enum for maximum flexibility. +- **Available Values:** `AppSystemDefault` (Follow OS), `AppDark` (Force Dark), `AppLight` (Force Light). +- **Behavior:** This value is initialized via `App.Options.Theme` and stored in `App.theme`. It dictates the fallback behavior for all windows unless explicitly overridden. Its value can be read via `app.GetTheme()` and set via `app.SetTheme()`. + +#### 2. `WinTheme` (Window Level Intent) +*Defined in `theme_window.go`* + +`WinTheme` represents the theme preference for an individual window across platforms. It is also a string-based enum. +- **Available Values:** + - `WinAppDefault` - Inherit the `AppTheme` resolving logic (Default). + - `WinSystemDefault` - Force the window to follow the OS theme, completely ignoring the `AppTheme`. + - `WinDark` - Force the window into Dark mode. + - `WinLight` - Force the window into Light mode. +- **Behavior:** Configured via window options at creation, and used via `window.GetTheme()` and `window.SetTheme()` methods. This state is purely intent-based and does not directly call OS bindings. + +#### 3. `Theme` (Resolved Output State for Windows OS) +*Defined in `theme_window_windows.go`* + +`Theme` is the final, resolved integer representation (`0` = System, `1` = Dark, `2` = Light) used exclusively by Windows OS to execute native OS layout changes. +- **Behavior:** This enum is completely invisible to the frontend and cross-platform APIs. When a Windows OS specific window is asked to update its appearance, it evaluates the `WinTheme` (and potentially the fallback `AppTheme`), runs the resolution function, and outputs this strongly-typed integer state to execute the native API calls. + +--- + +### The `followApplicationTheme` Flag + +Rather than storing a dedicated `Theme = WinAppDefault` state persistently inside the unexported window struct, the system utilizes a simple boolean flag: `w.parent.followApplicationTheme`. + +**Why use a flag instead of an enum?** +Because of how macOS handles themes natively (by querying the OS directly for what appearance is currently active), storing a resolved state enum on the window would be redundant and prone to desynchronization. Instead, we only need to know **one thing**: *Is this window currently slaved to the Application's global theme?* + +1. During initialization (or passing `WinAppDefault` to `SetTheme`), we set `followApplicationTheme = true`. +2. Every time a theme needs to be calculated or fetched, we check this flag. +3. If it is `true`, we infer the resolved theme from the Application theme. If it is `false` (because the user explicitly set the window to System, Dark, or Light), we resolve based on the window's specific settings. + +--- + +### Windows Theme Handling (Windows OS) + +For Windows, the UI framework relies on us explicitly telling the OS which theme to render. Therefore, Windows uses our 3-tier enum system to cascade state predictably: + +1. **Check the Flag:** If `followApplicationTheme` is `true`, the window looks at the global application's `AppTheme` intent (`AppSystemDefault`, `AppDark`, `AppLight`) and maps that intent to the resolved integer `Theme` (`0`, `1`, or `2`). +2. **Check the Window Intent:** If `followApplicationTheme` is `false`, the window checks its explicit `WinTheme` intent (`WinSystemDefault`, `WinDark`, or `WinLight`) and maps that to the resolved `Theme`. + +This strict unidirectional flow guarantees that the frontend developer only interacts with high-level strings (`AppTheme` and `WinTheme`), while the messy OS-specific fallback calculations are completely contained. + +--- + +### macOS Theme Handling (Darwin) + +macOS handles light and dark themes by managing `NSAppearance`. macOS has different Appearances for both light and dark themes, such as `NSAppearanceNameAqua` (light) vs. `NSAppearanceNameDarkAqua` (dark) or their accessibility high-contrast equivalents. + +The macOS implementation handles light/dark/system switching by following the exact same Intent vs. Resolution flow as Windows, however, rather than storing the resolved theme, **it infers the resolved theme from Internal OS Mechanics.** + +macOS allows us to access the Appearance and Effective Appearance of the current window, and we use this to determine the current theme dynamically: + +1. We utilize `NSAppearance` names to represent UI appearance. +2. The theme system divides different `NSAppearance` instances into light and dark variants. + +**macOS Initialization & Resolution:** +- If the user does *not* set an explicit appearance in the Mac window options, we assume the window should follow the app, and `followApplicationTheme` is set to `true`. +- If the Application's theme intent is System Default, we do nothing and keep appearance = `""` (which causes macOS to naturally inherit the OS appearance). +- If the Application's theme intent is Light or Dark, we assign the corresponding Light or Dark appearance to the window. +- Conversely, if the user explicitly set an appearance (e.g., `appearance = "NSAppearanceNameAqua"`) during window creation, `followApplicationTheme` is `false`, and we force that explicit appearance. + +**Inferring State for `GetTheme()` and `SetTheme()`:** +Instead of saving what theme the window is, `win.GetTheme()` infers the intent by querying macOS: +1. If `followApplicationTheme` is `true`, immediately return `WinAppDefault`. +2. If it is `false`, we check the window's explicit macOS appearance. If it represents an empty string (`""`), it means we are following the system, returning `WinSystemDefault`. +3. If the appearance is explicitly populated, we check if that specific macOS Appearance is a dark variant or light variant, and return `WinDark` or `WinLight` accordingly. + +This inference keeps the Window struct lightweight, relying directly on the native OS as the source of truth. + +--- + +### Notes + +* This PR focuses on introducing the **core theme abstraction and API surface**. +* Platform-specific behavior will continue to be implemented or extended in follow-up changes where necessary. +* **Refactor Note:** The previous integer-based `Theme` enum in `webview_window_options.go` has been relocated to `theme_window_windows.go` to demonstrate its new use as internal record keeping of applied Theme. + +--- + +### Files Updated +1. `webview_window_options_test.go` +2. `webview_window_options.go` +3. `application.go` +4. `application_darwin.go` +5. `application_windows.go` +6. `application_android.go` +7. `application_ios.go` +8. `application_linux.go` +9. `application_linux_gtk4.go` +10. `application_server.go` + +### Files Created +1. `theme_application.go` +2. `theme_window.go` +3. `theme_webview_window_darwin.go` +4. `theme_webview_window_windows.go` diff --git a/v3/examples/theme/WindowService.go b/v3/examples/theme/WindowService.go new file mode 100644 index 00000000000..761df92ddec --- /dev/null +++ b/v3/examples/theme/WindowService.go @@ -0,0 +1,35 @@ +package main + +import ( + "context" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +type WindowService struct { + app *application.App +} + +func (s *WindowService) SetAppTheme(theme string) { + s.app.SetTheme(application.AppTheme(theme)) +} + +func (s *WindowService) GetAppTheme() string { + return s.app.GetTheme().String() +} + +func (s *WindowService) SetWinTheme(ctx context.Context, theme string) { + win, ok := ctx.Value(application.WindowKey).(application.Window) + if !ok || win == nil { + return + } + win.SetTheme(application.WinTheme(theme)) +} + +func (s *WindowService) GetWinTheme(ctx context.Context) string { + win, ok := ctx.Value(application.WindowKey).(application.Window) + if !ok || win == nil { + return "" + } + return win.GetTheme().String() +} diff --git a/v3/examples/theme/assets/app.js b/v3/examples/theme/assets/app.js new file mode 100644 index 00000000000..cfd23c6d514 --- /dev/null +++ b/v3/examples/theme/assets/app.js @@ -0,0 +1,49 @@ +import { Call, CancellablePromise, Create} from "/wails/runtime.js"; +import { Events, Window} from "/wails/runtime.js"; + +const resultsApp = document.getElementById("app-theme"); +const resultsWin = document.getElementById("win-theme"); + +// Call Function [Services Functions] by name +async function callBinding(name, ...params) { + return Call.ByName(name, ...params); +} + +async function setAppTheme(theme) { + await callBinding("main.WindowService.SetAppTheme", theme); +} + +async function setWinTheme(theme) { + await callBinding("main.WindowService.SetWinTheme", theme); +} + +// Window Event Listeners +window.addEventListener("DOMContentLoaded", async () => { + // fetch the current theme from Go when the page loads + const appTheme = await callBinding("main.WindowService.GetAppTheme"); + resultsApp.innerText = appTheme; + + const winTheme = await callBinding("main.WindowService.GetWinTheme"); + resultsWin.innerText = winTheme; +}); + +// Button Event Listeners +document.getElementById("app-theme-system").addEventListener("click", () => setAppTheme("system")); +document.getElementById("app-theme-light").addEventListener("click", () => setAppTheme("light")); +document.getElementById("app-theme-dark").addEventListener("click", () => setAppTheme("dark")); + +document.getElementById("win-theme-app").addEventListener("click", () => setWinTheme("application")); +document.getElementById("win-theme-system").addEventListener("click", () => setWinTheme("system")); +document.getElementById("win-theme-light").addEventListener("click", () => setWinTheme("light")); +document.getElementById("win-theme-dark").addEventListener("click", () => setWinTheme("dark")); + +// Go Event Listeners +Events.On("common:ApplicationThemeChanged", async (ev) => { + const appTheme = await callBinding("main.WindowService.GetAppTheme"); + resultsApp.innerText = appTheme; +}); + +Events.On("common:ThemeChanged", async (ev) => { + const winTheme = await callBinding("main.WindowService.GetWinTheme"); + resultsWin.innerText = winTheme; +}); \ No newline at end of file diff --git a/v3/examples/theme/assets/index.html b/v3/examples/theme/assets/index.html new file mode 100644 index 00000000000..15f435ff86d --- /dev/null +++ b/v3/examples/theme/assets/index.html @@ -0,0 +1,46 @@ + + + + + + Theme Example + + + + + + + +
+ +
+

Application Theme:

+

Current Theme

+ +
+ + + +
+
+ +
+

Window Theme:

+

Current Theme

+ +
+ + + + +
+
+
+ + + + \ No newline at end of file diff --git a/v3/examples/theme/assets/style.css b/v3/examples/theme/assets/style.css new file mode 100644 index 00000000000..7ff7211cac0 --- /dev/null +++ b/v3/examples/theme/assets/style.css @@ -0,0 +1,58 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --main-color: rgb(255, 255, 255); + --secondary-color: rgb(235, 235, 235); + --text-color: rgb(0, 0, 0); +} + +.dark-theme { + --main-color: rgb(41, 41, 41); + --secondary-color: rgb(66, 66, 66); + --text-color: rgb(255, 255, 255); +} + +body { + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; + font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif; + color: var(--text-color); +} + +.btn { + padding: 5px 10px; +} + +.wrapper { + display: flex; + flex-direction: row; + width: 100vw; + height: 100vh; + gap: 1px; + + background-color: var(--secondary-color); +} + +.container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 20px; + padding: 20px; + width: 50%; + + background-color: var(--main-color); +} + +.control-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 10px; +} \ No newline at end of file diff --git a/v3/examples/theme/main.go b/v3/examples/theme/main.go new file mode 100644 index 00000000000..c406ee7b7f8 --- /dev/null +++ b/v3/examples/theme/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "embed" + // "fmt" + "log" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +//go:embed assets +var assets embed.FS + +func main() { + windowService := &WindowService{} + app := application.New(application.Options{ + Name: "Theme Demo", + Description: "A demo of the theme API", + // We Start With Dark Theme + Theme: application.AppDark, + Assets: application.AssetOptions{ + Handler: application.BundledAssetFileServer(assets), + }, + Mac: application.MacOptions{ + ApplicationShouldTerminateAfterLastWindowClosed: true, + }, + Windows: application.WindowsOptions{}, + Services: []application.Service{ + application.NewService(windowService), + }, + }) + + windowService.app = app + app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "Window 1", + Name: "Window 1", + // Both Mac and Windows will follow light theme + Mac: application.MacWindow{ + Appearance: "NSAppearanceNameAqua", + }, + Windows: application.WindowsWindow{ + Theme: application.WinLight, + }, + }) + + app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "Window 2", + Name: "Window 2", + // Both Mac and Widnows will follow Application Theme + }) + + app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "Window 3", + Name: "Window 3", + // Both Mac and Widnows will follow Dark Theme + Mac: application.MacWindow{ + Appearance: application.NSAppearanceNameDarkAqua, + }, + Windows: application.WindowsWindow{ + Theme: application.WinDark, + }, + }) + + err := app.Run() + + if err != nil { + log.Fatal(err.Error()) + } +} diff --git a/v3/pkg/application/application.go b/v3/pkg/application/application.go index 65f73228347..4a52d4ac9cf 100644 --- a/v3/pkg/application/application.go +++ b/v3/pkg/application/application.go @@ -187,6 +187,12 @@ func New(appOptions Options) *App { } } + // Set the application Theme + result.theme = AppSystemDefault + if appOptions.Theme.Valid() { + result.theme = appOptions.Theme + } + return result } @@ -221,6 +227,7 @@ type ( isOnMainThread() bool isDarkMode() bool getAccentColor() string + setTheme(theme AppTheme) } runnable interface { @@ -420,6 +427,9 @@ type App struct { // singleInstanceManager handles single instance functionality singleInstanceManager *singleInstanceManager + + // ApplicationTheme is the application theme to use for the application and any windows that follow the application theme. + theme AppTheme } func (a *App) Config() Options { diff --git a/v3/pkg/application/application_android.go b/v3/pkg/application/application_android.go index ce620a6038a..b54e1de8e0e 100644 --- a/v3/pkg/application/application_android.go +++ b/v3/pkg/application/application_android.go @@ -240,6 +240,10 @@ func (a *App) isDarkMode() bool { return false } +// setTheme sets the application-wide theme. +// Note: This is currently a stub implementation for Android. +func (a *androidApp) setTheme(_ AppTheme) {} + func (a *App) isWindows() bool { return false } diff --git a/v3/pkg/application/application_android_nocgo.go b/v3/pkg/application/application_android_nocgo.go index 045c5ae3f3a..37979c44781 100644 --- a/v3/pkg/application/application_android_nocgo.go +++ b/v3/pkg/application/application_android_nocgo.go @@ -54,6 +54,10 @@ func (a *App) isDarkMode() bool { return false } +func (a *App) setTheme(theme AppTheme) { + // TODO: Implement theme setting for Android +} + func (a *App) isWindows() bool { return false } diff --git a/v3/pkg/application/application_darwin.go b/v3/pkg/application/application_darwin.go index 6352be7c7e2..649f9b746f2 100644 --- a/v3/pkg/application/application_darwin.go +++ b/v3/pkg/application/application_darwin.go @@ -263,6 +263,24 @@ func (m *macosApp) setIcon(icon []byte) { C.setApplicationIcon(unsafe.Pointer(&icon[0]), C.int(len(icon))) } +// setTheme sets the application-wide theme by synchronizing the theme +// across all open windows. +func (m *macosApp) setTheme(theme AppTheme) { + m.parent.windowsLock.RLock() + var impls []*macosWebviewWindow + for _, window := range m.parent.windows { + if webviewWindow, ok := window.(*WebviewWindow); ok { + if impl, ok := webviewWindow.impl.(*macosWebviewWindow); ok { + impls = append(impls, impl) + } + } + } + m.parent.windowsLock.RUnlock() + for _, impl := range impls { + impl.syncTheme() + } +} + func (m *macosApp) name() string { appName := C.getAppName() defer C.free(unsafe.Pointer(appName)) diff --git a/v3/pkg/application/application_ios.go b/v3/pkg/application/application_ios.go index 6af32bf5e05..fd543c9b101 100644 --- a/v3/pkg/application/application_ios.go +++ b/v3/pkg/application/application_ios.go @@ -86,6 +86,10 @@ func (a *App) isDarkMode() bool { return bool(C.ios_is_dark_mode()) } +// setTheme sets the application-wide theme. +// Note: This is currently a stub implementation for iOS. +func (a *iosApp) setTheme(_ AppTheme) {} + func (a *App) isWindows() bool { return false } @@ -459,4 +463,4 @@ func hasListeners(eventID C.uint) C.bool { // For now, return true to enable all events // TODO: Check actual listener registration return C.bool(true) -} \ No newline at end of file +} diff --git a/v3/pkg/application/application_linux.go b/v3/pkg/application/application_linux.go index 2d3f3782d07..d6b411b3970 100644 --- a/v3/pkg/application/application_linux.go +++ b/v3/pkg/application/application_linux.go @@ -223,6 +223,12 @@ func (a *linuxApp) getAccentColor() string { return "rgb(0,122,255)" } +// setTheme sets the application-wide theme. +// Note: This is currently a stub implementation for Linux. +func (a *linuxApp) setTheme(theme AppTheme) { + // TODO: Implement theme setting for Linux +} + func (a *linuxApp) monitorThemeChanges() { go func() { defer handlePanic() diff --git a/v3/pkg/application/application_linux_gtk4.go b/v3/pkg/application/application_linux_gtk4.go index c774abcca6d..ef55a1e7ee6 100644 --- a/v3/pkg/application/application_linux_gtk4.go +++ b/v3/pkg/application/application_linux_gtk4.go @@ -307,6 +307,12 @@ func (a *linuxApp) isDarkMode() bool { return colorScheme == 1 } +// setTheme sets the application-wide theme. +// Note: This is currently a stub implementation for Linux GTK4. +func (a *linuxApp) setTheme(theme AppTheme) { + // TODO: Implement theme setting for Linux +} + func (a *linuxApp) getAccentColor() string { return "rgb(0,122,255)" } diff --git a/v3/pkg/application/application_options.go b/v3/pkg/application/application_options.go index 946b8733bf1..d4cc67a958c 100644 --- a/v3/pkg/application/application_options.go +++ b/v3/pkg/application/application_options.go @@ -132,6 +132,9 @@ type Options struct { // - Server-side rendering // - Web-only access without desktop dependencies Server ServerOptions + + // Theme specifies the application-wide theme used by the application and by windows that inherit the application theme. + Theme AppTheme } // ServerOptions configures the HTTP server for headless mode. @@ -304,66 +307,66 @@ type LinuxOptions struct { // IOSOptions contains options for iOS applications. type IOSOptions struct { - // DisableInputAccessoryView controls whether the iOS WKWebView shows the - // input accessory toolbar (the bar with Next/Previous/Done) above the keyboard. - // Default: false (accessory bar is shown). - // true => accessory view is disabled/hidden - // false => accessory view is enabled/shown - DisableInputAccessoryView bool - - // Scrolling & Bounce (defaults: scroll/bounce/indicators are enabled on iOS) - // Use Disable* to keep default true behavior without surprising zero-values. - DisableScroll bool - DisableBounce bool - DisableScrollIndicators bool - - // Navigation gestures (default false) - EnableBackForwardNavigationGestures bool - - // Link previews (default true on iOS) - // Use Disable* so default (false) means previews are enabled. - DisableLinkPreview bool - - // Media playback - // Inline playback (default false) -> Enable* - EnableInlineMediaPlayback bool - // Autoplay without user action (default false) -> Enable* - EnableAutoplayWithoutUserAction bool - - // Inspector / Debug (default true in dev) - // Use Disable* so default (false) keeps inspector enabled. - DisableInspectable bool - - // User agent customization - // If empty, defaults apply. ApplicationNameForUserAgent defaults to "wails.io". - UserAgent string - ApplicationNameForUserAgent string - - // App-wide background colour for the main iOS window prior to any WebView creation. - // If AppBackgroundColourSet is true, the delegate will apply this colour to the app window - // during didFinishLaunching. Otherwise, it defaults to white. - AppBackgroundColourSet bool - BackgroundColour RGBA - - // EnableNativeTabs enables a native iOS UITabBar at the bottom of the screen. - // When enabled, the native tab bar will dispatch a 'nativeTabSelected' CustomEvent - // to the window with detail: { index: number }. - // NOTE: If NativeTabsItems has one or more entries, native tabs are auto-enabled - // regardless of this flag, and the provided items will be used. - EnableNativeTabs bool - - // NativeTabsItems configures the labels and optional SF Symbol icons for the - // native UITabBar. If one or more items are provided, native tabs are automatically - // enabled. If empty and EnableNativeTabs is true, default items are used. - NativeTabsItems []NativeTabItem + // DisableInputAccessoryView controls whether the iOS WKWebView shows the + // input accessory toolbar (the bar with Next/Previous/Done) above the keyboard. + // Default: false (accessory bar is shown). + // true => accessory view is disabled/hidden + // false => accessory view is enabled/shown + DisableInputAccessoryView bool + + // Scrolling & Bounce (defaults: scroll/bounce/indicators are enabled on iOS) + // Use Disable* to keep default true behavior without surprising zero-values. + DisableScroll bool + DisableBounce bool + DisableScrollIndicators bool + + // Navigation gestures (default false) + EnableBackForwardNavigationGestures bool + + // Link previews (default true on iOS) + // Use Disable* so default (false) means previews are enabled. + DisableLinkPreview bool + + // Media playback + // Inline playback (default false) -> Enable* + EnableInlineMediaPlayback bool + // Autoplay without user action (default false) -> Enable* + EnableAutoplayWithoutUserAction bool + + // Inspector / Debug (default true in dev) + // Use Disable* so default (false) keeps inspector enabled. + DisableInspectable bool + + // User agent customization + // If empty, defaults apply. ApplicationNameForUserAgent defaults to "wails.io". + UserAgent string + ApplicationNameForUserAgent string + + // App-wide background colour for the main iOS window prior to any WebView creation. + // If AppBackgroundColourSet is true, the delegate will apply this colour to the app window + // during didFinishLaunching. Otherwise, it defaults to white. + AppBackgroundColourSet bool + BackgroundColour RGBA + + // EnableNativeTabs enables a native iOS UITabBar at the bottom of the screen. + // When enabled, the native tab bar will dispatch a 'nativeTabSelected' CustomEvent + // to the window with detail: { index: number }. + // NOTE: If NativeTabsItems has one or more entries, native tabs are auto-enabled + // regardless of this flag, and the provided items will be used. + EnableNativeTabs bool + + // NativeTabsItems configures the labels and optional SF Symbol icons for the + // native UITabBar. If one or more items are provided, native tabs are automatically + // enabled. If empty and EnableNativeTabs is true, default items are used. + NativeTabsItems []NativeTabItem } // NativeTabItem describes a single item in the iOS native UITabBar. // SystemImage is the SF Symbols name to use for the icon (iOS 13+). If empty or // unavailable on the current OS, no icon is shown. type NativeTabItem struct { - Title string `json:"Title"` - SystemImage NativeTabIcon `json:"SystemImage"` + Title string `json:"Title"` + SystemImage NativeTabIcon `json:"SystemImage"` } // NativeTabIcon is a string-based enum for SF Symbols. @@ -371,23 +374,24 @@ type NativeTabItem struct { // any valid SF Symbols name as a plain string. // // Example: -// NativeTabsItems: []NativeTabItem{ -// { Title: "Home", SystemImage: NativeTabIconHouse }, -// { Title: "Settings", SystemImage: "gearshape" }, // arbitrary string still allowed -// } +// +// NativeTabsItems: []NativeTabItem{ +// { Title: "Home", SystemImage: NativeTabIconHouse }, +// { Title: "Settings", SystemImage: "gearshape" }, // arbitrary string still allowed +// } type NativeTabIcon string const ( - // Common icons - NativeTabIconNone NativeTabIcon = "" - NativeTabIconHouse NativeTabIcon = "house" - NativeTabIconGear NativeTabIcon = "gear" - NativeTabIconStar NativeTabIcon = "star" - NativeTabIconPerson NativeTabIcon = "person" - NativeTabIconBell NativeTabIcon = "bell" - NativeTabIconMagnify NativeTabIcon = "magnifyingglass" - NativeTabIconList NativeTabIcon = "list.bullet" - NativeTabIconFolder NativeTabIcon = "folder" + // Common icons + NativeTabIconNone NativeTabIcon = "" + NativeTabIconHouse NativeTabIcon = "house" + NativeTabIconGear NativeTabIcon = "gear" + NativeTabIconStar NativeTabIcon = "star" + NativeTabIconPerson NativeTabIcon = "person" + NativeTabIconBell NativeTabIcon = "bell" + NativeTabIconMagnify NativeTabIcon = "magnifyingglass" + NativeTabIconList NativeTabIcon = "list.bullet" + NativeTabIconFolder NativeTabIcon = "folder" ) /********* Android Options *********/ diff --git a/v3/pkg/application/application_server.go b/v3/pkg/application/application_server.go index c7ed9696ac2..9a5151fcf03 100644 --- a/v3/pkg/application/application_server.go +++ b/v3/pkg/application/application_server.go @@ -326,6 +326,12 @@ func (h *serverApp) isDarkMode() bool { return false } +// setTheme sets the application-wide theme. +// Note: This is currently a stub implementation for server mode. +func (h *serverApp) setTheme(theme AppTheme) { + // TODO: Implement theme setting for Server +} + // getAccentColor returns empty string in server mode. func (h *serverApp) getAccentColor() string { return "" @@ -533,37 +539,37 @@ func (w *serverWebviewWindow) startDrag() error { func (w *serverWebviewWindow) startResize(border string) error { return errors.New("resize not available in server mode") } -func (w *serverWebviewWindow) print() error { return errors.New("print not available in server mode") } -func (w *serverWebviewWindow) setEnabled(enabled bool) {} -func (w *serverWebviewWindow) physicalBounds() Rect { return Rect{} } -func (w *serverWebviewWindow) setPhysicalBounds(bounds Rect) {} -func (w *serverWebviewWindow) bounds() Rect { return Rect{} } -func (w *serverWebviewWindow) setBounds(bounds Rect) {} -func (w *serverWebviewWindow) position() (int, int) { return 0, 0 } -func (w *serverWebviewWindow) setPosition(x int, y int) {} -func (w *serverWebviewWindow) centerOnScreen(_ *Screen) {} -func (w *serverWebviewWindow) relativePosition() (int, int) { return 0, 0 } -func (w *serverWebviewWindow) setRelativePosition(x int, y int) {} -func (w *serverWebviewWindow) flash(enabled bool) {} -func (w *serverWebviewWindow) handleKeyEvent(acceleratorString string) {} -func (w *serverWebviewWindow) getBorderSizes() *LRTB { return &LRTB{} } -func (w *serverWebviewWindow) setMinimiseButtonState(state ButtonState) {} -func (w *serverWebviewWindow) setMaximiseButtonState(state ButtonState) {} -func (w *serverWebviewWindow) setCloseButtonState(state ButtonState) {} -func (w *serverWebviewWindow) setFullscreenButtonState(state ButtonState) {} -func (w *serverWebviewWindow) isIgnoreMouseEvents() bool { return false } -func (w *serverWebviewWindow) setIgnoreMouseEvents(ignore bool) {} -func (w *serverWebviewWindow) cut() {} -func (w *serverWebviewWindow) copy() {} -func (w *serverWebviewWindow) paste() {} -func (w *serverWebviewWindow) undo() {} -func (w *serverWebviewWindow) delete() {} -func (w *serverWebviewWindow) selectAll() {} -func (w *serverWebviewWindow) redo() {} -func (w *serverWebviewWindow) showMenuBar() {} -func (w *serverWebviewWindow) hideMenuBar() {} -func (w *serverWebviewWindow) toggleMenuBar() {} -func (w *serverWebviewWindow) setMenu(menu *Menu) {} -func (w *serverWebviewWindow) snapAssist() {} -func (w *serverWebviewWindow) attachModal(modalWindow *WebviewWindow) {} -func (w *serverWebviewWindow) setContentProtection(enabled bool) {} +func (w *serverWebviewWindow) print() error { return errors.New("print not available in server mode") } +func (w *serverWebviewWindow) setEnabled(enabled bool) {} +func (w *serverWebviewWindow) physicalBounds() Rect { return Rect{} } +func (w *serverWebviewWindow) setPhysicalBounds(bounds Rect) {} +func (w *serverWebviewWindow) bounds() Rect { return Rect{} } +func (w *serverWebviewWindow) setBounds(bounds Rect) {} +func (w *serverWebviewWindow) position() (int, int) { return 0, 0 } +func (w *serverWebviewWindow) setPosition(x int, y int) {} +func (w *serverWebviewWindow) relativePosition() (int, int) { return 0, 0 } +func (w *serverWebviewWindow) setRelativePosition(x int, y int) {} +func (w *serverWebviewWindow) flash(enabled bool) {} +func (w *serverWebviewWindow) handleKeyEvent(acceleratorString string) {} +func (w *serverWebviewWindow) getBorderSizes() *LRTB { return &LRTB{} } +func (w *serverWebviewWindow) setMinimiseButtonState(state ButtonState) {} +func (w *serverWebviewWindow) setMaximiseButtonState(state ButtonState) {} +func (w *serverWebviewWindow) setCloseButtonState(state ButtonState) {} +func (w *serverWebviewWindow) isIgnoreMouseEvents() bool { return false } +func (w *serverWebviewWindow) setIgnoreMouseEvents(ignore bool) {} +func (w *serverWebviewWindow) cut() {} +func (w *serverWebviewWindow) copy() {} +func (w *serverWebviewWindow) paste() {} +func (w *serverWebviewWindow) undo() {} +func (w *serverWebviewWindow) delete() {} +func (w *serverWebviewWindow) selectAll() {} +func (w *serverWebviewWindow) redo() {} +func (w *serverWebviewWindow) showMenuBar() {} +func (w *serverWebviewWindow) hideMenuBar() {} +func (w *serverWebviewWindow) toggleMenuBar() {} +func (w *serverWebviewWindow) setMenu(menu *Menu) {} +func (w *serverWebviewWindow) snapAssist() {} +func (w *serverWebviewWindow) setContentProtection(enabled bool) {} +func (w *serverWebviewWindow) attachModal(_ *WebviewWindow) {} +func (w *serverWebviewWindow) getTheme() WinTheme { return WinAppDefault } +func (w *serverWebviewWindow) setTheme(_ WinTheme) {} diff --git a/v3/pkg/application/application_windows.go b/v3/pkg/application/application_windows.go index 357016541b2..31962072e13 100644 --- a/v3/pkg/application/application_windows.go +++ b/v3/pkg/application/application_windows.go @@ -124,6 +124,20 @@ func (m *windowsApp) on(_ uint) { func (m *windowsApp) setIcon(_ []byte) { } +// setTheme sets the application-wide theme by synchronizing the theme +// across all open windows. +func (m *windowsApp) setTheme(theme AppTheme) { + m.windowMapLock.RLock() + windows := make([]*windowsWebviewWindow, 0, len(m.windowMap)) + for _, window := range m.windowMap { + windows = append(windows, window) + } + m.windowMapLock.RUnlock() + for _, window := range windows { + window.syncTheme() + } +} + func (m *windowsApp) name() string { // appName := C.getAppName() // defer C.free(unsafe.Pointer(appName)) diff --git a/v3/pkg/application/theme_application.go b/v3/pkg/application/theme_application.go new file mode 100644 index 00000000000..6636f82fa33 --- /dev/null +++ b/v3/pkg/application/theme_application.go @@ -0,0 +1,59 @@ +package application + +// AppTheme represents the theme preference for the application. +type AppTheme string + +const ( + // AppSystemDefault follows the system theme (light or dark). + AppSystemDefault AppTheme = "system" + // AppDark forces the application to use a dark theme. + AppDark AppTheme = "dark" + // AppLight forces the application to use a light theme. + AppLight AppTheme = "light" +) + +// String returns the string representation of the application theme. +func (t AppTheme) String() string { + return string(t) +} + +// Valid returns true if the theme is a recognized AppTheme value. +func (t AppTheme) Valid() bool { + switch t { + case AppSystemDefault, AppDark, AppLight: + return true + } + return false +} + +// GetTheme returns the current application-level theme setting. +func (a *App) GetTheme() AppTheme { + return a.theme +} + +// SetTheme sets the application-level theme preference. +// This will apply the theme to the application and any windows configured to follow it. +func (a *App) SetTheme(theme AppTheme) { + if !theme.Valid() { + return + } + + if theme == a.theme { + return + } + a.theme = theme + + if a.impl != nil { + a.impl.setTheme(theme) + // Notify listeners of the theme change + // Use a dedicated application theme event instead of "common:ThemeChanged". + // If the same event were used for both application and window theme updates, + // the frontend would not be able to distinguish whether the change originated + // from the application theme or a specific window override. + + // An alternative would be to include additional information in the event + // payload, but that would complicate the event structure and could require + // emitting updates for windows whose effective theme did not actually change. + a.Event.Emit("common:ApplicationThemeChanged") + } +} diff --git a/v3/pkg/application/theme_webview_window_darwin.go b/v3/pkg/application/theme_webview_window_darwin.go new file mode 100644 index 00000000000..24546d8643d --- /dev/null +++ b/v3/pkg/application/theme_webview_window_darwin.go @@ -0,0 +1,110 @@ +//go:build darwin + +package application + +import "fmt" + +// getOppositeMacAppearance returns the macOS appearance that represents +// the opposite light/dark variant. +func getOppositeMacAppearance(name string) (MacAppearanceType, error) { + if name == "NSAppearanceNameDarkAqua" { + return "NSAppearanceNameAqua", nil + } + + // If opposite appearance doesnt match then send the default Dark Appearance + err := fmt.Errorf("unknown appearance name: %s", name) + return "NSAppearanceNameDarkAqua", err +} + +// isMacAppearanceDark reports whether the current window appearance +// corresponds to a dark macOS appearance. +func isMacAppearanceDark(appr string) bool { + // Check if the appearance name contains "Dark" + switch appr { + case "NSAppearanceNameDarkAqua", + "NSAppearanceNameVibrantDark", + "NSAppearanceNameAccessibilityHighContrastDarkAqua", + "NSAppearanceNameAccessibilityHighContrastVibrantDark": + return true + default: + return false + } +} + +// syncTheme synchronizes the window's appearance with the application-wide theme +// when the window is configured to follow global application theme settings. +func (w *macosWebviewWindow) syncTheme() { + if !w.parent.followApplicationTheme { + return + } + + currentAppearance := w.getEffectiveAppearanceName() + currentDark := isMacAppearanceDark(currentAppearance) + + switch globalApplication.theme { + case AppSystemDefault: + w.clearAppearance() + return + case AppDark: + if !currentDark { + appr, _ := getOppositeMacAppearance(currentAppearance) + w.setAppearanceByName(appr) + } + case AppLight: + if currentDark { + appr, _ := getOppositeMacAppearance(currentAppearance) + w.setAppearanceByName(appr) + } + } +} + +// setTheme sets the theme for the window. If WinThemeApplication is provided, +// the window will follow global application theme settings. +func (w *macosWebviewWindow) setTheme(theme WinTheme) { + switch theme { + case WinSystemDefault: + w.parent.followApplicationTheme = false + w.clearAppearance() + return + case WinAppDefault: + w.parent.followApplicationTheme = true + w.syncTheme() + return + } + + currentAppearance := w.getEffectiveAppearanceName() + isDark := isMacAppearanceDark(currentAppearance) + w.parent.followApplicationTheme = false + + switch theme { + case WinDark: + if !isDark { + appr, _ := getOppositeMacAppearance(currentAppearance) + w.setAppearanceByName(appr) + } + case WinLight: + if isDark { + appr, _ := getOppositeMacAppearance(currentAppearance) + w.setAppearanceByName(appr) + } + } +} + +// getTheme returns the current theme configuration for the window. +func (w *macosWebviewWindow) getTheme() WinTheme { + if w.parent.followApplicationTheme { + return WinAppDefault + } + + explicitAppearance := w.getExplicitAppearanceName() + + if explicitAppearance == "" { + return WinSystemDefault + } + + if isMacAppearanceDark(w.getEffectiveAppearanceName()) { + return WinDark + } + + return WinLight +} diff --git a/v3/pkg/application/theme_webview_window_windows.go b/v3/pkg/application/theme_webview_window_windows.go new file mode 100644 index 00000000000..607be55562a --- /dev/null +++ b/v3/pkg/application/theme_webview_window_windows.go @@ -0,0 +1,125 @@ +//go:build windows + +package application + +import "github.com/wailsapp/wails/v3/pkg/w32" + +// resolveWindowsEffectiveTheme determines the realized Theme for the window by resolving +// application-level and window-level theme settings. It also returns whether the window follows the application theme. +func resolveWindowsEffectiveTheme(winTheme WinTheme, appTheme AppTheme) (theme, bool) { + switch winTheme { + case WinDark: + return dark, false + case WinLight: + return light, false + case WinSystemDefault: + return systemDefault, false + default: + // For WinThemeApplication and/or Unset values we default to following + switch appTheme { + case AppDark: + return dark, true + case AppLight: + return light, true + case AppSystemDefault: + return systemDefault, true + default: + return systemDefault, true + } + } +} + +// syncTheme synchronizes the window's appearance with the application-wide theme, +// assuming the window is configured to follow the application theme. +// Must be called on the UI thread. +func (w *windowsWebviewWindow) syncTheme() { + if !w.parent.followApplicationTheme { + return + } + + switch globalApplication.theme { + case AppSystemDefault: + w.themeMu.Lock() + w.theme = systemDefault + w.themeMu.Unlock() + w.updateTheme(w32.IsCurrentlyDarkMode()) + case AppDark: + w.themeMu.Lock() + changed := w.theme != dark + if changed { + w.theme = dark + } + w.themeMu.Unlock() + if changed { + w32.AllowDarkModeForWindow(w.hwnd, true) + w.updateTheme(true) + } + case AppLight: + w.themeMu.Lock() + changed := w.theme != light + if changed { + w.theme = light + } + w.themeMu.Unlock() + if changed { + w32.AllowDarkModeForWindow(w.hwnd, false) + w.updateTheme(false) + } + } +} + +// setTheme sets the theme for the window. If WinAppDefault is provided, +// the window will follow the application-wide theme settings. +func (w *windowsWebviewWindow) setTheme(theme WinTheme) { + if theme == WinAppDefault { + w.parent.followApplicationTheme = true + w.syncTheme() + return + } + + w.parent.followApplicationTheme = false + switch theme { + case WinDark: + w.themeMu.Lock() + w.theme = dark + w.themeMu.Unlock() + w32.AllowDarkModeForWindow(w.hwnd, true) + w.updateTheme(true) + case WinLight: + w.themeMu.Lock() + w.theme = light + w.themeMu.Unlock() + w32.AllowDarkModeForWindow(w.hwnd, false) + w.updateTheme(false) + case WinSystemDefault: + w.themeMu.Lock() + w.theme = systemDefault + w.themeMu.Unlock() + w.updateTheme(w32.IsCurrentlyDarkMode()) + default: + w.themeMu.Lock() + w.theme = systemDefault + w.themeMu.Unlock() + w.updateTheme(w32.IsCurrentlyDarkMode()) + } +} + +// getTheme returns the current theme configuration for the window. +func (w *windowsWebviewWindow) getTheme() WinTheme { + if w.parent.followApplicationTheme { + return WinAppDefault + } + + w.themeMu.RLock() + t := w.theme + w.themeMu.RUnlock() + + switch t { + case dark: + return WinDark + case light: + return WinLight + default: + return WinSystemDefault + } +} diff --git a/v3/pkg/application/theme_webview_window_windows_test.go b/v3/pkg/application/theme_webview_window_windows_test.go new file mode 100644 index 00000000000..b39418a4ba7 --- /dev/null +++ b/v3/pkg/application/theme_webview_window_windows_test.go @@ -0,0 +1,43 @@ +//go:build windows + +package application + +import "testing" + +func TestResolveWindowsEffectiveTheme(t *testing.T) { + tests := []struct { + name string + winTheme WinTheme + appTheme AppTheme + wantTheme theme + wantFollowApp bool + }{ + // Explicit window overrides — never follow the application. + {"WinDark overrides app", WinDark, AppLight, dark, false}, + {"WinLight overrides app", WinLight, AppDark, light, false}, + {"WinSystemDefault overrides app", WinSystemDefault, AppDark, systemDefault, false}, + + // WinAppDefault (and empty / unset) — always follow the application. + {"WinAppDefault + AppDark → dark", WinAppDefault, AppDark, dark, true}, + {"WinAppDefault + AppLight → light", WinAppDefault, AppLight, light, true}, + {"WinAppDefault + AppSystemDefault → systemDefault", WinAppDefault, AppSystemDefault, systemDefault, true}, + + // Empty WinTheme is treated as WinAppDefault. + {"empty WinTheme + AppDark → dark", "", AppDark, dark, true}, + {"empty WinTheme + AppLight → light", "", AppLight, light, true}, + {"empty WinTheme + AppSystemDefault → systemDefault", "", AppSystemDefault, systemDefault, true}, + {"empty WinTheme + empty AppTheme → systemDefault", "", "", systemDefault, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotTheme, gotFollow := resolveWindowsEffectiveTheme(tc.winTheme, tc.appTheme) + if gotTheme != tc.wantTheme { + t.Errorf("theme: got %v, want %v", gotTheme, tc.wantTheme) + } + if gotFollow != tc.wantFollowApp { + t.Errorf("followApp: got %v, want %v", gotFollow, tc.wantFollowApp) + } + }) + } +} diff --git a/v3/pkg/application/theme_window.go b/v3/pkg/application/theme_window.go new file mode 100644 index 00000000000..48b99ef3ada --- /dev/null +++ b/v3/pkg/application/theme_window.go @@ -0,0 +1,54 @@ +package application + +import "github.com/wailsapp/wails/v3/pkg/events" + +// WinTheme represents the theme preference for a window. +type WinTheme string + +const ( + // WinAppDefault indicates the window should follow the application theme. + WinAppDefault WinTheme = "application" + // WinDark forces the window to use a dark theme. + WinDark WinTheme = "dark" + // WinLight forces the window to use a light theme. + WinLight WinTheme = "light" + // WinSystemDefault indicates the window should follow the system theme. + WinSystemDefault WinTheme = "system" +) + +// String returns the string representation of the window theme. +func (t WinTheme) String() string { + return string(t) +} + +// Valid returns true if the theme is a recognized WinTheme value. +func (t WinTheme) Valid() bool { + switch t { + case WinAppDefault, WinDark, WinLight, WinSystemDefault: + return true + } + return false +} + +// GetTheme returns the current theme of the window. +func (w *WebviewWindow) GetTheme() WinTheme { + if w.impl == nil { + return w.options.Windows.Theme + } + return w.impl.getTheme() +} + +// SetTheme sets the theme for the window. +func (w *WebviewWindow) SetTheme(theme WinTheme) { + if !theme.Valid() { + return + } + w.options.Windows.Theme = theme + if w.impl != nil { + InvokeSync(func() { + w.impl.setTheme(theme) + }) + // Notify listeners of the theme change + w.emit(events.WindowEventType(events.Common.ThemeChanged)) + } +} diff --git a/v3/pkg/application/theme_window_windows.go b/v3/pkg/application/theme_window_windows.go new file mode 100644 index 00000000000..845f2be543c --- /dev/null +++ b/v3/pkg/application/theme_window_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package application + +type theme int + +// theme set to internal unexported enum +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 +) diff --git a/v3/pkg/application/webview_window.go b/v3/pkg/application/webview_window.go index 8615c2b2901..cee70fd0c84 100644 --- a/v3/pkg/application/webview_window.go +++ b/v3/pkg/application/webview_window.go @@ -116,6 +116,8 @@ type ( snapAssist() setContentProtection(enabled bool) attachModal(modalWindow *WebviewWindow) + getTheme() WinTheme + setTheme(theme WinTheme) } ) @@ -176,6 +178,9 @@ type WebviewWindow struct { // unconditionallyClose marks the window to be unconditionally closed (atomic) unconditionallyClose uint32 + + // followApplicationTheme indicates whether the window should follow application theme changes + followApplicationTheme bool } func (w *WebviewWindow) SetMenu(menu *Menu) { diff --git a/v3/pkg/application/webview_window_android.go b/v3/pkg/application/webview_window_android.go index 2624acef556..cb5d4b27b68 100644 --- a/v3/pkg/application/webview_window_android.go +++ b/v3/pkg/application/webview_window_android.go @@ -191,7 +191,8 @@ func (w *androidWebviewWindow) setIgnoreMouseEvents(_ bool) {} func (w *androidWebviewWindow) setOpacity(_ float32) {} -func (w *androidWebviewWindow) setTheme(_ Theme) {} +func (w *androidWebviewWindow) getTheme() WinTheme { return WinAppDefault } +func (w *androidWebviewWindow) setTheme(_ WinTheme) {} func (w *androidWebviewWindow) setPinned(_ bool) {} diff --git a/v3/pkg/application/webview_window_darwin.go b/v3/pkg/application/webview_window_darwin.go index e9a85e55164..998c5a08cf7 100644 --- a/v3/pkg/application/webview_window_darwin.go +++ b/v3/pkg/application/webview_window_darwin.go @@ -551,7 +551,27 @@ void windowSetShowToolbarWhenFullscreen(void* window, bool setting) { delegate.showToolbarWhenFullscreen = setting; } -// Set Window appearance type +// windowGetExplicitAppearanceName returns the name of the explicit appearance override +// currently applied to the window, or NULL if no override is set. +char* windowGetExplicitAppearanceName(void* nsWindow) { + NSAppearance* appearance = [(WebviewWindow*)nsWindow appearance]; + if (appearance == nil) { + return NULL; + } + NSString* appearanceName = [appearance bestMatchFromAppearancesWithNames:@[NSAppearanceNameAqua, NSAppearanceNameDarkAqua]]; + return strdup([appearanceName UTF8String]); +} + +// windowGetEffectiveAppearanceName returns the name of the effective appearance +// currently applied to the window (taking into account system settings and overrides). +char* windowGetEffectiveAppearanceName(void* nsWindow) { + NSAppearance* appearance = [(WebviewWindow*)nsWindow effectiveAppearance]; + NSString* appearanceName = [appearance bestMatchFromAppearancesWithNames:@[NSAppearanceNameAqua, NSAppearanceNameDarkAqua]]; + return strdup([appearanceName UTF8String]); +} + +// windowSetAppearanceTypeByName applies an explicit appearance override to the window +// using the specified appearance name (e.g., NSAppearanceNameDarkAqua). void windowSetAppearanceTypeByName(void* nsWindow, const char *appearanceName) { // set window appearance type by name // Convert appearance name to NSString @@ -562,6 +582,12 @@ void windowSetAppearanceTypeByName(void* nsWindow, const char *appearanceName) { free((void*)appearanceName); } +// windowClearAppearanceType removes any explicit appearance override from the window, +// allowing it to follow the system or application theme. +void windowClearAppearanceType(void* nsWindow) { + [(WebviewWindow*)nsWindow setAppearance:nil]; +} + // Center window on current monitor void windowCenter(void* nsWindow) { WebviewWindow* window = (WebviewWindow*)nsWindow; @@ -1342,6 +1368,62 @@ func (w *macosWebviewWindow) getWebviewPreferences() C.struct_WebviewPreferences return result } +// getEffectiveAppearanceName returns the name of the effective appearance +func (w *macosWebviewWindow) getEffectiveAppearanceName() string { + var result string + var wg sync.WaitGroup + wg.Add(1) + globalApplication.dispatchOnMainThread(func() { + defer wg.Done() + cstr := C.windowGetEffectiveAppearanceName(w.nsWindow) + defer C.free(unsafe.Pointer(cstr)) + result = C.GoString(cstr) + }) + wg.Wait() + return result +} + +// getExplicitAppearanceName reports whether the window has an explicit +// appearance override. +func (w *macosWebviewWindow) getExplicitAppearanceName() string { + var result string + var wg sync.WaitGroup + wg.Add(1) + + globalApplication.dispatchOnMainThread(func() { + defer wg.Done() + cstr := C.windowGetExplicitAppearanceName(w.nsWindow) + if cstr != nil { + result = C.GoString(cstr) + C.free(unsafe.Pointer(cstr)) + } + }) + wg.Wait() + return result +} + +// setAppearanceByName applies an explicit macOS appearance override +// to the window. +// This is dispatched asynchronously to the main thread, consistent with other +// window setter operations in this file. +func (w *macosWebviewWindow) setAppearanceByName(appearanceName MacAppearanceType) { + // Dispatching on GlobalApplication's main thread to ensure that the window is fully initialized before we try to set the appearance + // These are utilized in an event listener hence explicitly stating globalApplication + globalApplication.dispatchOnMainThread(func() { + C.windowSetAppearanceTypeByName(w.nsWindow, C.CString(string(appearanceName))) + }) +} + +// clearAppearance removes any explicit appearance override from the window. +// Once cleared, the window implicitly follows the system appearance. +// This is dispatched asynchronously to the main thread, consistent with other +// window setter operations in this file. +func (w *macosWebviewWindow) clearAppearance() { + globalApplication.dispatchOnMainThread(func() { + C.windowClearAppearanceType(w.nsWindow) + }) +} + func (w *macosWebviewWindow) run() { for eventId := range w.parent.eventListeners { w.on(eventId) @@ -1420,8 +1502,20 @@ func (w *macosWebviewWindow) run() { C.windowSetHideToolbarSeparator(w.nsWindow, C.bool(titleBarOptions.HideToolbarSeparator)) } + // Does the Window follow Application Theme + w.parent.followApplicationTheme = true if macOptions.Appearance != "" { - C.windowSetAppearanceTypeByName(w.nsWindow, C.CString(string(macOptions.Appearance))) + // Explicit Appearance has been provided + w.parent.followApplicationTheme = false + w.setAppearanceByName(macOptions.Appearance) + } else { + // If we do follow Application Resolve the Window to follow Application Theme + switch globalApplication.theme { + case AppDark: + w.setAppearanceByName(NSAppearanceNameDarkAqua) + case AppLight: + w.setAppearanceByName(NSAppearanceNameAqua) + } } // Only apply invisible title bar when the native drag area is hidden diff --git a/v3/pkg/application/webview_window_ios.go b/v3/pkg/application/webview_window_ios.go index 6641e6c5f48..26f344951e6 100644 --- a/v3/pkg/application/webview_window_ios.go +++ b/v3/pkg/application/webview_window_ios.go @@ -223,7 +223,8 @@ func (w *iosWebviewWindow) setIgnoreMouseEvents(_ bool) {} func (w *iosWebviewWindow) setOpacity(_ float32) {} -func (w *iosWebviewWindow) setTheme(_ Theme) {} +func (w *iosWebviewWindow) getTheme() WinTheme { return WinAppDefault } +func (w *iosWebviewWindow) setTheme(_ WinTheme) {} func (w *iosWebviewWindow) setPinned(_ bool) {} diff --git a/v3/pkg/application/webview_window_linux.go b/v3/pkg/application/webview_window_linux.go index 3affcd9010b..5002f0c544a 100644 --- a/v3/pkg/application/webview_window_linux.go +++ b/v3/pkg/application/webview_window_linux.go @@ -473,3 +473,5 @@ func (w *linuxWebviewWindow) hideMenuBar() {} func (w *linuxWebviewWindow) toggleMenuBar() {} func (w *linuxWebviewWindow) snapAssist() {} // No-op on Linux func (w *linuxWebviewWindow) setContentProtection(enabled bool) {} +func (w *linuxWebviewWindow) getTheme() WinTheme { return WinAppDefault } +func (w *linuxWebviewWindow) setTheme(_ WinTheme) {} diff --git a/v3/pkg/application/webview_window_options.go b/v3/pkg/application/webview_window_options.go index 682042a8543..2b05e977392 100644 --- a/v3/pkg/application/webview_window_options.go +++ b/v3/pkg/application/webview_window_options.go @@ -247,9 +247,14 @@ type WindowsWindow struct { // Default: false DisableIcon bool - // Theme (Dark / Light / SystemDefault) - // Default: SystemDefault - The application will follow system theme changes. - Theme Theme + // Theme specifies the theme preference for this window. + // - WinAppDefault (or unset ""): the window follows the application theme + // - WinSystemDefault: the window follows the operating system theme + // - WinDark: the window uses dark mode + // - WinLight: the window uses light mode + // If not specified, an empty string is resolved as WinAppDefault and the + // window inherits the application theme. + Theme WinTheme // Specify custom colours to use for dark/light mode // Default: nil @@ -306,17 +311,6 @@ type WindowsWindow struct { PasswordAutosaveEnabled bool } -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 -) - type WindowTheme struct { // BorderColour is the colour of the window border BorderColour *uint32 diff --git a/v3/pkg/application/webview_window_options_test.go b/v3/pkg/application/webview_window_options_test.go index 837d1882631..7fa68d2bb1b 100644 --- a/v3/pkg/application/webview_window_options_test.go +++ b/v3/pkg/application/webview_window_options_test.go @@ -82,15 +82,18 @@ func TestBackdropType_Constants(t *testing.T) { } } -func TestTheme_Constants(t *testing.T) { - if SystemDefault != 0 { - t.Error("SystemDefault should be 0") +func TestWinTheme_Constants(t *testing.T) { + if WinAppDefault != "application" { + t.Error("WinThemeApplication should be application") } - if Dark != 1 { - t.Error("Dark should be 1") + if WinDark != "dark" { + t.Error("WinThemeDark should be dark") } - if Light != 2 { - t.Error("Light should be 2") + if WinLight != "light" { + t.Error("WinThemeLight should be light") + } + if WinSystemDefault != "system" { + t.Error("WinThemeSystem should be system") } } @@ -294,8 +297,8 @@ func TestWindowsWindow_Defaults(t *testing.T) { if opts.DisableIcon != false { t.Error("DisableIcon should default to false") } - if opts.Theme != SystemDefault { - t.Error("Theme should default to SystemDefault") + if opts.Theme != "" { + t.Error("Theme should default to empty string") } } diff --git a/v3/pkg/application/webview_window_windows.go b/v3/pkg/application/webview_window_windows.go index 9a41d68c3e4..09461464de8 100644 --- a/v3/pkg/application/webview_window_windows.go +++ b/v3/pkg/application/webview_window_windows.go @@ -81,6 +81,12 @@ type windowsWebviewWindow struct { // Modal window tracking parentHWND w32.HWND // Parent window HWND when this window is a modal + + // themeMu protects theme against concurrent access from the SystemThemeChanged event goroutine. + themeMu sync.RWMutex + + // Theme - Record the resolved theme for Windows + theme theme } func (w *windowsWebviewWindow) setMenu(menu *Menu) { @@ -508,29 +514,37 @@ func (w *windowsWebviewWindow) run() { } // Process the theme - switch options.Windows.Theme { - case SystemDefault: - isDark := w32.IsCurrentlyDarkMode() - if isDark { - w32.AllowDarkModeForWindow(w.hwnd, true) - } - w.updateTheme(isDark) - // Don't initialize default dark theme here if custom theme might be set - // The updateTheme call above will handle both default and custom themes - w.parent.onApplicationEvent(events.Windows.SystemThemeChanged, func(*ApplicationEvent) { - InvokeAsync(func() { - w.updateTheme(w32.IsCurrentlyDarkMode()) - }) - }) - case Light: - w.updateTheme(false) - case Dark: + // System, Dark, Light - Resolved Theme to Apply + theme, followAppTheme := resolveWindowsEffectiveTheme(options.Windows.Theme, globalApplication.theme) + w.theme = theme + w.parent.followApplicationTheme = followAppTheme + + switch w.theme { + case systemDefault: + w.updateTheme(w32.IsCurrentlyDarkMode()) + case dark: w32.AllowDarkModeForWindow(w.hwnd, true) w.updateTheme(true) - // Don't initialize default dark theme here if custom theme might be set - // The updateTheme call above will handle custom themes + case light: + w32.AllowDarkModeForWindow(w.hwnd, false) + w.updateTheme(false) } + // Listen to OS theme changes; update when the window is in system-following mode (WinSystemDefault). + w.parent.onApplicationEvent(events.Windows.SystemThemeChanged, func(*ApplicationEvent) { + w.themeMu.RLock() + isSystemDefault := w.theme == systemDefault + w.themeMu.RUnlock() + + if !isSystemDefault { + return + } + + InvokeAsync(func() { + w.updateTheme(w32.IsCurrentlyDarkMode()) + }) + }) + w.setBackgroundColour(options.BackgroundColour) if options.BackgroundType == BackgroundTypeTranslucent { w.setBackdropType(w.parent.options.Windows.BackdropType) diff --git a/v3/pkg/application/window.go b/v3/pkg/application/window.go index e21658acdd9..356a5c0ba6f 100644 --- a/v3/pkg/application/window.go +++ b/v3/pkg/application/window.go @@ -104,4 +104,8 @@ type Window interface { redo() delete() selectAll() + + // Theme methods + GetTheme() WinTheme + SetTheme(theme WinTheme) }