feat(desktop): match the Windows title bar to the app theme

The native Windows caption follows the OS "show accent colour on title
bars" setting, so it renders as a stray coloured strip disconnected from
the dark/light Stirling UI directly below it. There is no Tauri config
option to colour or hide the Windows caption (titleBarStyle/hiddenTitle
are macOS-only), so paint it via DwmSetWindowAttribute (Windows 11) from
the app's --c-bg / --c-text instead.

- set_titlebar_color command applies a caption + text colour to a window
- DesktopTitleBarSync resolves the theme colours and repaints on change
- windows paint a best-guess colour at creation to avoid an accent flash

No-op on macOS/Linux and below Windows 11 build 22000.
This commit is contained in:
Connor Yoh
2026-09-02 13:23:49 +01:00
parent f6661a8f87
commit 97fc2b4f29
7 changed files with 214 additions and 1 deletions
+1
View File
@@ -58,6 +58,7 @@ objc2-pdf-kit = { version = "0.3.2", features = ["PDFDocument", "objc2-app-kit"]
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Graphics_Dwm",
"Win32_System_Com",
"Win32_UI_Shell",
"Win32_System_ApplicationInstallationAndServicing",
@@ -6,6 +6,7 @@ pub mod default_app;
pub mod local_proxy;
pub mod platform;
pub mod print;
pub mod titlebar;
pub mod updater;
pub mod window;
@@ -44,6 +45,7 @@ pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
pub use local_proxy::proxy_local_pdf_request;
pub use platform::get_desktop_os;
pub use print::print_pdf_file_native;
pub use titlebar::set_titlebar_color;
pub use updater::{
can_install_updates, check_for_update, download_and_install_update, get_app_version,
restart_app,
@@ -0,0 +1,113 @@
// Paint the native Windows title bar (caption) to match the app theme.
//
// Left to itself the caption follows the OS "show accent colour on title bars"
// setting, so it renders as a stray coloured strip disconnected from the dark
// (or light) Stirling UI below it. Windows 11 lets an app override the caption
// background and text via DwmSetWindowAttribute; we drive those from the app's
// --c-bg / --c-text so the chrome reads as part of the window.
//
// Windows 11 build 22000+ only: on older builds the attributes are silently
// ignored (no error), and on macOS/Linux this whole module is a no-op.
/// An sRGB colour supplied by the frontend (resolved from a CSS custom property).
// Fields are only read on Windows, where they feed the COLORREF below.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
#[derive(serde::Deserialize)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
// COLORREF packs the channels as 0x00BBGGRR.
#[cfg(target_os = "windows")]
fn colorref(c: &Rgb) -> u32 {
(c.r as u32) | ((c.g as u32) << 8) | ((c.b as u32) << 16)
}
#[cfg(target_os = "windows")]
fn paint_caption(window: &tauri::WebviewWindow, caption: &Rgb, text: &Rgb) -> Result<(), String> {
use windows::Win32::Foundation::{COLORREF, HWND};
use windows::Win32::Graphics::Dwm::{
DwmSetWindowAttribute, DWMWA_CAPTION_COLOR, DWMWA_TEXT_COLOR,
};
// Tauri hands back an HWND from its own (older) `windows` crate, a distinct
// type from the one this crate links. They share the same representation
// (a *mut c_void), so rebuild ours from the raw pointer.
let raw = window.hwnd().map_err(|e| e.to_string())?;
let hwnd = HWND(raw.0 as *mut _);
let caption = COLORREF(colorref(caption));
let text = COLORREF(colorref(text));
let size = std::mem::size_of::<COLORREF>() as u32;
// SAFETY: `hwnd` is a live top-level window and the attribute pointers stay
// valid for the duration of each synchronous call.
unsafe {
DwmSetWindowAttribute(
hwnd,
DWMWA_CAPTION_COLOR,
(&caption as *const COLORREF).cast(),
size,
)
.map_err(|e| e.to_string())?;
DwmSetWindowAttribute(
hwnd,
DWMWA_TEXT_COLOR,
(&text as *const COLORREF).cast(),
size,
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
/// Set the caption background and text colour for the calling window. The
/// frontend calls this on mount and whenever the theme changes, passing the
/// resolved --c-bg / --c-text.
#[tauri::command]
pub fn set_titlebar_color(
window: tauri::WebviewWindow,
caption: Rgb,
text: Rgb,
) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
paint_caption(&window, &caption, &text)
}
#[cfg(not(target_os = "windows"))]
{
let _ = (window, caption, text);
Ok(())
}
}
/// Paint a best-guess caption colour at window-creation time, before the webview
/// has mounted and can report its exact theme. This kills the OS-accent flash on
/// cold start; the frontend re-applies the precise colour a moment later.
pub fn apply_startup_color(window: &tauri::WebviewWindow) {
#[cfg(target_os = "windows")]
{
use tauri::Theme;
// Mirrors --c-bg / --c-text for the default (untinted) accent in
// src/core/theme/colors.css. Keep in sync if those primitives change.
let (caption, text) = match window.theme() {
Ok(Theme::Light) => (
Rgb { r: 0xf5, g: 0xf4, b: 0xf1 },
Rgb { r: 0x37, g: 0x35, b: 0x30 },
),
_ => (
Rgb { r: 0x14, g: 0x14, b: 0x16 },
Rgb { r: 0xfa, g: 0xfa, b: 0xfa },
),
};
if let Err(e) = paint_caption(window, &caption, &text) {
crate::utils::add_log(format!("⚠️ Failed to set startup titlebar colour: {}", e));
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = window;
}
}
@@ -51,7 +51,11 @@ fn build_window(app: &AppHandle, label: &str, url: &str) -> Result<WebviewWindow
let builder =
builder.additional_browser_args("--enable-features=CertVerifierBuiltinFeature");
builder.build().map_err(|e| e.to_string())
let window = builder.build().map_err(|e| e.to_string())?;
// Match the caption to the app theme up front (see titlebar module); the
// window's own webview re-applies the exact colour once it mounts.
crate::commands::titlebar::apply_startup_color(&window);
Ok(window)
}
// Run `work` on the main thread and await its result. WebView2 on Windows
+9
View File
@@ -32,6 +32,7 @@ use commands::{
save_user_info,
set_connection_mode,
set_as_default_pdf_handler,
set_titlebar_color,
get_desktop_os,
get_update_mode,
print_pdf_file_native,
@@ -146,6 +147,13 @@ pub fn run() {
.setup(|app| {
add_log("🚀 Tauri app setup started".to_string());
// Paint the main window's caption to match the app theme before the
// webview mounts, so it never flashes the OS accent colour. The frontend
// re-applies the exact themed colour once it has loaded.
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
commands::titlebar::apply_startup_color(&window);
}
// Files passed on the command line at first launch load into the main
// window once the frontend mounts.
let args: Vec<String> = std::env::args().collect();
@@ -224,6 +232,7 @@ pub fn run() {
clear_user_info,
start_oauth_login,
get_desktop_os,
set_titlebar_color,
print_pdf_file_native,
can_install_updates,
check_for_update,
@@ -1,6 +1,7 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { DesktopConfigSync } from "@app/components/DesktopConfigSync";
import { DesktopTitleBarSync } from "@app/components/DesktopTitleBarSync";
import { DesktopQueryCacheReset } from "@app/components/DesktopQueryCacheReset";
import { DesktopBannerInitializer } from "@app/components/DesktopBannerInitializer";
import { SaveShortcutListener } from "@app/components/SaveShortcutListener";
@@ -328,6 +329,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
>
{/* Also here: the auth check below switches mode pre-authChecked. */}
<DesktopQueryCacheReset />
<DesktopTitleBarSync />
<div style={{ minHeight: "100vh" }} />
{updatePopupModal}
</ProprietaryAppProviders>
@@ -354,6 +356,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
}}
>
<DesktopQueryCacheReset />
<DesktopTitleBarSync />
<SaaSTeamProvider key={appKey}>
<DesktopConfigSync />
<DesktopBannerInitializer />
@@ -0,0 +1,81 @@
import { useEffect } from "react";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { getDesktopOs, DesktopOs } from "@app/services/platformService";
interface Rgb {
r: number;
g: number;
b: number;
}
// Resolve a CSS custom property to concrete sRGB channels. Reading the variable
// directly can hand back an unresolved value (var()/color-mix()); assigning it
// to a probe's `color` and reading the computed style forces the browser to
// resolve it to `rgb(...)`.
function resolveColor(cssVar: string): Rgb | null {
const probe = document.createElement("span");
probe.style.color = `var(${cssVar})`;
probe.style.display = "none";
document.body.appendChild(probe);
const computed = getComputedStyle(probe).color;
probe.remove();
const match = computed.match(/rgba?\(([^)]+)\)/);
if (!match) return null;
const parts = match[1].split(/[\s,/]+/).map(Number);
if (parts.length < 3 || parts.slice(0, 3).some(Number.isNaN)) return null;
return {
r: Math.round(parts[0]),
g: Math.round(parts[1]),
b: Math.round(parts[2]),
};
}
/**
* Desktop-only, Windows-only: keeps the native title bar (caption) painted to
* match the app's --c-bg / --c-text so the window chrome stays on theme instead
* of showing the OS accent colour. Re-applies whenever the theme attributes on
* <html> change (light/dark toggle, system change). Renders nothing.
*
* The Rust command is a safe no-op off Windows and below Windows 11 22000, so
* the OS gate here is only to avoid pointless IPC on mac/Linux.
*/
export function DesktopTitleBarSync() {
useEffect(() => {
if (!isTauri()) return;
let cancelled = false;
let observer: MutationObserver | null = null;
const apply = () => {
const caption = resolveColor("--c-bg");
const text = resolveColor("--c-text");
if (!caption || !text) return;
void invoke("set_titlebar_color", { caption, text }).catch(() => {});
};
void getDesktopOs().then((os) => {
if (cancelled || os !== DesktopOs.Windows) return;
apply();
// The editor's dark palette is gated on data-mantine-color-scheme (set by
// Mantine); data-theme/data-accent flip alongside it. Watch all three so
// any theme change repaints the caption.
observer = new MutationObserver(apply);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: [
"data-theme",
"data-accent",
"data-mantine-color-scheme",
],
});
});
return () => {
cancelled = true;
observer?.disconnect();
};
}, []);
return null;
}