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]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
windows = { version = "0.62", features = [
|
windows = { version = "0.62", features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
|
"Win32_Graphics_Dwm",
|
||||||
"Win32_System_Com",
|
"Win32_System_Com",
|
||||||
"Win32_UI_Shell",
|
"Win32_UI_Shell",
|
||||||
"Win32_System_ApplicationInstallationAndServicing",
|
"Win32_System_ApplicationInstallationAndServicing",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod default_app;
|
|||||||
pub mod local_proxy;
|
pub mod local_proxy;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod print;
|
pub mod print;
|
||||||
|
pub mod titlebar;
|
||||||
pub mod updater;
|
pub mod updater;
|
||||||
pub mod window;
|
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 local_proxy::proxy_local_pdf_request;
|
||||||
pub use platform::get_desktop_os;
|
pub use platform::get_desktop_os;
|
||||||
pub use print::print_pdf_file_native;
|
pub use print::print_pdf_file_native;
|
||||||
|
pub use titlebar::set_titlebar_color;
|
||||||
pub use updater::{
|
pub use updater::{
|
||||||
can_install_updates, check_for_update, download_and_install_update, get_app_version,
|
can_install_updates, check_for_update, download_and_install_update, get_app_version,
|
||||||
restart_app,
|
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 =
|
let builder =
|
||||||
builder.additional_browser_args("--enable-features=CertVerifierBuiltinFeature");
|
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
|
// Run `work` on the main thread and await its result. WebView2 on Windows
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ use commands::{
|
|||||||
save_user_info,
|
save_user_info,
|
||||||
set_connection_mode,
|
set_connection_mode,
|
||||||
set_as_default_pdf_handler,
|
set_as_default_pdf_handler,
|
||||||
|
set_titlebar_color,
|
||||||
get_desktop_os,
|
get_desktop_os,
|
||||||
get_update_mode,
|
get_update_mode,
|
||||||
print_pdf_file_native,
|
print_pdf_file_native,
|
||||||
@@ -146,6 +147,13 @@ pub fn run() {
|
|||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
add_log("🚀 Tauri app setup started".to_string());
|
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
|
// Files passed on the command line at first launch load into the main
|
||||||
// window once the frontend mounts.
|
// window once the frontend mounts.
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
@@ -224,6 +232,7 @@ pub fn run() {
|
|||||||
clear_user_info,
|
clear_user_info,
|
||||||
start_oauth_login,
|
start_oauth_login,
|
||||||
get_desktop_os,
|
get_desktop_os,
|
||||||
|
set_titlebar_color,
|
||||||
print_pdf_file_native,
|
print_pdf_file_native,
|
||||||
can_install_updates,
|
can_install_updates,
|
||||||
check_for_update,
|
check_for_update,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
||||||
import { DesktopConfigSync } from "@app/components/DesktopConfigSync";
|
import { DesktopConfigSync } from "@app/components/DesktopConfigSync";
|
||||||
|
import { DesktopTitleBarSync } from "@app/components/DesktopTitleBarSync";
|
||||||
import { DesktopQueryCacheReset } from "@app/components/DesktopQueryCacheReset";
|
import { DesktopQueryCacheReset } from "@app/components/DesktopQueryCacheReset";
|
||||||
import { DesktopBannerInitializer } from "@app/components/DesktopBannerInitializer";
|
import { DesktopBannerInitializer } from "@app/components/DesktopBannerInitializer";
|
||||||
import { SaveShortcutListener } from "@app/components/SaveShortcutListener";
|
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. */}
|
{/* Also here: the auth check below switches mode pre-authChecked. */}
|
||||||
<DesktopQueryCacheReset />
|
<DesktopQueryCacheReset />
|
||||||
|
<DesktopTitleBarSync />
|
||||||
<div style={{ minHeight: "100vh" }} />
|
<div style={{ minHeight: "100vh" }} />
|
||||||
{updatePopupModal}
|
{updatePopupModal}
|
||||||
</ProprietaryAppProviders>
|
</ProprietaryAppProviders>
|
||||||
@@ -354,6 +356,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DesktopQueryCacheReset />
|
<DesktopQueryCacheReset />
|
||||||
|
<DesktopTitleBarSync />
|
||||||
<SaaSTeamProvider key={appKey}>
|
<SaaSTeamProvider key={appKey}>
|
||||||
<DesktopConfigSync />
|
<DesktopConfigSync />
|
||||||
<DesktopBannerInitializer />
|
<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