415 lines
15 KiB
Rust
415 lines
15 KiB
Rust
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<PathBuf, OutputError> {
|
|
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<PathBuf, OutputError> {
|
|
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<palette::encoding::Srgb, u8> = 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);
|
|
}
|
|
}
|