From f06d15ae35204cb3bcef5a3188b5ec59a1cc9bfd Mon Sep 17 00:00:00 2001 From: Adil Hanney Date: Tue, 31 Mar 2026 16:02:52 +0100 Subject: [PATCH] feat(cosmic-theme): produce QPalette ini for more compatibility --- cosmic-theme/src/output/mod.rs | 4 + cosmic-theme/src/output/qt56ct_output.rs | 281 ++++++++++++++++++++++- cosmic-theme/src/output/qt_output.rs | 38 +-- 3 files changed, 303 insertions(+), 20 deletions(-) diff --git a/cosmic-theme/src/output/mod.rs b/cosmic-theme/src/output/mod.rs index b2474dc1..19f7bc5b 100644 --- a/cosmic-theme/src/output/mod.rs +++ b/cosmic-theme/src/output/mod.rs @@ -46,8 +46,10 @@ impl Theme { pub fn write_exports(&self) -> Result<(), OutputError> { let gtk_res = self.write_gtk4(); let qt_res = self.write_qt(); + let qt56ct_res = self.write_qt56ct(); gtk_res?; qt_res?; + qt56ct_res?; Ok(()) } @@ -56,8 +58,10 @@ impl Theme { pub fn reset_exports() -> Result<(), OutputError> { let gtk_res = Theme::reset_gtk(); let qt_res = Theme::reset_qt(); + let qt56ct_res = Theme::reset_qt56ct(); gtk_res?; qt_res?; + qt56ct_res?; Ok(()) } } diff --git a/cosmic-theme/src/output/qt56ct_output.rs b/cosmic-theme/src/output/qt56ct_output.rs index 552e7fec..eccfc846 100644 --- a/cosmic-theme/src/output/qt56ct_output.rs +++ b/cosmic-theme/src/output/qt56ct_output.rs @@ -1,8 +1,11 @@ use crate::Theme; use configparser::ini::Ini; +use palette::{Mix, Srgba, WithAlpha, blend::Compose, rgb::Rgba}; use std::{ fs::{self, File}, + io::Write, path::PathBuf, + vec, }; use super::{OutputError, qt_settings_ini_style}; @@ -15,7 +18,117 @@ impl Theme { /// 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 = 1; + 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] @@ -39,7 +152,7 @@ impl Theme { .map_err(OutputError::Ini)? .unwrap_or_default(); - let color_scheme_path = Self::get_qt_colors_path(is_dark)?; + let color_scheme_path = Self::get_qpalette_path(ct, is_dark)?; let icon_theme = if is_dark { "breeze-dark" } else { "breeze" }; ini.set( @@ -91,11 +204,48 @@ impl Theme { 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); }; @@ -111,4 +261,131 @@ impl Theme { 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 + ) } diff --git a/cosmic-theme/src/output/qt_output.rs b/cosmic-theme/src/output/qt_output.rs index 9bca3d18..86f7ac13 100644 --- a/cosmic-theme/src/output/qt_output.rs +++ b/cosmic-theme/src/output/qt_output.rs @@ -14,10 +14,11 @@ impl Theme { /// Produces a color scheme ini file for Qt. /// /// Some high-level documentation for this file can be found at: - /// https://web.archive.org/web/20250402234329/https://docs.kde.org/stable5/en/plasma-workspace/kcontrol/colors/ + /// - https://api.kde.org/kcolorscheme.html + /// - https://web.archive.org/web/20250402234329/https://docs.kde.org/stable5/en/plasma-workspace/kcontrol/colors/ #[must_use] #[cold] - pub fn as_qt(&self) -> String { + pub fn as_kcolorscheme(&self) -> String { // Usually, disabled elements will have strongly reduced contrast and are often notably darker or lighter let disabled_color_effects = IniColorEffects { color: self.button.disabled, @@ -41,7 +42,7 @@ impl Theme { let bg = self.background.base; // the background container - let view_colors = IniColors { + let window_colors = IniColors { background_alternate: bg.mix(self.accent.base, 0.05), background_normal: bg, decoration_focus: self.accent_text_color(), @@ -56,16 +57,17 @@ impl Theme { foreground_visited: self.accent_text_color(), }; // components inside the background container - let window_colors = IniColors { + let view_colors = IniColors { background_alternate: self.background.component.base.mix(self.accent.base, 0.05), background_normal: self.background.component.base, - ..view_colors + ..window_colors }; // selected text and items let selection_colors = { - let selected = self.background.component.selected; - let selected_text = self.background.component.selected_text; + // selection colors are swapped to fix menu bar contrast + let selected = self.background.component.selected_text; + let selected_text = self.background.component.selected; IniColors { background_alternate: selected.mix(bg, 0.5), background_normal: selected, @@ -116,10 +118,10 @@ impl Theme { }; // headers in cosmic don't have a background - let header_colors = &view_colors; - let header_colors_inactive = &view_colors; + let header_colors = &window_colors; + let header_colors_inactive = &window_colors; // tool tips, "What's This" tips, and similar elements - let tooltip_colors = &window_colors; + let tooltip_colors = &view_colors; let general_color_scheme = if self.is_dark { "CosmicDark" @@ -198,7 +200,7 @@ widgetStyle=qt6ct-style format_ini_colors(&tooltip_colors, bg), format_ini_colors(&view_colors, bg), format_ini_colors(&window_colors, bg), - format_ini_wm_colors(&view_colors, self.is_dark), + format_ini_wm_colors(&window_colors, self.is_dark), ) } @@ -212,14 +214,14 @@ widgetStyle=qt6ct-style /// Returns an `OutputError` if there is an error writing the colors file. #[cold] pub fn write_qt(&self) -> Result<(), OutputError> { - let colors = self.as_qt(); - let file_path = Self::get_qt_colors_path(self.is_dark)?; + let kcolorscheme = self.as_kcolorscheme(); + let file_path = Self::get_kcolorscheme_path(self.is_dark)?; let tmp_file_path = file_path.with_extension("colors.new"); // Write to tmp_file_path first, then move it to file_path let mut tmp_file = File::create(&tmp_file_path).map_err(OutputError::Io)?; let res = tmp_file - .write_all(colors.as_bytes()) + .write_all(kcolorscheme.as_bytes()) .and_then(|_| tmp_file.flush()) .and_then(|_| std::fs::rename(&tmp_file_path, file_path)); if let Err(e) = res { @@ -245,7 +247,7 @@ widgetStyle=qt6ct-style let kdeglobals_file = config_dir.join("kdeglobals"); let mut kdeglobals_ini = Self::read_ini(&kdeglobals_file)?; - let src_file = Self::get_qt_colors_path(is_dark)?; + let src_file = Self::get_kcolorscheme_path(is_dark)?; let src_ini = Self::read_ini(&src_file)?; Self::backup_non_cosmic_kdeglobals(&kdeglobals_ini, &kdeglobals_file) @@ -288,7 +290,7 @@ widgetStyle=qt6ct-style } let is_dark = false; // doesn't matter since we're only reading keys - let src_file = Self::get_qt_colors_path(is_dark)?; + let src_file = Self::get_kcolorscheme_path(is_dark)?; let src_ini = Self::read_ini(&src_file)?; for (section, key_value) in src_ini.get_map_ref() { @@ -303,8 +305,8 @@ widgetStyle=qt6ct-style Ok(()) } - /// Gets a path like `~/.config/color-schemes/CosmicDark.colors` - pub fn get_qt_colors_path(is_dark: bool) -> Result { + /// Gets a path like `~/.local/share/color-schemes/CosmicDark.colors` + fn get_kcolorscheme_path(is_dark: bool) -> Result { let Some(mut data_dir) = dirs::data_dir() else { return Err(OutputError::MissingDataDir); };