Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions v3/examples/theme/README.md
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.
Comment on lines +24 to +29
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Documentation references incorrect constant names.

The documented constant names don't match the actual code:

  • WinThemeApplication → actual: WinAppDefault
  • WinThemeSystem → actual: WinSystemDefault
  • WinThemeDark → actual: WinDark
  • WinThemeLight → actual: WinLight

This will confuse developers trying to use the API.

📝 Proposed fix
 - **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.
+  - `WinAppDefault` - Inherit the `AppTheme` resolving logic (Default).
+  - `WinSystemDefault` - Force the window to follow the OS theme, ignoring the `AppTheme`.
+  - `WinDark` - Force the window into Dark mode.
+  - `WinLight` - Force the window into Light mode.
🧰 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
Verify each finding against the current code and only fix it if needed.

In `@v3/examples/theme/README.md` around lines 24 - 29, Update the README to use
the actual enum/constant names used in code: replace documented
`WinThemeApplication` with `WinAppDefault`, `WinThemeSystem` with
`WinSystemDefault`, `WinThemeDark` with `WinDark`, and `WinThemeLight` with
`WinLight`; ensure the documentation sentence referencing window methods still
points to `window.GetTheme()` and `window.SetTheme()` and confirm the list items
match the constants used by the Window options parsing logic (search for symbols
WinAppDefault, WinSystemDefault, WinDark, WinLight to verify).

Copy link
Copy Markdown
Collaborator

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: WinThemeApplicationWinAppDefault, WinThemeSystemWinSystemDefault, WinThemeDarkWinDark, WinThemeLightWinLight.


Taliesin is an AI agent. CC @leaanthony

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!


#### 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
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Documentation inconsistency with code.

Line 57 references WinThemeSystem, WinThemeDark, WinThemeLight which should be WinSystemDefault, WinDark, WinLight per the actual code in theme_window.go.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v3/examples/theme/README.md` around lines 56 - 57, The README's theme mapping
text uses incorrect window intent names; update the second bullet to reference
the actual intent constants used in code: replace mentions of WinThemeSystem,
WinThemeDark, WinThemeLight with WinSystemDefault, WinDark, WinLight (keep the
first bullet's AppTheme names as-is if they match code). Edit the README line
that describes the behavior when followApplicationTheme is false so it refers to
WinSystemDefault, WinDark, and WinLight to match theme_window.go and the
resolver that maps those intents to Theme (0/1/2).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 (WinAppDefault, WinSystemDefault, WinDark, WinLight).


Taliesin is an AI agent. CC @leaanthony

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!


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
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Documentation uses incorrect constant names.

Lines 82-84 reference WinThemeApplication, WinThemeSystem, WinThemeDark, WinThemeLight - these should match the actual code constants: WinAppDefault, WinSystemDefault, WinDark, WinLight.

🧰 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.
Context: ... system, returning WinThemeSystem. 3. If the appearance is explicitly populated,...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v3/examples/theme/README.md` around lines 82 - 84, Update the README lines
that reference theme constants to use the actual enum/constant names used in
code: replace WinThemeApplication with WinAppDefault, WinThemeSystem with
WinSystemDefault, WinThemeDark with WinDark, and WinThemeLight with WinLight so
the documentation matches the implementation (references: WinAppDefault,
WinSystemDefault, WinDark, WinLight).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!


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`
35 changes: 35 additions & 0 deletions v3/examples/theme/WindowService.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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in de0e66282. Both SetWinTheme and GetWinTheme now use comma-ok type assertion (win, ok := ctx.Value(application.WindowKey).(application.Window)). The bare cast was panicking on missing context key.


Taliesin is an AI agent. CC @leaanthony

return ""
}
return win.GetTheme().String()
}
49 changes: 49 additions & 0 deletions v3/examples/theme/assets/app.js
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;
});
46 changes: 46 additions & 0 deletions v3/examples/theme/assets/index.html
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>
58 changes: 58 additions & 0 deletions v3/examples/theme/assets/style.css
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);
}
Comment thread
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;
}
69 changes: 69 additions & 0 deletions v3/examples/theme/main.go
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())
}
}
Loading
Loading