diff --git a/frontend/editor/src-tauri/Cargo.toml b/frontend/editor/src-tauri/Cargo.toml index f617cd0738..51839c0f86 100644 --- a/frontend/editor/src-tauri/Cargo.toml +++ b/frontend/editor/src-tauri/Cargo.toml @@ -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", diff --git a/frontend/editor/src-tauri/src/commands/mod.rs b/frontend/editor/src-tauri/src/commands/mod.rs index 1637c926c7..9c57531e18 100644 --- a/frontend/editor/src-tauri/src/commands/mod.rs +++ b/frontend/editor/src-tauri/src/commands/mod.rs @@ -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, diff --git a/frontend/editor/src-tauri/src/commands/titlebar.rs b/frontend/editor/src-tauri/src/commands/titlebar.rs new file mode 100644 index 0000000000..ea403a8efe --- /dev/null +++ b/frontend/editor/src-tauri/src/commands/titlebar.rs @@ -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::() 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; + } +} diff --git a/frontend/editor/src-tauri/src/commands/window.rs b/frontend/editor/src-tauri/src/commands/window.rs index 761d47aeea..e738859651 100644 --- a/frontend/editor/src-tauri/src/commands/window.rs +++ b/frontend/editor/src-tauri/src/commands/window.rs @@ -51,7 +51,11 @@ fn build_window(app: &AppHandle, label: &str, url: &str) -> Result = 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, diff --git a/frontend/editor/src/desktop/components/AppProviders.tsx b/frontend/editor/src/desktop/components/AppProviders.tsx index d7405ef83d..154961c943 100644 --- a/frontend/editor/src/desktop/components/AppProviders.tsx +++ b/frontend/editor/src/desktop/components/AppProviders.tsx @@ -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. */} +
{updatePopupModal} @@ -354,6 +356,7 @@ export function AppProviders({ children }: { children: ReactNode }) { }} > + diff --git a/frontend/editor/src/desktop/components/DesktopTitleBarSync.tsx b/frontend/editor/src/desktop/components/DesktopTitleBarSync.tsx new file mode 100644 index 0000000000..ee2f28f3b7 --- /dev/null +++ b/frontend/editor/src/desktop/components/DesktopTitleBarSync.tsx @@ -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 + * 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; +}