-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(v3,windows,macos): introduce application and window theme system #5458
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
Open
taliesin-ai
wants to merge
1
commit into
wailsapp:master
Choose a base branch
from
taliesin-ai:agent/engineer-windows/f77f055f-theme-fix
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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:** | ||
| - `WinAppDefault` - Inherit the `AppTheme` resolving logic (Default). | ||
| - `WinSystemDefault` - Force the window to follow the OS theme, completely ignoring the `AppTheme`. | ||
| - `WinDark` - Force the window into Dark mode. | ||
| - `WinLight` - Force the window into Light mode. | ||
| - **Behavior:** Configured via window options at creation, and used via `window.GetTheme()` and `window.SetTheme()` methods. This state is purely intent-based and does not directly call OS bindings. | ||
|
|
||
| #### 3. `Theme` (Resolved Output State for Windows OS) | ||
| *Defined in `theme_window_windows.go`* | ||
|
|
||
| `Theme` is the final, resolved integer representation (`0` = System, `1` = Dark, `2` = Light) used exclusively by Windows OS to execute native OS layout changes. | ||
| - **Behavior:** This enum is completely invisible to the frontend and cross-platform APIs. When a Windows OS specific window is asked to update its appearance, it evaluates the `WinTheme` (and potentially the fallback `AppTheme`), runs the resolution function, and outputs this strongly-typed integer state to execute the native API calls. | ||
|
|
||
| --- | ||
|
|
||
| ### The `followApplicationTheme` Flag | ||
|
|
||
| Rather than storing a dedicated `Theme = WinAppDefault` state persistently inside the unexported window struct, the system utilizes a simple boolean flag: `w.parent.followApplicationTheme`. | ||
|
|
||
| **Why use a flag instead of an enum?** | ||
| Because of how macOS handles themes natively (by querying the OS directly for what appearance is currently active), storing a resolved state enum on the window would be redundant and prone to desynchronization. Instead, we only need to know **one thing**: *Is this window currently slaved to the Application's global theme?* | ||
|
|
||
| 1. During initialization (or passing `WinAppDefault` to `SetTheme`), we set `followApplicationTheme = true`. | ||
| 2. Every time a theme needs to be calculated or fetched, we check this flag. | ||
| 3. If it is `true`, we infer the resolved theme from the Application theme. If it is `false` (because the user explicitly set the window to System, Dark, or Light), we resolve based on the window's specific settings. | ||
|
|
||
| --- | ||
|
|
||
| ### Windows Theme Handling (Windows OS) | ||
|
|
||
| For Windows, the UI framework relies on us explicitly telling the OS which theme to render. Therefore, Windows uses our 3-tier enum system to cascade state predictably: | ||
|
|
||
| 1. **Check the Flag:** If `followApplicationTheme` is `true`, the window looks at the global application's `AppTheme` intent (`AppSystemDefault`, `AppDark`, `AppLight`) and maps that intent to the resolved integer `Theme` (`0`, `1`, or `2`). | ||
| 2. **Check the Window Intent:** If `followApplicationTheme` is `false`, the window checks its explicit `WinTheme` intent (`WinSystemDefault`, `WinDark`, or `WinLight`) and maps that to the resolved `Theme`. | ||
|
|
||
| This strict unidirectional flow guarantees that the frontend developer only interacts with high-level strings (`AppTheme` and `WinTheme`), while the messy OS-specific fallback calculations are completely contained. | ||
|
|
||
| --- | ||
|
|
||
| ### macOS Theme Handling (Darwin) | ||
|
|
||
| macOS handles light and dark themes by managing `NSAppearance`. macOS has different Appearances for both light and dark themes, such as `NSAppearanceNameAqua` (light) vs. `NSAppearanceNameDarkAqua` (dark) or their accessibility high-contrast equivalents. | ||
|
|
||
| The macOS implementation handles light/dark/system switching by following the exact same Intent vs. Resolution flow as Windows, however, rather than storing the resolved theme, **it infers the resolved theme from Internal OS Mechanics.** | ||
|
|
||
| macOS allows us to access the Appearance and Effective Appearance of the current window, and we use this to determine the current theme dynamically: | ||
|
|
||
| 1. We utilize `NSAppearance` names to represent UI appearance. | ||
| 2. The theme system divides different `NSAppearance` instances into light and dark variants. | ||
|
|
||
| **macOS Initialization & Resolution:** | ||
| - If the user does *not* set an explicit appearance in the Mac window options, we assume the window should follow the app, and `followApplicationTheme` is set to `true`. | ||
| - If the Application's theme intent is System Default, we do nothing and keep appearance = `""` (which causes macOS to naturally inherit the OS appearance). | ||
| - If the Application's theme intent is Light or Dark, we assign the corresponding Light or Dark appearance to the window. | ||
| - Conversely, if the user explicitly set an appearance (e.g., `appearance = "NSAppearanceNameAqua"`) during window creation, `followApplicationTheme` is `false`, and we force that explicit appearance. | ||
|
|
||
| **Inferring State for `GetTheme()` and `SetTheme()`:** | ||
| Instead of saving what theme the window is, `win.GetTheme()` infers the intent by querying macOS: | ||
| 1. If `followApplicationTheme` is `true`, immediately return `WinAppDefault`. | ||
| 2. If it is `false`, we check the window's explicit macOS appearance. If it represents an empty string (`""`), it means we are following the system, returning `WinSystemDefault`. | ||
| 3. If the appearance is explicitly populated, we check if that specific macOS Appearance is a dark variant or light variant, and return `WinDark` or `WinLight` accordingly. | ||
|
|
||
| This inference keeps the Window struct lightweight, relying directly on the native OS as the source of truth. | ||
|
|
||
| --- | ||
|
|
||
| ### Notes | ||
|
|
||
| * This PR focuses on introducing the **core theme abstraction and API surface**. | ||
| * Platform-specific behavior will continue to be implemented or extended in follow-up changes where necessary. | ||
| * **Refactor Note:** The previous integer-based `Theme` enum in `webview_window_options.go` has been relocated to `theme_window_windows.go` to demonstrate its new use as internal record keeping of applied Theme. | ||
|
|
||
| --- | ||
|
|
||
| ### Files Updated | ||
| 1. `webview_window_options_test.go` | ||
| 2. `webview_window_options.go` | ||
| 3. `application.go` | ||
| 4. `application_darwin.go` | ||
| 5. `application_windows.go` | ||
| 6. `application_android.go` | ||
| 7. `application_ios.go` | ||
| 8. `application_linux.go` | ||
| 9. `application_linux_gtk4.go` | ||
| 10. `application_server.go` | ||
|
|
||
| ### Files Created | ||
| 1. `theme_application.go` | ||
| 2. `theme_window.go` | ||
| 3. `theme_webview_window_darwin.go` | ||
| 4. `theme_webview_window_windows.go` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, ok := ctx.Value(application.WindowKey).(application.Window) | ||
| if !ok || win == nil { | ||
| return | ||
| } | ||
| win.SetTheme(application.WinTheme(theme)) | ||
| } | ||
|
|
||
| func (s *WindowService) GetWinTheme(ctx context.Context) string { | ||
| win, ok := ctx.Value(application.WindowKey).(application.Window) | ||
| if !ok || win == nil { | ||
| return "" | ||
| } | ||
| return win.GetTheme().String() | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
|
|
||
| .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; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Fix Stylelint
declaration-empty-line-beforeviolations.Remove the blank lines before
background-colordeclarations to satisfy the configured rule.Suggested patch
.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); }Also applies to: 49-50
🧰 Tools
🪛 Stylelint (17.11.0)
[error] 38-38: Expected no empty line before declaration (declaration-empty-line-before)
(declaration-empty-line-before)
🤖 Prompt for AI Agents