-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(v3,windows,macos): introduce application and window theme system (#4665) #5042
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
b19c657
6d9e228
e8a1bf0
2c123b8
e8ade53
1b57748
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`. | ||
|
Comment on lines
+56
to
+57
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Documentation inconsistency with code. Line 57 references 🤖 Prompt for AI Agents
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in de0e66282. All constant references in the README have been updated to match the actual exported identifiers ( Taliesin is an AI agent. CC @leaanthony
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| 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. | ||
|
Comment on lines
+82
to
+84
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Documentation uses incorrect constant names. Lines 82-84 reference 🧰 Tools🪛 LanguageTool[style] ~84-~84: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym. (ENGLISH_WORD_REPEAT_BEGINNING_RULE) 🤖 Prompt for AI Agents
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in de0e66282. Lines 82-84 and all surrounding constant references updated to use the correct exported names. Taliesin is an AI agent. CC @leaanthony
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| 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` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { | ||
|
Comment on lines
+22
to
+31
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in de0e66282. Both Taliesin is an AI agent. CC @leaanthony |
||
| return "" | ||
| } | ||
| return win.GetTheme().String() | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
|
|
||
| <head> | ||
| <meta charset="UTF-8"> | ||
| <title>Theme Example</title> | ||
| <link rel="stylesheet" href="style.css"> | ||
|
|
||
| <!-- <script src="/wails/runtime.js" type="module"></script> --> | ||
| <!-- <script> | ||
| async function callBinding(name, ...params) { | ||
| return wails.Call.ByName(name, ...params) | ||
| } | ||
| </script> --> | ||
| </head> | ||
|
|
||
| <body> | ||
| <div class="wrapper"> | ||
|
|
||
| <div class="container"> | ||
| <h3>Application Theme:</h3> | ||
| <h2 id="app-theme">Current Theme</h2> | ||
|
|
||
| <div class="control-container"> | ||
| <button id="app-theme-system" class="btn">System</button> | ||
| <button id="app-theme-dark" class="btn">Dark</button> | ||
| <button id="app-theme-light" class="btn">Light</button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="container"> | ||
| <h3>Window Theme:</h3> | ||
| <h2 id="win-theme">Current Theme</h2> | ||
|
|
||
| <div class="control-container"> | ||
| <button id="win-theme-app" class="btn">App</button> | ||
| <button id="win-theme-system" class="btn">System</button> | ||
| <button id="win-theme-dark" class="btn">Dark</button> | ||
| <button id="win-theme-light" class="btn">Light</button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <script type="module" src="app.js"></script> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| .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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()) | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Documentation references incorrect constant names.
The documented constant names don't match the actual code:
WinThemeApplication→ actual:WinAppDefaultWinThemeSystem→ actual:WinSystemDefaultWinThemeDark→ actual:WinDarkWinThemeLight→ actual:WinLightThis will confuse developers trying to use the API.
📝 Proposed fix
🧰 Tools
🪛 LanguageTool
[style] ~26-~26: Consider using a different adverb to strengthen your wording.
Context: ...orce the window to follow the OS theme, completely ignoring the
AppTheme. - `WinThemeD...(COMPLETELY_ENTIRELY)
🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in de0e66282. All README references updated:
WinThemeApplication→WinAppDefault,WinThemeSystem→WinSystemDefault,WinThemeDark→WinDark,WinThemeLight→WinLight.Taliesin is an AI agent. CC @leaanthony
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.