use std::fmt;
#[cfg(target_os = "linux")]
use crate::detect::{LinuxDesktop, detect_linux_desktop, parse_linux_desktop, system_is_dark};
use crate::model::Theme;
use crate::{OverlaySource, ReaderOutput, ReaderResult, SystemTheme};
pub(crate) fn run_pipeline(
reader: ReaderResult,
preset_name: &str,
mode: crate::ColorMode,
) -> crate::Result<SystemTheme> {
let ReaderResult {
output: reader_output,
name: reader_name,
icon_set: reader_icon_set,
layout: reader_layout,
font_dpi,
accessibility,
} = reader;
let live_preset = Theme::preset(preset_name)?;
let full_preset_name = preset_name.strip_suffix("-live").unwrap_or(preset_name);
debug_assert!(
full_preset_name != preset_name || !preset_name.ends_with("-live"),
"live preset '{preset_name}' should have -live suffix stripped"
);
let full_preset = Theme::preset(full_preset_name)?;
let reader_as_theme = reader_output.to_theme(&reader_name, reader_icon_set, &reader_layout);
let mut merged = full_preset.clone();
merged.merge(&live_preset);
merged.merge(&reader_as_theme);
let name = if reader_name.is_empty() {
merged.name.clone()
} else {
reader_name.clone()
};
let icon_set = merged
.icon_set
.unwrap_or_else(crate::model::icons::system_icon_set);
let mut ctx = crate::resolve::ResolutionContext::from_system();
if let Some(dpi) = font_dpi {
ctx.font_dpi = dpi;
}
let icon_theme: std::borrow::Cow<'static, str> = {
let active = if mode == crate::ColorMode::Dark {
&merged.dark
} else {
&merged.light
};
active
.as_ref()
.and_then(|v| v.defaults.icon_theme.clone()) .or_else(|| merged.icon_theme.clone()) .or_else(|| ctx.icon_theme.clone()) .unwrap_or_else(|| std::borrow::Cow::Owned(crate::model::icons::system_icon_theme()))
};
let layout = merged.layout.clone();
let (light_variant, dark_variant) = match &reader_output {
ReaderOutput::Single { is_dark, .. } => {
if *is_dark {
(
full_preset.light.unwrap_or_default(),
merged.dark.unwrap_or_default(),
)
} else {
(
merged.light.unwrap_or_default(),
full_preset.dark.unwrap_or_default(),
)
}
}
ReaderOutput::Dual { .. } => (
merged.light.unwrap_or_default(),
merged.dark.unwrap_or_default(),
),
};
let light = light_variant.into_resolved(&ctx)?;
let dark = dark_variant.into_resolved(&ctx)?;
let overlay_source = OverlaySource {
reader_output,
name: reader_name,
icon_set: reader_icon_set,
layout: reader_layout,
preset_name: preset_name.to_string(),
context: ctx,
};
Ok(SystemTheme {
name,
mode,
light,
dark,
overlay_source,
preset: full_preset_name.to_string(),
live_preset: preset_name.to_string(),
icon_set,
icon_theme,
layout,
accessibility,
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum DiagnosticEntry {
Platform(&'static str),
#[cfg(target_os = "linux")]
DesktopEnv(crate::detect::LinuxDesktop),
EnvVar {
name: &'static str,
value: String,
},
EnvVarMissing(&'static str),
ToolAvailable {
name: &'static str,
version: String,
},
ToolError(&'static str),
ToolMissing {
name: &'static str,
impact: &'static str,
},
ConfigFound {
name: &'static str,
path: std::path::PathBuf,
},
ConfigMissing {
name: &'static str,
path: std::path::PathBuf,
},
FeatureEnabled(&'static str),
FeatureDisabled {
feature: &'static str,
impact: &'static str,
},
}
impl DiagnosticEntry {
#[must_use]
pub fn name(&self) -> &str {
match self {
Self::Platform(_) => "Platform",
#[cfg(target_os = "linux")]
Self::DesktopEnv(_) => "Detected DE",
Self::EnvVar { name, .. } | Self::EnvVarMissing(name) => name,
Self::ToolAvailable { name, .. }
| Self::ToolError(name)
| Self::ToolMissing { name, .. } => name,
Self::ConfigFound { name, .. } | Self::ConfigMissing { name, .. } => name,
Self::FeatureEnabled(feature) | Self::FeatureDisabled { feature, .. } => feature,
}
}
#[must_use]
pub fn status(&self) -> &str {
match self {
Self::Platform(p) => p,
#[cfg(target_os = "linux")]
Self::DesktopEnv(_) => "detected",
Self::EnvVar { value, .. } => value.as_str(),
Self::EnvVarMissing(_) => "not set",
Self::ToolAvailable { .. } => "available",
Self::ToolError(_) => "found but returned error",
Self::ToolMissing { .. } => "not found",
Self::ConfigFound { .. } => "found",
Self::ConfigMissing { .. } => "not found",
Self::FeatureEnabled(_) => "enabled",
Self::FeatureDisabled { .. } => "disabled",
}
}
#[must_use]
pub fn detail(&self) -> Option<String> {
match self {
#[cfg(target_os = "linux")]
Self::DesktopEnv(de) => Some(format!("{de:?}")),
Self::ToolAvailable { version, .. } => Some(version.clone()),
Self::ToolMissing { impact, .. } => Some((*impact).to_string()),
Self::FeatureDisabled { impact, .. } => Some((*impact).to_string()),
Self::ConfigFound { path, .. } | Self::ConfigMissing { path, .. } => {
Some(path.display().to_string())
}
_ => None,
}
}
}
impl fmt::Display for DiagnosticEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Platform(p) => write!(f, "Platform: {p}"),
#[cfg(target_os = "linux")]
Self::DesktopEnv(de) => write!(f, "Detected DE: {de:?}"),
Self::EnvVar { name, value } => write!(f, "{name}: {value}"),
Self::EnvVarMissing(name) => write!(f, "{name}: not set"),
Self::ToolAvailable { name, version } => {
write!(f, "{name}: available ({version})")
}
Self::ToolError(name) => write!(f, "{name}: found but returned error"),
Self::ToolMissing { name, impact } => write!(f, "{name}: not found ({impact})"),
Self::ConfigFound { name, path } => {
write!(f, "{name}: found at {}", path.display())
}
Self::ConfigMissing { name, path } => {
write!(f, "{name}: not found at {}", path.display())
}
Self::FeatureEnabled(feature) => write!(f, "{feature} support: enabled"),
Self::FeatureDisabled { feature, impact } => {
write!(f, "{feature} support: disabled ({impact})")
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlatformPreset {
pub name: &'static str,
pub is_live: bool,
}
impl PlatformPreset {
#[must_use]
pub fn live_name(&self) -> String {
if self.is_live {
format!("{}-live", self.name)
} else {
self.name.to_string()
}
}
}
impl fmt::Display for PlatformPreset {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name)
}
}
#[cfg(target_os = "linux")]
pub(crate) fn linux_preset_for_de(de: LinuxDesktop) -> PlatformPreset {
match de {
LinuxDesktop::Kde => PlatformPreset {
name: "kde-breeze",
is_live: true,
},
_ => PlatformPreset {
name: "adwaita",
is_live: true,
},
}
}
#[allow(unreachable_code)]
#[must_use]
pub fn platform_preset_name() -> PlatformPreset {
#[cfg(target_os = "macos")]
{
return PlatformPreset {
name: "macos-sonoma",
is_live: true,
};
}
#[cfg(target_os = "windows")]
{
return PlatformPreset {
name: "windows-11",
is_live: true,
};
}
#[cfg(target_os = "linux")]
{
linux_preset_for_de(detect_linux_desktop())
}
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
{
PlatformPreset {
name: "adwaita",
is_live: true,
}
}
}
#[must_use]
pub fn diagnose_platform_support() -> Vec<DiagnosticEntry> {
let mut diagnostics = Vec::new();
#[cfg(target_os = "linux")]
{
diagnostics.push(DiagnosticEntry::Platform("Linux"));
match std::env::var("XDG_CURRENT_DESKTOP") {
Ok(val) if !val.is_empty() => {
let de = parse_linux_desktop(&val);
diagnostics.push(DiagnosticEntry::EnvVar {
name: "XDG_CURRENT_DESKTOP",
value: val,
});
diagnostics.push(DiagnosticEntry::DesktopEnv(de));
}
_ => {
diagnostics.push(DiagnosticEntry::EnvVarMissing("XDG_CURRENT_DESKTOP"));
diagnostics.push(DiagnosticEntry::DesktopEnv(LinuxDesktop::Unknown));
}
}
match std::process::Command::new("gsettings")
.arg("--version")
.output()
{
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
diagnostics.push(DiagnosticEntry::ToolAvailable {
name: "gsettings",
version,
});
}
Ok(_) => {
diagnostics.push(DiagnosticEntry::ToolError("gsettings"));
}
Err(_) => {
diagnostics.push(DiagnosticEntry::ToolMissing {
name: "gsettings",
impact: "dark mode and icon theme detection may be limited",
});
}
}
#[cfg(feature = "kde")]
{
let path = crate::kde::kdeglobals_path();
if path.exists() {
diagnostics.push(DiagnosticEntry::ConfigFound {
name: "KDE kdeglobals",
path,
});
} else {
diagnostics.push(DiagnosticEntry::ConfigMissing {
name: "KDE kdeglobals",
path,
});
}
}
#[cfg(not(feature = "kde"))]
{
diagnostics.push(DiagnosticEntry::FeatureDisabled {
feature: "KDE",
impact: "kde feature not enabled",
});
}
#[cfg(feature = "portal")]
diagnostics.push(DiagnosticEntry::FeatureEnabled("Portal"));
#[cfg(not(feature = "portal"))]
diagnostics.push(DiagnosticEntry::FeatureDisabled {
feature: "Portal",
impact: "portal feature not enabled",
});
}
#[cfg(target_os = "macos")]
{
diagnostics.push(DiagnosticEntry::Platform("macOS"));
#[cfg(feature = "macos")]
diagnostics.push(DiagnosticEntry::FeatureEnabled("macOS theme detection"));
#[cfg(not(feature = "macos"))]
diagnostics.push(DiagnosticEntry::FeatureDisabled {
feature: "macOS theme detection",
impact: "macos feature not enabled, using subprocess fallback",
});
}
#[cfg(target_os = "windows")]
{
diagnostics.push(DiagnosticEntry::Platform("Windows"));
#[cfg(feature = "windows")]
diagnostics.push(DiagnosticEntry::FeatureEnabled("Windows theme detection"));
#[cfg(not(feature = "windows"))]
diagnostics.push(DiagnosticEntry::FeatureDisabled {
feature: "Windows theme detection",
impact: "windows feature not enabled",
});
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
diagnostics.push(DiagnosticEntry::Platform(
"unsupported (no native theme detection available)",
));
}
diagnostics
}
fn preset_as_reader(preset_name: &str, mode: crate::ColorMode) -> crate::Result<ReaderResult> {
let theme = Theme::preset(preset_name)?;
let is_dark = mode == crate::ColorMode::Dark;
let output = if is_dark {
ReaderOutput::Single {
mode: Box::new(theme.dark.unwrap_or_default()),
is_dark: true,
}
} else {
ReaderOutput::Single {
mode: Box::new(theme.light.unwrap_or_default()),
is_dark: false,
}
};
Ok(ReaderResult {
output,
name: theme.name,
icon_set: theme.icon_set,
layout: theme.layout,
font_dpi: None,
accessibility: crate::AccessibilityPreferences::default(),
})
}
#[allow(unreachable_code)]
#[allow(clippy::unnecessary_wraps)] #[allow(clippy::needless_return)] async fn select_reader() -> Option<(Box<dyn crate::reader::ThemeReader>, &'static str)> {
#[cfg(target_os = "macos")]
{
#[cfg(feature = "macos")]
{
return Some((Box::new(crate::macos::MacosReader), "macos-sonoma-live"));
}
#[cfg(not(feature = "macos"))]
return None;
}
#[cfg(target_os = "windows")]
{
#[cfg(feature = "windows")]
{
return Some((Box::new(crate::windows::WindowsReader), "windows-11-live"));
}
#[cfg(not(feature = "windows"))]
return None;
}
#[cfg(target_os = "linux")]
{
let de = detect_linux_desktop();
match de {
#[cfg(all(feature = "kde", feature = "portal"))]
LinuxDesktop::Kde => {
return Some((
Box::new(crate::gnome::GnomePortalKdeReader),
"kde-breeze-live",
));
}
#[cfg(all(feature = "kde", not(feature = "portal")))]
LinuxDesktop::Kde => {
return Some((Box::new(crate::kde::KdeReader), "kde-breeze-live"));
}
#[cfg(not(feature = "kde"))]
LinuxDesktop::Kde => return None,
#[cfg(feature = "portal")]
LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
return Some((Box::new(crate::gnome::GnomeReader), "adwaita-live"));
}
#[cfg(not(feature = "portal"))]
LinuxDesktop::Gnome | LinuxDesktop::Budgie => return None,
LinuxDesktop::Xfce
| LinuxDesktop::Cinnamon
| LinuxDesktop::Mate
| LinuxDesktop::LxQt
| LinuxDesktop::Hyprland
| LinuxDesktop::Sway
| LinuxDesktop::River
| LinuxDesktop::Niri
| LinuxDesktop::Wayfire
| LinuxDesktop::CosmicDe => return None,
LinuxDesktop::Unknown => {
#[cfg(feature = "portal")]
{
if let Some(detected) = crate::gnome::detect_portal_backend().await {
return match detected {
#[cfg(feature = "kde")]
LinuxDesktop::Kde => Some((
Box::new(crate::gnome::GnomePortalKdeReader),
"kde-breeze-live",
)),
#[cfg(not(feature = "kde"))]
LinuxDesktop::Kde => None,
LinuxDesktop::Gnome => {
Some((Box::new(crate::gnome::GnomeReader), "adwaita-live"))
}
_ => None,
};
}
}
#[cfg(feature = "kde")]
{
if crate::kde::kdeglobals_path().exists() {
return Some((Box::new(crate::kde::KdeReader), "kde-breeze-live"));
}
}
return None;
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
{
None
}
}
#[allow(unreachable_code)]
#[allow(clippy::needless_return)] pub(crate) async fn from_system_inner() -> crate::Result<SystemTheme> {
if let Some((reader, preset_live)) = select_reader().await {
let result = reader.read().await?;
let mode = match &result.output {
ReaderOutput::Single { is_dark, .. } => {
if *is_dark {
crate::ColorMode::Dark
} else {
crate::ColorMode::Light
}
}
ReaderOutput::Dual { .. } => crate::ColorMode::Light,
};
return run_pipeline(result, preset_live, mode);
}
#[cfg(target_os = "macos")]
{
return Err(crate::Error::FeatureDisabled {
name: "macos",
needed_for: "macOS theme detection",
});
}
#[cfg(target_os = "windows")]
{
return Err(crate::Error::FeatureDisabled {
name: "windows",
needed_for: "Windows theme detection",
});
}
#[cfg(target_os = "linux")]
{
let mode = if system_is_dark() {
crate::ColorMode::Dark
} else {
crate::ColorMode::Light
};
let preset_live = linux_preset_for_de(detect_linux_desktop()).live_name();
return run_pipeline(preset_as_reader("adwaita", mode)?, &preset_live, mode);
}
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
{
Err(crate::Error::PlatformUnsupported {
platform: "unsupported",
})
}
}
pub(crate) async fn accessibility_from_system_inner() -> crate::AccessibilityPreferences {
let mut prefs = match select_reader().await {
Some((reader, _preset)) => match reader.read().await {
Ok(result) => result.accessibility,
Err(_) => crate::AccessibilityPreferences::default(),
},
None => crate::AccessibilityPreferences::default(),
};
if !prefs.reduce_motion {
prefs.reduce_motion = crate::detect::detect_reduced_motion();
}
prefs
}
#[cfg(all(test, target_os = "linux"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod dispatch_tests {
use super::*;
#[test]
fn detect_kde_simple() {
assert_eq!(parse_linux_desktop("KDE"), LinuxDesktop::Kde);
}
#[test]
fn detect_kde_colon_separated_after() {
assert_eq!(parse_linux_desktop("ubuntu:KDE"), LinuxDesktop::Kde);
}
#[test]
fn detect_kde_colon_separated_before() {
assert_eq!(parse_linux_desktop("KDE:plasma"), LinuxDesktop::Kde);
}
#[test]
fn detect_gnome_simple() {
assert_eq!(parse_linux_desktop("GNOME"), LinuxDesktop::Gnome);
}
#[test]
fn detect_gnome_ubuntu() {
assert_eq!(parse_linux_desktop("ubuntu:GNOME"), LinuxDesktop::Gnome);
}
#[test]
fn detect_xfce() {
assert_eq!(parse_linux_desktop("XFCE"), LinuxDesktop::Xfce);
}
#[test]
fn detect_cinnamon() {
assert_eq!(parse_linux_desktop("X-Cinnamon"), LinuxDesktop::Cinnamon);
}
#[test]
fn detect_cinnamon_short() {
assert_eq!(parse_linux_desktop("Cinnamon"), LinuxDesktop::Cinnamon);
}
#[test]
fn detect_mate() {
assert_eq!(parse_linux_desktop("MATE"), LinuxDesktop::Mate);
}
#[test]
fn detect_lxqt() {
assert_eq!(parse_linux_desktop("LXQt"), LinuxDesktop::LxQt);
}
#[test]
fn detect_budgie() {
assert_eq!(parse_linux_desktop("Budgie:GNOME"), LinuxDesktop::Budgie);
}
#[test]
fn detect_empty_string() {
assert_eq!(parse_linux_desktop(""), LinuxDesktop::Unknown);
}
#[test]
fn from_linux_non_kde_returns_adwaita() -> crate::Result<()> {
let preset = linux_preset_for_de(LinuxDesktop::Gnome);
let result = preset_as_reader("adwaita", crate::ColorMode::Light)?;
let theme = run_pipeline(result, &preset.live_name(), crate::ColorMode::Light)?;
assert_eq!(theme.name, "Adwaita");
Ok(())
}
#[test]
#[cfg(feature = "kde")]
fn from_linux_unknown_de_with_kdeglobals_fallback() -> crate::Result<()> {
const MINIMAL_KDE_FIXTURE: &str = "\
[General]
ColorScheme=TestTheme
[Colors:Window]
BackgroundNormal=239,240,241
[Colors:View]
BackgroundNormal=252,252,252
ForegroundNormal=35,38,41
DecorationFocus=61,174,233
BackgroundAlternate=239,240,241
ForegroundLink=41,128,185";
let (reader_theme, dpi, acc) =
crate::kde::from_kde_content_pure(MINIMAL_KDE_FIXTURE, None)?;
let is_dark = reader_theme.dark.is_some() && reader_theme.light.is_none();
let output = if is_dark {
ReaderOutput::Single {
mode: Box::new(reader_theme.dark.unwrap_or_default()),
is_dark: true,
}
} else {
ReaderOutput::Single {
mode: Box::new(reader_theme.light.unwrap_or_default()),
is_dark: false,
}
};
let result = ReaderResult {
output,
name: reader_theme.name,
icon_set: reader_theme.icon_set,
layout: reader_theme.layout,
font_dpi: dpi,
accessibility: acc,
};
let preset = linux_preset_for_de(LinuxDesktop::Kde);
let theme = run_pipeline(result, &preset.live_name(), crate::ColorMode::Light)?;
assert_eq!(
theme.name, "TestTheme",
"should use KDE theme name from reader output"
);
Ok(())
}
#[test]
fn from_linux_unknown_de_without_kdeglobals_returns_adwaita() -> crate::Result<()> {
let preset = linux_preset_for_de(LinuxDesktop::Unknown);
let result = preset_as_reader("adwaita", crate::ColorMode::Light)?;
let theme = run_pipeline(result, &preset.live_name(), crate::ColorMode::Light)?;
assert_eq!(
theme.name, "Adwaita",
"should fall back to Adwaita without kdeglobals"
);
Ok(())
}
#[test]
fn detect_hyprland() {
assert_eq!(parse_linux_desktop("Hyprland"), LinuxDesktop::Hyprland);
}
#[test]
fn detect_sway() {
assert_eq!(parse_linux_desktop("sway"), LinuxDesktop::Sway);
}
#[test]
fn detect_cosmic() {
assert_eq!(parse_linux_desktop("COSMIC"), LinuxDesktop::CosmicDe);
}
#[test]
fn detect_river() {
assert_eq!(parse_linux_desktop("river"), LinuxDesktop::River);
}
#[test]
fn detect_niri() {
assert_eq!(parse_linux_desktop("niri"), LinuxDesktop::Niri);
}
#[test]
fn parses_xdg_wayfire() {
assert_eq!(parse_linux_desktop("Wayfire"), LinuxDesktop::Wayfire);
assert_eq!(parse_linux_desktop("Wayfire:GNOME"), LinuxDesktop::Wayfire);
}
#[test]
fn parses_xdg_empty_is_unknown() {
assert_eq!(parse_linux_desktop(""), LinuxDesktop::Unknown);
}
#[test]
fn detect_cosmic_full_desktop() {
assert_eq!(
parse_linux_desktop("COSMIC:Freedesktop"),
LinuxDesktop::CosmicDe
);
}
#[test]
fn from_system_returns_result() {
let result = preset_as_reader("adwaita", crate::ColorMode::Light).unwrap();
let theme = run_pipeline(result, "adwaita-live", crate::ColorMode::Light)
.expect("run_pipeline should succeed with adwaita preset");
assert_eq!(theme.name, "Adwaita");
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::field_reassign_with_default
)]
mod pipeline_tests {
use crate::color::Rgba;
use crate::model::{LayoutTheme, Theme, ThemeMode};
use crate::{ReaderOutput, ReaderResult};
use super::{preset_as_reader, run_pipeline};
fn reader_from_preset(preset_name: &str) -> ReaderResult {
let preset = Theme::preset(preset_name).unwrap();
ReaderResult {
output: ReaderOutput::Dual {
light: Box::new(preset.light.clone().unwrap_or_default()),
dark: Box::new(preset.dark.clone().unwrap_or_default()),
},
name: preset.name,
icon_set: preset.icon_set,
layout: preset.layout,
font_dpi: None,
accessibility: crate::AccessibilityPreferences::default(),
}
}
#[test]
fn test_run_pipeline_produces_both_variants() {
let reader = reader_from_preset("catppuccin-mocha");
let result = run_pipeline(reader, "catppuccin-mocha", crate::ColorMode::Light);
assert!(result.is_ok(), "run_pipeline should succeed");
let st = result.unwrap();
assert!(!st.name.is_empty(), "name should be populated");
}
#[test]
fn test_run_pipeline_reader_values_win() {
let custom_accent = Rgba::rgb(42, 100, 200);
let mut variant = ThemeMode::default();
variant.defaults.accent_color = Some(custom_accent);
let reader = ReaderResult {
output: ReaderOutput::Single {
mode: Box::new(variant),
is_dark: false,
},
name: "CustomTheme".into(),
icon_set: None,
layout: LayoutTheme::default(),
font_dpi: None,
accessibility: crate::AccessibilityPreferences::default(),
};
let result = run_pipeline(reader, "catppuccin-mocha", crate::ColorMode::Light);
assert!(result.is_ok(), "run_pipeline should succeed");
let st = result.unwrap();
assert_eq!(
st.light.defaults.accent_color, custom_accent,
"reader accent should win over preset accent"
);
assert_eq!(st.name, "CustomTheme", "reader name should win");
}
#[test]
fn test_run_pipeline_single_variant() {
let full = Theme::preset("kde-breeze").unwrap();
let mut dark_v = full.dark.clone().unwrap();
dark_v.defaults.accent_color = Some(Rgba::rgb(200, 50, 50));
let reader = ReaderResult {
output: ReaderOutput::Single {
mode: Box::new(dark_v),
is_dark: true,
},
name: std::borrow::Cow::Borrowed(""),
icon_set: None,
layout: LayoutTheme::default(),
font_dpi: None,
accessibility: crate::AccessibilityPreferences::default(),
};
let result = run_pipeline(reader, "kde-breeze-live", crate::ColorMode::Dark);
assert!(
result.is_ok(),
"run_pipeline should succeed with single variant"
);
let st = result.unwrap();
assert_eq!(
st.dark.defaults.accent_color,
Rgba::rgb(200, 50, 50),
"dark variant should have reader accent"
);
assert_eq!(st.live_preset, "kde-breeze-live");
assert_eq!(st.preset, "kde-breeze");
}
#[test]
fn test_run_pipeline_inactive_variant_from_full_preset() {
let full = Theme::preset("kde-breeze").unwrap();
let reader = ReaderResult {
output: ReaderOutput::Single {
mode: Box::new(full.dark.clone().unwrap_or_default()),
is_dark: true,
},
name: std::borrow::Cow::Borrowed(""),
icon_set: None,
layout: LayoutTheme::default(),
font_dpi: None,
accessibility: crate::AccessibilityPreferences::default(),
};
let st = run_pipeline(reader, "kde-breeze-live", crate::ColorMode::Dark).unwrap();
let full_light = full.light.unwrap();
assert_eq!(
st.light.defaults.accent_color,
full_light.defaults.accent_color.unwrap(),
"inactive light variant should get accent from full preset"
);
assert_eq!(
st.light.defaults.background_color,
full_light.defaults.background_color.unwrap(),
"inactive light variant should get background from full preset"
);
}
#[test]
fn test_run_pipeline_with_preset_as_reader() {
let reader = reader_from_preset("adwaita");
let result = run_pipeline(reader, "adwaita", crate::ColorMode::Light);
assert!(
result.is_ok(),
"double-merge with same preset should succeed"
);
let st = result.unwrap();
assert_eq!(st.name, "Adwaita");
}
#[test]
fn test_single_variant_fills_inactive_from_preset() {
let full = Theme::preset("kde-breeze").unwrap();
let dark_v = full.dark.clone().unwrap();
let reader = ReaderResult {
output: ReaderOutput::Single {
mode: Box::new(dark_v),
is_dark: true,
},
name: "Breeze Dark".into(),
icon_set: full.icon_set,
layout: full.layout.clone(),
font_dpi: None,
accessibility: crate::AccessibilityPreferences::default(),
};
let st = run_pipeline(reader, "kde-breeze-live", crate::ColorMode::Dark).unwrap();
let full_light = Theme::preset("kde-breeze").unwrap().light.unwrap();
assert_eq!(
st.light.defaults.accent_color,
full_light.defaults.accent_color.unwrap(),
"Single: inactive light variant should come from full preset"
);
assert_eq!(st.mode, crate::ColorMode::Dark);
}
#[test]
fn test_dual_variant_uses_both_from_reader() {
let full = Theme::preset("macos-sonoma").unwrap();
let custom_light_accent = Rgba::rgb(42, 100, 200);
let custom_dark_accent = Rgba::rgb(200, 50, 50);
let mut light_v = full.light.clone().unwrap();
light_v.defaults.accent_color = Some(custom_light_accent);
let mut dark_v = full.dark.clone().unwrap();
dark_v.defaults.accent_color = Some(custom_dark_accent);
let reader = ReaderResult {
output: ReaderOutput::Dual {
light: Box::new(light_v),
dark: Box::new(dark_v),
},
name: "macOS Sonoma".into(),
icon_set: full.icon_set,
layout: full.layout.clone(),
font_dpi: Some(72.0),
accessibility: crate::AccessibilityPreferences::default(),
};
let st = run_pipeline(reader, "macos-sonoma-live", crate::ColorMode::Light).unwrap();
assert_eq!(
st.light.defaults.accent_color, custom_light_accent,
"Dual: light variant should have reader's light accent"
);
assert_eq!(
st.dark.defaults.accent_color, custom_dark_accent,
"Dual: dark variant should have reader's dark accent"
);
}
fn reader_with_empty_variant(is_dark: bool) -> ReaderResult {
ReaderResult {
output: ReaderOutput::Single {
mode: Box::new(ThemeMode::default()),
is_dark,
},
name: std::borrow::Cow::Borrowed(""),
icon_set: None,
layout: LayoutTheme::default(),
font_dpi: None,
accessibility: crate::AccessibilityPreferences::default(),
}
}
#[test]
fn icon_theme_tier2_theme_level_used_when_per_variant_none() {
let reader = reader_with_empty_variant(false);
let st = run_pipeline(reader, "adwaita", crate::ColorMode::Light).unwrap();
assert_eq!(
st.icon_theme.as_ref(),
"Adwaita",
"tier 2: Theme.icon_theme should be used when per-variant is None"
);
}
#[test]
fn icon_theme_tier1_per_variant_wins_over_theme_level() {
let mut variant = ThemeMode::default();
variant.defaults.icon_theme = Some(std::borrow::Cow::Borrowed("custom-override"));
let reader = ReaderResult {
output: ReaderOutput::Single {
mode: Box::new(variant),
is_dark: false,
},
name: std::borrow::Cow::Borrowed(""),
icon_set: None,
layout: LayoutTheme::default(),
font_dpi: None,
accessibility: crate::AccessibilityPreferences::default(),
};
let st = run_pipeline(reader, "adwaita", crate::ColorMode::Light).unwrap();
assert_eq!(
st.icon_theme.as_ref(),
"custom-override",
"tier 1: per-variant icon_theme override must win over Theme-level"
);
}
#[test]
fn icon_theme_kde_per_variant_values_still_win() {
let kde_full = Theme::preset("kde-breeze").unwrap();
let dark_v = kde_full.dark.clone().unwrap();
assert_eq!(
dark_v.defaults.icon_theme.as_deref(),
Some("breeze-dark"),
"precondition: kde-breeze dark variant must carry breeze-dark"
);
let reader = ReaderResult {
output: ReaderOutput::Single {
mode: Box::new(dark_v),
is_dark: true,
},
name: std::borrow::Cow::Borrowed(""),
icon_set: None,
layout: LayoutTheme::default(),
font_dpi: None,
accessibility: crate::AccessibilityPreferences::default(),
};
let st = run_pipeline(reader, "kde-breeze-live", crate::ColorMode::Dark).unwrap();
assert_eq!(
st.icon_theme.as_ref(),
"breeze-dark",
"KDE per-variant icon_theme must still win (Phase 80-fix invariant)"
);
}
#[test]
fn theme_icon_theme_round_trips_when_some() {
let theme = Theme {
name: std::borrow::Cow::Borrowed("RoundTrip"),
light: Some(ThemeMode::default()),
dark: None,
layout: LayoutTheme::default(),
icon_set: None,
icon_theme: Some(std::borrow::Cow::Borrowed("lucide")),
};
let toml_str = theme.to_toml().expect("serialize");
let reparsed = Theme::from_toml(&toml_str).expect("deserialize");
assert_eq!(
reparsed.icon_theme.as_deref(),
Some("lucide"),
"Theme.icon_theme must round-trip through TOML"
);
assert!(
toml_str.contains("icon_theme = \"lucide\""),
"serialized TOML should contain top-level icon_theme, got:\n{toml_str}"
);
}
#[test]
fn theme_icon_theme_skipped_when_none() {
let theme = Theme {
name: std::borrow::Cow::Borrowed("NoIcon"),
light: Some(ThemeMode::default()),
dark: None,
layout: LayoutTheme::default(),
icon_set: None,
icon_theme: None,
};
let toml_str = theme.to_toml().expect("serialize");
assert!(
!toml_str.contains("icon_theme"),
"serialized TOML should NOT contain icon_theme when None, got:\n{toml_str}"
);
}
#[test]
fn lint_toml_accepts_top_level_icon_theme() {
let toml_str = r##"
name = "Test"
icon_theme = "lucide"
[light.defaults]
accent_color = "#0066cc"
"##;
let warnings = Theme::lint_toml(toml_str).expect("lint");
assert!(
!warnings.iter().any(|w| w.contains("icon_theme")),
"lint_toml must not flag top-level icon_theme as unknown; warnings: {warnings:?}"
);
}
#[test]
fn system_theme_layout_is_the_merged_preset_layout() -> crate::Result<()> {
let reader = preset_as_reader("adwaita", crate::ColorMode::Light)?;
let sys = run_pipeline(reader, "adwaita-live", crate::ColorMode::Light)?;
let mut expected = Theme::preset("adwaita")?.layout;
expected.merge(&Theme::preset("adwaita-live")?.layout);
expected.merge(&Theme::preset("adwaita")?.layout);
assert_eq!(sys.layout, expected);
assert!(
sys.layout.widget_gap.is_some(),
"adwaita defines all four layout keys (spec §1.3)"
);
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod theme_reader_trait_tests {
#[test]
fn theme_reader_trait_exists_and_is_object_safe() {
#[allow(dead_code)]
fn assert_object_safe<T: ?Sized>(_: &T) {}
#[cfg(all(target_os = "linux", feature = "kde"))]
{
let r: Box<dyn crate::reader::ThemeReader> = Box::new(crate::kde::KdeReader);
assert_object_safe(&r);
}
#[cfg(all(target_os = "macos", feature = "macos"))]
{
let r: Box<dyn crate::reader::ThemeReader> = Box::new(crate::macos::MacosReader);
assert_object_safe(&r);
}
#[cfg(all(target_os = "windows", feature = "windows"))]
{
let r: Box<dyn crate::reader::ThemeReader> = Box::new(crate::windows::WindowsReader);
assert_object_safe(&r);
}
let _: Option<&dyn crate::reader::ThemeReader> = None;
}
#[test]
fn select_reader_returns_platform_specific_impl() {
let picked: Option<(Box<dyn crate::reader::ThemeReader>, &'static str)> =
pollster::block_on(super::select_reader());
#[cfg(all(target_os = "macos", feature = "macos"))]
{
assert!(
picked.is_some(),
"select_reader must return Some on macOS+macos"
);
if let Some((_reader, preset)) = &picked {
assert_eq!(*preset, "macos-sonoma-live");
}
}
#[cfg(all(target_os = "windows", feature = "windows"))]
{
assert!(
picked.is_some(),
"select_reader must return Some on windows+windows"
);
if let Some((_reader, preset)) = &picked {
assert_eq!(*preset, "windows-11-live");
}
}
#[cfg(all(target_os = "linux", feature = "kde", feature = "portal"))]
{
if let Some((_reader, preset)) = &picked {
assert!(
*preset == "kde-breeze-live" || *preset == "adwaita-live",
"select_reader on linux must return a known -live preset, got: {preset}"
);
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
assert!(
picked.is_none(),
"select_reader() must return None on unsupported platforms"
);
}
drop(picked);
}
}