use crate::Theme; use configparser::ini::Ini; use palette::blend::Compose; use palette::rgb::Rgba; use palette::{Mix, Srgba, WithAlpha}; use std::fs::{self, File}; use std::io::Write; use std::path::PathBuf; use std::vec; use super::{OutputError, qt_settings_ini_style}; impl Theme { /// The "version" of this theme. /// /// To avoid repeatedly overwriting the user's config, we use a version system. /// /// Increment this value when changes to qt{5,6}ct.conf are needed. /// If the config's version is outdated, we update several sections. /// Otherwise, only the light/dark mode is updated. const COSMIC_QT_VERSION: u64 = 2; /// Produces a QPalette ini file for qt5ct and qt6ct. /// /// Example file: https://github.com/trialuser02/qt6ct/blob/master/colors/airy.conf #[must_use] #[cold] pub fn as_qpalette(&self) -> String { let lightest = if self.is_dark { self.background.on } else { self.background.base }; let darkest = if self.is_dark { self.background.base } else { self.background.on }; let active = QPaletteGroup { window_text: self.background.on, button: self.button.base, light: self.button.base.mix(lightest, 0.1), midlight: self.button.base.mix(lightest, 0.05), dark: self.button.base.mix(darkest, 0.1), mid: self.button.base.mix(darkest, 0.05), text: self.background.component.on, bright_text: lightest, button_text: self.button.on, base: self.background.component.base, window: self.background.base, shadow: darkest, // selection colors are swapped to fix menu bar contrast highlight: self.background.component.selected_text, highlighted_text: self.background.component.selected, link: self.link_button.on, link_visited: self.link_button.on.mix(self.secondary.component.base, 0.2), alternate_base: self.background.base.mix(self.accent.base, 0.05), no_role: self.background.component.disabled, tool_tip_base: self.background.component.base, tool_tip_text: self.background.component.on, placeholder_text: self.background.component.on.with_alpha(0.5), }; let inactive = QPaletteGroup { window_text: active.window_text.with_alpha(0.8), text: active.text.with_alpha(0.8), highlighted_text: active.highlighted_text.with_alpha(0.8), tool_tip_text: active.tool_tip_text.with_alpha(0.8), ..active }; let disabled = QPaletteGroup { button: self.button.disabled, text: self.background.component.on_disabled, button_text: self.button.on_disabled, base: self.background.component.disabled, highlighted_text: active.highlighted_text.with_alpha(0.5), link: self.link_button.on_disabled, link_visited: self .link_button .on_disabled .mix(self.secondary.component.disabled, 0.2), alternate_base: self.background.base.mix(self.accent.disabled, 0.05), tool_tip_base: self.background.component.disabled, tool_tip_text: self.background.component.on_disabled, placeholder_text: self.background.component.on_disabled.with_alpha(0.5), ..inactive }; format!( r#"# GENERATED BY COSMIC [ColorScheme] active_colors={} disabled_colors={} inactive_colors={} "#, active.as_list(), disabled.as_list(), inactive.as_list(), ) } /// Writes the QPalette ini files to: /// - `~/.config/qt6ct/colors/` /// - `~/.config/qt5ct/colors/` #[cold] pub fn write_qt56ct(&self) -> Result<(), OutputError> { let qpalette = self.as_qpalette(); let qt5ct_res = self.write_ct("qt5ct", &qpalette); let qt6ct_res = self.write_ct("qt6ct", &qpalette); qt5ct_res?; qt6ct_res?; Ok(()) } #[must_use] #[cold] fn write_ct(&self, ct: &str, qpalette: &str) -> Result<(), OutputError> { let file_path = Self::get_qpalette_path(ct, self.is_dark)?; let tmp_file_path = file_path.with_extension("conf.new"); let mut tmp_file = File::create(&tmp_file_path).map_err(OutputError::Io)?; let res = tmp_file .write_all(qpalette.as_bytes()) .and_then(|_| tmp_file.flush()) .and_then(|_| std::fs::rename(&tmp_file_path, file_path)); if let Err(e) = res { _ = std::fs::remove_file(&tmp_file_path); return Err(OutputError::Io(e)); } Ok(()) } /// Edits qt{5,6}ct.conf to use COSMIC styles if needed. #[cold] pub fn apply_qt56ct(is_dark: bool) -> Result<(), OutputError> { let qt5ct_res = Self::apply_ct("qt5ct", is_dark); let qt6ct_res = Self::apply_ct("qt6ct", is_dark); qt5ct_res?; qt6ct_res?; Ok(()) } #[must_use] #[cold] fn apply_ct(ct: &str, is_dark: bool) -> Result<(), OutputError> { let path = Self::get_conf_path(ct)?; let file_content = fs::read_to_string(&path).map_err(OutputError::Io)?; let mut ini = Ini::new_cs(); ini.read(file_content).map_err(OutputError::Ini)?; let old_version = ini .getuint("Appearance", "cosmic_qt_version") .map_err(OutputError::Ini)? .unwrap_or_default(); let color_scheme_path = Self::get_qpalette_path(ct, is_dark)?; let icon_theme = if is_dark { "breeze-dark" } else { "breeze" }; ini.set( "Appearance", "cosmic_qt_version", Some(Theme::COSMIC_QT_VERSION.to_string()), ); if old_version < Theme::COSMIC_QT_VERSION { // Config is outdated, update it unconditionally! ini.setstr( "Appearance", "color_scheme_path", color_scheme_path.to_str(), ); // Enable the above color scheme, instead of using the default color scheme of e.g. Breeze ini.setstr("Appearance", "custom_palette", Some("true")); // COSMIC icons are stuck in light mode, so use breeze icons instead ini.setstr("Appearance", "icon_theme", Some(icon_theme)); // Use COSMIC dialogs instead of KDE's ini.setstr("Appearance", "standard_dialogs", Some("xdgdesktopportal")); // TODO: Add fonts section to match COSMIC } else { // Config is not outdated, check before updating light/dark mode only! let old_color_scheme_path = ini .get("Appearance", "color_scheme_path") .unwrap_or_else(|| "CosmicPlease".to_owned()); if old_color_scheme_path.contains("Cosmic") { ini.setstr( "Appearance", "color_scheme_path", color_scheme_path.to_str(), ); } let old_icon_theme = ini .get("Appearance", "icon_theme") .unwrap_or_else(|| "breeze".to_owned()); if old_icon_theme.contains("breeze") { ini.setstr("Appearance", "icon_theme", Some(icon_theme)); } } ini.pretty_write(path, &qt_settings_ini_style()) .map_err(OutputError::Io)?; Ok(()) } /// Reset the applied qt56ct config by removing COSMIC-specific entries from the config file. #[cold] pub fn reset_qt56ct() -> Result<(), OutputError> { let qt5ct_res = Self::reset_ct("qt5ct"); let qt6ct_res = Self::reset_ct("qt6ct"); qt5ct_res?; qt6ct_res?; Ok(()) } #[must_use] #[cold] fn reset_ct(ct: &str) -> Result<(), OutputError> { let path = Self::get_conf_path(ct)?; let file_content = fs::read_to_string(&path).map_err(OutputError::Io)?; let mut ini = Ini::new_cs(); ini.read(file_content).map_err(OutputError::Ini)?; let old_version = ini .getuint("Appearance", "cosmic_qt_version") .map_err(OutputError::Ini)? .unwrap_or_default(); if old_version == 0 { return Ok(()); } ini.remove_key("Appearance", "cosmic_qt_version"); ini.remove_key("Appearance", "color_scheme_path"); ini.remove_key("Appearance", "icon_theme"); ini.pretty_write(path, &qt_settings_ini_style()) .map_err(OutputError::Io)?; Ok(()) } /// Returns the file paths of the form `~/.config/ct/ct.conf`: /// e.g. `~/.config/qt6ct/qt6ct.conf`. /// /// The file and its parent directory are created if they don't exist. #[cold] fn get_conf_path(ct: &str) -> Result { assert!(ct == "qt5ct" || ct == "qt6ct"); let Some(mut config_dir) = dirs::config_dir() else { return Err(OutputError::MissingConfigDir); }; config_dir.push(&ct); if !config_dir.exists() { fs::create_dir_all(&config_dir).map_err(OutputError::Io)?; } let file_path = config_dir.join(ct.to_owned() + ".conf"); if !file_path.exists() { File::create_new(&file_path).map_err(OutputError::Io)?; } Ok(file_path) } /// Gets a path like `~/.config/qt6ct/colors/CosmicDark.conf` /// /// Its parent directory is created if it doesn't exist. #[cold] fn get_qpalette_path(ct: &str, is_dark: bool) -> Result { assert!(ct == "qt5ct" || ct == "qt6ct"); let Some(mut config_dir) = dirs::config_dir() else { return Err(OutputError::MissingConfigDir); }; config_dir.push(&ct); config_dir.push("colors"); if !config_dir.exists() { fs::create_dir_all(&config_dir).map_err(OutputError::Io)?; } let file_name = if is_dark { "CosmicDark.conf" } else { "CosmicLight.conf" }; Ok(config_dir.join(file_name)) } } /// Defines the different symbolic color roles used in current GUIs. /// /// qt5ct and qt6ct consume this as a list of colors, ordered by ColorRole: /// - https://doc.qt.io/qt-6/qpalette.html#ColorRole-enum /// - https://doc.qt.io/archives/qt-5.15/qpalette.html#ColorRole-enum struct QPaletteGroup { /// A general foreground color. window_text: Srgba, /// The general button background color. button: Srgba, /// Lighter than [button] color, used mostly for 3D bevel and shadow effects. light: Srgba, /// Between [button] and [light], used mostly for 3D bevel and shadow effects. midlight: Srgba, /// Darker than [button], used mostly for 3D bevel and shadow effects. dark: Srgba, /// Between [button] and [dark], used mostly for 3D bevel and shadow effects. mid: Srgba, /// The foreground color used with [base]. text: Srgba, /// A text color that is very different from [window_text], and contrasts well with e.g. [dark]. /// Typically used for text that needs to be drawn where [text] or [window_text] would give poor contrast, such as on pressed push buttons. bright_text: Srgba, /// A foreground color used with the [button] color. button_text: Srgba, /// Used mostly as the background color for text entry widgets, but can also be used for other painting - /// such as the background of combobox drop down lists and toolbar handles. base: Srgba, /// A general background color. window: Srgba, /// A very dark color, used mostly for 3D bevel and shadow effects. /// Opaque black by default. shadow: Srgba, /// A color to indicate a selected item or the current item. highlight: Srgba, /// A text color that contrasts with [highlight]. highlighted_text: Srgba, /// A text color used for unvisited hyperlinks. link: Srgba, /// A text color used for already visited hyperlinks. link_visited: Srgba, /// Used as the alternate background color in views with alternating row colors. alternate_base: Srgba, /// No role; this special role is often used to indicate that a role has not been assigned. no_role: Srgba, /// Used as the background color for QToolTip and QWhatsThis. /// Tool tips use the inactive color group of QPalette, because tool tips are not active windows. tool_tip_base: Srgba, /// Used as the foreground color for QToolTip and QWhatsThis. /// Tool tips use the inactive color group of QPalette, because tool tips are not active windows. tool_tip_text: Srgba, /// Used as the placeholder color for various text input widgets. placeholder_text: Srgba, // /// [accent] only exists since Qt 6.6. Including it here breaks qt5ct. // /// When omitted, it defaults to [highlight]. // accent: Srgba, } impl QPaletteGroup { /// Returns a comma-separated list of the colors as hex codes. /// E.g. `#ff000000, #ffdcdcdc, ...` /// /// Any transparent colors are flattened with [base] to avoid issues with /// the Fusion style. fn as_list(&self) -> String { let colors = vec![ to_argb_hex(self.window_text.over(self.base)), to_argb_hex(self.button.over(self.base)), to_argb_hex(self.light.over(self.base)), to_argb_hex(self.midlight.over(self.base)), to_argb_hex(self.dark.over(self.base)), to_argb_hex(self.mid.over(self.base)), to_argb_hex(self.text.over(self.base)), to_argb_hex(self.bright_text.over(self.base)), to_argb_hex(self.button_text.over(self.base)), to_argb_hex(self.base.over(self.base)), to_argb_hex(self.window.over(self.base)), to_argb_hex(self.shadow.over(self.base)), to_argb_hex(self.highlight.over(self.base)), to_argb_hex(self.highlighted_text.over(self.base)), to_argb_hex(self.link.over(self.base)), to_argb_hex(self.link_visited.over(self.base)), to_argb_hex(self.alternate_base.over(self.base)), to_argb_hex(self.no_role.over(self.base)), to_argb_hex(self.tool_tip_base.over(self.base)), to_argb_hex(self.tool_tip_text.over(self.base)), to_argb_hex(self.placeholder_text.over(self.base)), ]; colors.join(", ") } } /// Converts a color to a hex string in the format `#AARRGGBB`. /// Do not use [to_hex] since that uses the format `RRGGBBAA`. fn to_argb_hex(c: Srgba) -> String { let c_u8: Rgba = c.into_format(); format!( "#{:02x}{:02x}{:02x}{:02x}", c_u8.alpha, c_u8.red, c_u8.green, c_u8.blue ) } #[cfg(test)] mod tests { use super::*; #[test] fn test_color_to_argb_hex() { let color = Srgba::new(0x33, 0x55, 0x77, 0xff); let argb = to_argb_hex(color.into()); assert_eq!(argb, "#ff335577"); } #[test] fn test_light_default_qpalette() { let light_default_qpalette = Theme::light_default().as_qpalette(); insta::assert_snapshot!(light_default_qpalette); } #[test] fn test_dark_default_qpalette() { let dark_default_qpalette = Theme::dark_default().as_qpalette(); insta::assert_snapshot!(dark_default_qpalette); } }