diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3ee7131..eee9a2a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -60,6 +60,7 @@ keyring = "4" windows-sys = { version = "0.61", features = [ "Win32_Security_Cryptography", "Win32_Foundation", + "Win32_Graphics_Dwm", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b7f810..9ae358c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,4 +9,5 @@ pub mod ipc; pub mod private_file; pub mod qr; pub mod sync; +pub mod title_bar; pub mod words; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f9eec50..6126e6b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -425,6 +425,15 @@ async fn setup_from_file(app: State<'_, App>, request: tauri::ipc::Request<'_>) app.sync.setup_from_file(bytes) } +/// The theme's `bg` and `text` for the native title bar (Windows 11 only; see `title_bar`). +#[tauri::command] +fn set_title_bar_colors(window: tauri::WebviewWindow, bg: String, text: String) { + #[cfg(windows)] + kestrove_desktop::title_bar::paint(&window, &bg, &text); + #[cfg(not(windows))] + let _ = (window, bg, text); +} + #[tauri::command] fn clear_setup(app: State<'_, App>) { app.sync.clear_setup(); @@ -616,6 +625,7 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ state, + set_title_bar_colors, create_account, unlock, lock, diff --git a/src-tauri/src/title_bar.rs b/src-tauri/src/title_bar.rs new file mode 100644 index 0000000..13f4bfb --- /dev/null +++ b/src-tauri/src/title_bar.rs @@ -0,0 +1,52 @@ +//! The window's title bar on Windows and Linux. +//! +//! Tauri 2 can only overlay web content on the title bar on macOS (`titleBarStyle: "Overlay"`), so Windows and +//! Linux keep the native title bar and its window controls. On Windows 11 its caption is painted in the theme's +//! `bg` with `text` for the title, so it reads as one bar with the app's own header below it. Windows 10 and +//! Linux (GTK) draw their own colours. + +/// `#rrggbb` (a design token) → a Win32 `COLORREF` (`0x00bbggrr`). +pub fn colorref(hex: &str) -> Option { + let hex = hex.trim().strip_prefix('#')?; + if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let rgb = u32::from_str_radix(hex, 16).ok()?; + let (r, g, b) = (rgb >> 16, (rgb >> 8) & 0xff, rgb & 0xff); + Some(b << 16 | g << 8 | r) +} + +/// Paints the caption, its title and the window border. Errors (Windows 10 has no caption colour) are ignored. +#[cfg(windows)] +pub fn paint(window: &tauri::WebviewWindow, bg: &str, text: &str) { + use windows_sys::Win32::Graphics::Dwm::{ + DwmSetWindowAttribute, DWMWA_BORDER_COLOR, DWMWA_CAPTION_COLOR, DWMWA_TEXT_COLOR, + }; + let (Ok(hwnd), Some(bg), Some(text)) = (window.hwnd(), colorref(bg), colorref(text)) else { return }; + for (attribute, color) in [(DWMWA_CAPTION_COLOR, bg), (DWMWA_BORDER_COLOR, bg), (DWMWA_TEXT_COLOR, text)] { + // SAFETY: a live window handle and a COLORREF-sized value, as DwmSetWindowAttribute expects. + unsafe { + DwmSetWindowAttribute(hwnd.0, attribute as u32, (&color as *const u32).cast(), size_of::() as u32); + } + } +} + +#[cfg(test)] +mod tests { + use super::colorref; + + #[test] + fn token_hex_to_colorref() { + assert_eq!(colorref("#f4f6f4"), Some(0x00f4f6f4)); + assert_eq!(colorref(" #0B1210 "), Some(0x0010120b)); + assert_eq!(colorref("#13201b"), Some(0x001b2013)); + } + + #[test] + fn rejects_other_formats() { + assert_eq!(colorref("f4f6f4"), None); + assert_eq!(colorref("#fff"), None); + assert_eq!(colorref("rgb(1, 2, 3)"), None); + assert_eq!(colorref("#gg0000"), None); + } +} diff --git a/src/main.ts b/src/main.ts index 18b4cfd..4a39d90 100644 --- a/src/main.ts +++ b/src/main.ts @@ -81,8 +81,22 @@ function applyTheme(t: Theme) { else document.documentElement.dataset.theme = t; // The native title bar and window controls follow too. void getCurrentWindow().setTheme(t === "system" ? null : t).catch(() => undefined); + paintTitleBar(); } +/// Windows and Linux keep the native title bar (Tauri overlays it only on macOS); on Windows 11 its caption +/// takes the theme's `bg` and `text`, so it and the header below read as one custom title bar. +function paintTitleBar() { + if (isMac) return; + const css = getComputedStyle(document.documentElement); + const bg = css.getPropertyValue("--bg").trim(); + const text = css.getPropertyValue("--text").trim(); + void invoke("set_title_bar_colors", { bg, text }).catch(() => undefined); +} + +// "System" follows the OS setting while the app runs. +matchMedia("(prefers-color-scheme: dark)").addEventListener("change", paintTitleBar); + function setTheme(t: Theme) { try { localStorage.setItem("theme", t);