mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user