diff --git a/v3/examples/theme/README.md b/v3/examples/theme/README.md
new file mode 100644
index 00000000000..f5b4012a19a
--- /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:**
+ - `WinThemeApplication` - Inherit the `AppTheme` resolving logic (Default).
+ - `WinThemeSystem` - Force the window to follow the OS theme, completely ignoring the `AppTheme`.
+ - `WinThemeDark` - Force the window into Dark mode.
+ - `WinThemeLight` - 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 = WinThemeApplication` 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 `WinThemeApplication` 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 (`WinThemeSystem`, `WinThemeDark`, or `WinThemeLight`) 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 `WinThemeApplication`.
+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 `WinThemeSystem`.
+3. If the appearance is explicitly populated, we check if that specific macOS Appearance is a dark variant or light variant, and return `WinThemeDark` or `WinThemeLight` 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..4567f4ad36b
--- /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 := ctx.Value(application.WindowKey).(application.Window)
+ if win == nil {
+ return
+ }
+ win.SetTheme((application.WinTheme(theme)))
+}
+
+func (s *WindowService) GetWinTheme(ctx context.Context) string {
+ win := ctx.Value(application.WindowKey).(application.Window)
+ if 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 cef8f567da2..a0a32652423 100644
--- a/v3/pkg/application/application.go
+++ b/v3/pkg/application/application.go
@@ -183,6 +183,12 @@ func New(appOptions Options) *App {
}
}
+ // Set the application Theme
+ result.theme = AppSystemDefault
+ if appOptions.Theme.Valid() {
+ result.theme = appOptions.Theme
+ }
+
return result
}
@@ -217,6 +223,7 @@ type (
isOnMainThread() bool
isDarkMode() bool
getAccentColor() string
+ setTheme(theme AppTheme)
}
runnable interface {
@@ -416,6 +423,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..f4374871856 100644
--- a/v3/pkg/application/application_android.go
+++ b/v3/pkg/application/application_android.go
@@ -240,6 +240,12 @@ func (a *App) isDarkMode() bool {
return false
}
+// setTheme sets the application-wide theme.
+// Note: This is currently a stub implementation for Android.
+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_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 287b66e84bd..b00da560098 100644
--- a/v3/pkg/application/application_darwin.go
+++ b/v3/pkg/application/application_darwin.go
@@ -263,6 +263,21 @@ 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) {
+ // Cycle through individual window themes to trigger theme resolution
+ m.parent.windowsLock.RLock()
+ defer m.parent.windowsLock.RUnlock()
+ for _, window := range m.parent.windows {
+ if webviewWindow, ok := window.(*WebviewWindow); ok {
+ if impl, ok := webviewWindow.impl.(*macosWebviewWindow); ok {
+ 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..cdec3475146 100644
--- a/v3/pkg/application/application_ios.go
+++ b/v3/pkg/application/application_ios.go
@@ -86,6 +86,12 @@ 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 *App) setTheme(theme AppTheme) {
+ // TODO: Implement theme setting for iOS
+}
+
func (a *App) isWindows() bool {
return false
}
@@ -459,4 +465,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 adc21435043..ebea41ac1e8 100644
--- a/v3/pkg/application/application_linux.go
+++ b/v3/pkg/application/application_linux.go
@@ -221,6 +221,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 031dedbf66a..ee701aa04c4 100644
--- a/v3/pkg/application/application_linux_gtk4.go
+++ b/v3/pkg/application/application_linux_gtk4.go
@@ -299,6 +299,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 41059242f3c..5579f842060 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 ""
@@ -446,21 +452,27 @@ type serverSystemTray struct {
parent *SystemTray
}
-func (t *serverSystemTray) setLabel(label string) {}
-func (t *serverSystemTray) setTooltip(tooltip string) {}
-func (t *serverSystemTray) run() {}
-func (t *serverSystemTray) setIcon(icon []byte) {}
-func (t *serverSystemTray) setMenu(menu *Menu) {}
-func (t *serverSystemTray) setIconPosition(pos IconPosition) {}
-func (t *serverSystemTray) setTemplateIcon(icon []byte) {}
-func (t *serverSystemTray) destroy() {}
-func (t *serverSystemTray) setDarkModeIcon(icon []byte) {}
-func (t *serverSystemTray) bounds() (*Rect, error) { return nil, errors.New("system tray not available in server mode") }
-func (t *serverSystemTray) getScreen() (*Screen, error) { return nil, errors.New("system tray not available in server mode") }
-func (t *serverSystemTray) positionWindow(w Window, o int) error { return errors.New("system tray not available in server mode") }
-func (t *serverSystemTray) openMenu() {}
-func (t *serverSystemTray) Show() {}
-func (t *serverSystemTray) Hide() {}
+func (t *serverSystemTray) setLabel(label string) {}
+func (t *serverSystemTray) setTooltip(tooltip string) {}
+func (t *serverSystemTray) run() {}
+func (t *serverSystemTray) setIcon(icon []byte) {}
+func (t *serverSystemTray) setMenu(menu *Menu) {}
+func (t *serverSystemTray) setIconPosition(pos IconPosition) {}
+func (t *serverSystemTray) setTemplateIcon(icon []byte) {}
+func (t *serverSystemTray) destroy() {}
+func (t *serverSystemTray) setDarkModeIcon(icon []byte) {}
+func (t *serverSystemTray) bounds() (*Rect, error) {
+ return nil, errors.New("system tray not available in server mode")
+}
+func (t *serverSystemTray) getScreen() (*Screen, error) {
+ return nil, errors.New("system tray not available in server mode")
+}
+func (t *serverSystemTray) positionWindow(w Window, o int) error {
+ return errors.New("system tray not available in server mode")
+}
+func (t *serverSystemTray) openMenu() {}
+func (t *serverSystemTray) Show() {}
+func (t *serverSystemTray) Hide() {}
// newWindowImpl creates a webview window implementation for server mode.
func newWindowImpl(parent *WebviewWindow) *serverWebviewWindow {
@@ -473,82 +485,88 @@ type serverWebviewWindow struct {
}
// All webviewWindowImpl methods as no-ops for server mode
-func (w *serverWebviewWindow) setTitle(title string) {}
-func (w *serverWebviewWindow) setSize(width, height int) {}
-func (w *serverWebviewWindow) setAlwaysOnTop(alwaysOnTop bool) {}
-func (w *serverWebviewWindow) setURL(url string) {}
-func (w *serverWebviewWindow) setResizable(resizable bool) {}
-func (w *serverWebviewWindow) setMinSize(width, height int) {}
-func (w *serverWebviewWindow) setMaxSize(width, height int) {}
-func (w *serverWebviewWindow) execJS(js string) {}
-func (w *serverWebviewWindow) setBackgroundColour(color RGBA) {}
-func (w *serverWebviewWindow) run() {}
-func (w *serverWebviewWindow) center() {}
-func (w *serverWebviewWindow) size() (int, int) { return 0, 0 }
-func (w *serverWebviewWindow) width() int { return 0 }
-func (w *serverWebviewWindow) height() int { return 0 }
-func (w *serverWebviewWindow) destroy() {}
-func (w *serverWebviewWindow) reload() {}
-func (w *serverWebviewWindow) forceReload() {}
-func (w *serverWebviewWindow) openDevTools() {}
-func (w *serverWebviewWindow) zoomReset() {}
-func (w *serverWebviewWindow) zoomIn() {}
-func (w *serverWebviewWindow) zoomOut() {}
-func (w *serverWebviewWindow) getZoom() float64 { return 1.0 }
-func (w *serverWebviewWindow) setZoom(zoom float64) {}
-func (w *serverWebviewWindow) close() {}
-func (w *serverWebviewWindow) zoom() {}
-func (w *serverWebviewWindow) setHTML(html string) {}
-func (w *serverWebviewWindow) on(eventID uint) {}
-func (w *serverWebviewWindow) minimise() {}
-func (w *serverWebviewWindow) unminimise() {}
-func (w *serverWebviewWindow) maximise() {}
-func (w *serverWebviewWindow) unmaximise() {}
-func (w *serverWebviewWindow) fullscreen() {}
-func (w *serverWebviewWindow) unfullscreen() {}
-func (w *serverWebviewWindow) isMinimised() bool { return false }
-func (w *serverWebviewWindow) isMaximised() bool { return false }
-func (w *serverWebviewWindow) isFullscreen() bool { return false }
-func (w *serverWebviewWindow) isNormal() bool { return true }
-func (w *serverWebviewWindow) isVisible() bool { return false }
-func (w *serverWebviewWindow) isFocused() bool { return false }
-func (w *serverWebviewWindow) focus() {}
-func (w *serverWebviewWindow) show() {}
-func (w *serverWebviewWindow) hide() {}
-func (w *serverWebviewWindow) getScreen() (*Screen, error) { return nil, errors.New("screens not available in server mode") }
-func (w *serverWebviewWindow) setFrameless(frameless bool) {}
+func (w *serverWebviewWindow) setTitle(title string) {}
+func (w *serverWebviewWindow) setSize(width, height int) {}
+func (w *serverWebviewWindow) setAlwaysOnTop(alwaysOnTop bool) {}
+func (w *serverWebviewWindow) setURL(url string) {}
+func (w *serverWebviewWindow) setResizable(resizable bool) {}
+func (w *serverWebviewWindow) setMinSize(width, height int) {}
+func (w *serverWebviewWindow) setMaxSize(width, height int) {}
+func (w *serverWebviewWindow) execJS(js string) {}
+func (w *serverWebviewWindow) setBackgroundColour(color RGBA) {}
+func (w *serverWebviewWindow) run() {}
+func (w *serverWebviewWindow) center() {}
+func (w *serverWebviewWindow) size() (int, int) { return 0, 0 }
+func (w *serverWebviewWindow) width() int { return 0 }
+func (w *serverWebviewWindow) height() int { return 0 }
+func (w *serverWebviewWindow) destroy() {}
+func (w *serverWebviewWindow) reload() {}
+func (w *serverWebviewWindow) forceReload() {}
+func (w *serverWebviewWindow) openDevTools() {}
+func (w *serverWebviewWindow) zoomReset() {}
+func (w *serverWebviewWindow) zoomIn() {}
+func (w *serverWebviewWindow) zoomOut() {}
+func (w *serverWebviewWindow) getZoom() float64 { return 1.0 }
+func (w *serverWebviewWindow) setZoom(zoom float64) {}
+func (w *serverWebviewWindow) close() {}
+func (w *serverWebviewWindow) zoom() {}
+func (w *serverWebviewWindow) setHTML(html string) {}
+func (w *serverWebviewWindow) on(eventID uint) {}
+func (w *serverWebviewWindow) minimise() {}
+func (w *serverWebviewWindow) unminimise() {}
+func (w *serverWebviewWindow) maximise() {}
+func (w *serverWebviewWindow) unmaximise() {}
+func (w *serverWebviewWindow) fullscreen() {}
+func (w *serverWebviewWindow) unfullscreen() {}
+func (w *serverWebviewWindow) isMinimised() bool { return false }
+func (w *serverWebviewWindow) isMaximised() bool { return false }
+func (w *serverWebviewWindow) isFullscreen() bool { return false }
+func (w *serverWebviewWindow) isNormal() bool { return true }
+func (w *serverWebviewWindow) isVisible() bool { return false }
+func (w *serverWebviewWindow) isFocused() bool { return false }
+func (w *serverWebviewWindow) focus() {}
+func (w *serverWebviewWindow) show() {}
+func (w *serverWebviewWindow) hide() {}
+func (w *serverWebviewWindow) getScreen() (*Screen, error) {
+ return nil, errors.New("screens not available in server mode")
+}
+func (w *serverWebviewWindow) setFrameless(frameless bool) {}
func (w *serverWebviewWindow) openContextMenu(menu *Menu, data *ContextMenuData) {}
-func (w *serverWebviewWindow) nativeWindow() unsafe.Pointer { return nil }
-func (w *serverWebviewWindow) startDrag() error { return errors.New("drag not available in server mode") }
-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) 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) nativeWindow() unsafe.Pointer { return nil }
+func (w *serverWebviewWindow) startDrag() error {
+ return errors.New("drag not available in server mode")
+}
+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) 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) {}
diff --git a/v3/pkg/application/application_windows.go b/v3/pkg/application/application_windows.go
index 3b93b9a9caf..a5124f2300f 100644
--- a/v3/pkg/application/application_windows.go
+++ b/v3/pkg/application/application_windows.go
@@ -124,6 +124,17 @@ 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) {
+ // Apply theme to all windows
+ m.windowMapLock.RLock()
+ for _, window := range m.windowMap {
+ window.syncTheme()
+ }
+ m.windowMapLock.RUnlock()
+}
+
func (m *windowsApp) name() string {
// appName := C.getAppName()
// defer C.free(unsafe.Pointer(appName))
diff --git a/v3/pkg/application/events_common_ios.go b/v3/pkg/application/events_common_ios.go
index eadfc2afbb1..fe9514f6ec9 100644
--- a/v3/pkg/application/events_common_ios.go
+++ b/v3/pkg/application/events_common_ios.go
@@ -6,19 +6,19 @@ import "github.com/wailsapp/wails/v3/pkg/events"
// Map platform events → common events (same pattern as macOS & others)
var commonApplicationEventMap = map[events.ApplicationEventType]events.ApplicationEventType{
- events.IOS.ApplicationDidFinishLaunching: events.Common.ApplicationStarted,
+ events.IOS.ApplicationDidFinishLaunching: events.Common.ApplicationStarted,
}
// setupCommonEvents forwards iOS platform events to their common counterparts
func (i *iosApp) setupCommonEvents() {
- for sourceEvent, targetEvent := range commonApplicationEventMap {
- sourceEvent := sourceEvent
- targetEvent := targetEvent
- i.parent.Event.OnApplicationEvent(sourceEvent, func(event *ApplicationEvent) {
- event.Id = uint(targetEvent)
- // Log the forwarding so we can see every emitted event in iOS NSLog
- iosConsoleLogf("info", " [events_common_ios.go] Forwarding iOS event %d → common %d", sourceEvent, targetEvent)
- applicationEvents <- event
- })
- }
-}
\ No newline at end of file
+ for sourceEvent, targetEvent := range commonApplicationEventMap {
+ sourceEvent := sourceEvent
+ targetEvent := targetEvent
+ i.parent.Event.OnApplicationEvent(sourceEvent, func(event *ApplicationEvent) {
+ event.Id = uint(targetEvent)
+ // Log the forwarding so we can see every emitted event in iOS NSLog
+ iosConsoleLogf("info", " [events_common_ios.go] Forwarding iOS event %d → common %d", sourceEvent, targetEvent)
+ applicationEvents <- event
+ })
+ }
+}
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..ae019f67545
--- /dev/null
+++ b/v3/pkg/application/theme_webview_window_windows.go
@@ -0,0 +1,103 @@
+//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.
+// Theme updates are expected to run on the UI thread.
+// SystemThemeChanged events dispatch via InvokeAsync, ensuring
+// that window theme state is mutated from a single thread.
+// But if required, Mutex can be added to make sure w.theme does not
+// cause any Race condition.
+func (w *windowsWebviewWindow) syncTheme() {
+ if !w.parent.followApplicationTheme {
+ return
+ }
+
+ switch globalApplication.theme {
+ case AppSystemDefault:
+ w.theme = systemDefault
+ w.updateTheme(w32.IsCurrentlyDarkMode())
+ case AppDark:
+ if w.theme != dark {
+ w.theme = dark
+ w32.AllowDarkModeForWindow(w.hwnd, true)
+ w.updateTheme(true)
+ }
+ case AppLight:
+ if w.theme != light {
+ w.theme = light
+ w.updateTheme(false)
+ }
+ }
+}
+
+// setTheme sets the theme for the window. If WinThemeApplication 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.theme = dark
+ w.updateTheme(true)
+ case WinLight:
+ w.theme = light
+ w.updateTheme(false)
+ case WinSystemDefault:
+ w.theme = systemDefault
+ w.updateTheme(w32.IsCurrentlyDarkMode())
+ default:
+ w.theme = systemDefault
+ w.updateTheme(w32.IsCurrentlyDarkMode())
+ }
+}
+
+// getTheme returns the current theme configuration for the window.
+func (w *windowsWebviewWindow) getTheme() WinTheme {
+ if w.parent.followApplicationTheme {
+ return WinAppDefault
+ }
+
+ if w.theme == systemDefault {
+ return WinSystemDefault
+ }
+
+ if w.theme == dark {
+ return WinDark
+ }
+
+ return WinLight
+}
diff --git a/v3/pkg/application/theme_window.go b/v3/pkg/application/theme_window.go
new file mode 100644
index 00000000000..125ec23aca8
--- /dev/null
+++ b/v3/pkg/application/theme_window.go
@@ -0,0 +1,51 @@
+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 WinAppDefault
+ }
+ return w.impl.getTheme()
+}
+
+// SetTheme sets the theme for the window.
+func (w *WebviewWindow) SetTheme(theme WinTheme) {
+ if !theme.Valid() {
+ return
+ }
+ if w.impl != nil {
+ 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..fe6b1d479a1
--- /dev/null
+++ b/v3/pkg/application/theme_window_windows.go
@@ -0,0 +1,13 @@
+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 91909e83edd..7d49ede2ef0 100644
--- a/v3/pkg/application/webview_window.go
+++ b/v3/pkg/application/webview_window.go
@@ -114,6 +114,8 @@ type (
snapAssist()
setContentProtection(enabled bool)
attachModal(modalWindow *WebviewWindow)
+ getTheme() WinTheme
+ setTheme(theme WinTheme)
}
)
@@ -174,6 +176,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) {
@@ -1290,12 +1295,12 @@ func (w *WebviewWindow) AttachModal(modalWindow Window) {
if w.impl == nil || w.isDestroyed() {
return
}
-
+
modalWebviewWindow, ok := modalWindow.(*WebviewWindow)
if !ok || modalWebviewWindow == nil {
return
}
-
+
InvokeSync(func() {
w.impl.attachModal(modalWebviewWindow)
})
diff --git a/v3/pkg/application/webview_window_darwin.go b/v3/pkg/application/webview_window_darwin.go
index 14fef21dafd..8c97444ce7e 100644
--- a/v3/pkg/application/webview_window_darwin.go
+++ b/v3/pkg/application/webview_window_darwin.go
@@ -548,7 +548,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
@@ -559,6 +579,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;
@@ -1276,6 +1302,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)
@@ -1348,8 +1430,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_options.go b/v3/pkg/application/webview_window_options.go
index c1b1bf164c0..89c83a0da7c 100644
--- a/v3/pkg/application/webview_window_options.go
+++ b/v3/pkg/application/webview_window_options.go
@@ -240,9 +240,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 (default): 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, the window defaults to WinAppDefault and inherits
+ // the application theme.
+ Theme WinTheme
// Specify custom colours to use for dark/light mode
// Default: nil
@@ -299,17 +304,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 4d43e123e53..794e6325803 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 434723fb4d2..5873cde17a4 100644
--- a/v3/pkg/application/webview_window_windows.go
+++ b/v3/pkg/application/webview_window_windows.go
@@ -81,6 +81,9 @@ type windowsWebviewWindow struct {
// Modal window tracking
parentHWND w32.HWND // Parent window HWND when this window is a modal
+
+ // Theme - Record the resolved theme for Windows
+ theme theme
}
func (w *windowsWebviewWindow) setMenu(menu *Menu) {
@@ -505,29 +508,32 @@ 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:
+ w.updateTheme(false)
}
+ // Always listen to OS theme changes but only update the theme if we are following the application theme
+ w.parent.onApplicationEvent(events.Windows.SystemThemeChanged, func(*ApplicationEvent) {
+ if w.theme != systemDefault {
+ 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 ec810b115bd..44eac7e8607 100644
--- a/v3/pkg/application/window.go
+++ b/v3/pkg/application/window.go
@@ -102,4 +102,8 @@ type Window interface {
redo()
delete()
selectAll()
+
+ // Theme methods
+ GetTheme() WinTheme
+ SetTheme(theme WinTheme)
}