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) }