feat(titlebar): auto-detect KDE Plasma/Klassy theme for window controls

Adds a runtime-detected "Klassy (auto-detect)" preset for the custom
window controls that reads the user's Plasma decoration theme and
applies matching colors and shape. When Klassy itself is not installed
but Plasma is, the option re-labels to "Plasma (auto-detect)" and
still applies the desktop color scheme. Hidden on non-KDE / non-Linux.

Backend:
  * New desktop_theme module parses kwinrc, kdeglobals, and klassyrc
    without external deps. Exposes detect_desktop_theme Tauri command
    returning DesktopThemeInfo: desktop id, isKlassy flag, WM active/
    inactive background + foreground, accent, DecorationHover, and the
    user's Klassy ButtonIconStyle / ButtonShape when present.

Frontend:
  * windowControlsStore gains applyKlassyPreset(), a cached detection
    helper, and mapKlassyShapeToQbz() that maps Klassy's button shapes
    (FullHeightRectangle, FullHeightRoundedRectangle, Tab, Circle,
    Square, IntegratedRoundedRectangle, ...) onto QBZ's shape set.
  * Adds the full-height-rounded shape to ButtonShape to cover the
    most common Klassy style that QBZ was missing; CSS matches Klassy
    FullHeightRoundedRectangle visual (full-height tab with ~7px
    radius and margin).
  * SettingsView fires detection on mount, derives the preset list
    reactively so the auto-detect option only appears when relevant,
    and adds the new shape to the Style picker.
  * i18n keys added for the new shape and both preset labels in en/es.

cc
This commit is contained in:
vicrodh committed 2026-04-15 09:43:00 -06:00
1 parent 1cb8d2c305
commit 6a5cfc7235
7 files changed
+343 -8

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
//! Desktop theme detection for adaptive window controls.
//!
//! Reads relevant KDE Plasma config files (`kwinrc`, `kdeglobals`, `klassyrc`)
//! so QBZ's custom titlebar can mirror the system's decoration colors when
//! the user has native decorations disabled. Plasma-only today; other desktops
//! return `None`.
use serde::Serialize;
#[derive(Debug, Default, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DesktopThemeInfo {
/// e.g. "plasma-klassy", "plasma-breeze", "other"
pub desktop: String,
pub is_klassy: bool,
/// Hex `#rrggbb` when available.
pub titlebar_active_bg: Option<String>,
pub titlebar_active_fg: Option<String>,
pub titlebar_inactive_bg: Option<String>,
pub titlebar_inactive_fg: Option<String>,
pub accent: Option<String>,
pub decoration_hover: Option<String>,
pub klassy_button_icon_style: Option<String>,
pub klassy_button_shape: Option<String>,
pub klassy_match_app_color: Option<bool>,
}
/// Parse a `R,G,B` KDE color triplet into a `#rrggbb` hex string. Accepts
/// whitespace between components (KDE writes inconsistent spacing).
fn kde_color_to_hex(raw: &str) -> Option<String> {
let parts: Vec<u8> = raw
.split(',')
.map(|s| s.trim().parse::<u8>().ok())
.collect::<Option<Vec<_>>>()?;
if parts.len() < 3 {
return None;
}
Some(format!("#{:02x}{:02x}{:02x}", parts[0], parts[1], parts[2]))
}
/// Read `key=value` from an ini-ish file, scoped to a `[section]` header.
/// Stops at the next `[section]`. Returns the first match found.
fn ini_get(path: &std::path::Path, section: &str, key: &str) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
let mut in_section = false;
for line in content.lines() {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
in_section = &line[1..line.len() - 1] == section;
continue;
}
if !in_section {
continue;
}
if let Some((k, v)) = line.split_once('=') {
if k.trim() == key {
return Some(v.trim().to_string());
}
}
}
None
}
/// Return a best-effort snapshot of the user's desktop decoration theme.
/// Non-Plasma desktops yield `desktop="other"` with everything else `None`.
#[tauri::command]
pub fn detect_desktop_theme() -> DesktopThemeInfo {
let mut info = DesktopThemeInfo {
desktop: "other".to_string(),
..Default::default()
};
let config_dir = match dirs::config_dir() {
Some(d) => d,
None => return info,
};
let kwinrc = config_dir.join("kwinrc");
let kdeglobals = config_dir.join("kdeglobals");
let klassyrc = config_dir.join("klassy/klassyrc");
if !kwinrc.exists() && !kdeglobals.exists() {
return info;
}
let kwin_theme = ini_get(&kwinrc, "org.kde.kdecoration2", "theme")
.or_else(|| ini_get(&kwinrc, "org.kde.kdecoration3", "theme"));
match kwin_theme.as_deref() {
Some("Klassy") => {
info.desktop = "plasma-klassy".to_string();
info.is_klassy = true;
}
Some(_) => info.desktop = "plasma-breeze".to_string(),
None => info.desktop = "plasma-breeze".to_string(),
}
if info.is_klassy {
info.klassy_button_icon_style = ini_get(&klassyrc, "Windeco", "ButtonIconStyle");
info.klassy_button_shape = ini_get(&klassyrc, "Windeco", "ButtonShape");
info.klassy_match_app_color = ini_get(&klassyrc, "Windeco", "MatchTitleBarToApplicationColor")
.as_deref()
.map(|v| v.eq_ignore_ascii_case("true") || v.starts_with('1'));
}
info.titlebar_active_bg = ini_get(&kdeglobals, "WM", "activeBackground").and_then(|v| kde_color_to_hex(&v));
info.titlebar_active_fg = ini_get(&kdeglobals, "WM", "activeForeground").and_then(|v| kde_color_to_hex(&v));
info.titlebar_inactive_bg = ini_get(&kdeglobals, "WM", "inactiveBackground").and_then(|v| kde_color_to_hex(&v));
info.titlebar_inactive_fg = ini_get(&kdeglobals, "WM", "inactiveForeground").and_then(|v| kde_color_to_hex(&v));
info.accent = ini_get(&kdeglobals, "Colors:Window", "DecorationFocus").and_then(|v| kde_color_to_hex(&v));
info.decoration_hover = ini_get(&kdeglobals, "Colors:Window", "DecorationHover").and_then(|v| kde_color_to_hex(&v));
info
}
+3
View File
@@ -12,6 +12,7 @@ pub mod session_lifecycle;
pub mod tauri_adapter;
pub mod auto_theme;
pub mod desktop_theme;
#[cfg(target_os = "linux")]
pub mod autoconfig_graphics;
@@ -1761,6 +1762,8 @@ pub fn run() {
commands_v2::v2_set_image_cache_max_size,
commands_v2::v2_get_image_cache_stats,
commands_v2::v2_clear_image_cache,
// Desktop theme detection (KDE/Klassy → adaptive window controls)
desktop_theme::detect_desktop_theme,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")