Files
hyprcosmic/cosmic-conf/src/import.rs
T
gitops 362f324755 import-theme: write rofi's entry point and per-machine overrides
The four-layer rofi chain needs config.rasi and local.rasi to exist, and
neither has an upstream file to copy: a HyDE theme has no equivalent of
either. They were hand-written for this machine, which meant a second machine
got a launcher that reported a missing @import instead of opening.

config.rasi is `include_str!`d from config/rofi/ rather than kept as a string
literal, so it stays a real .rasi file -- highlightable, diffable, editable
without a rebuild to see the result -- and there is one copy of it rather than
two that can disagree.

local.rasi is composed per machine from two things the repo cannot know: the
theme's $ICON_THEME, and a wallpaper path. Each half is omitted entirely when
there is nothing to say, because an empty `icon-theme:` list is something rofi
would honour.

$ICON_THEME comes back as a field on Import rather than being re-parsed out of
the conf text that the same function just rendered.

The wallpaper needed a stable name. local.rasi cannot hardcode a filename
without going stale at the next theme import, so plan_wallpapers now also
maintains ~/.local/share/wallpapers/hyprcosmic/current as a symlink to one of
the copies it made. HyDE has this problem too and solves it the same way, with
~/.cache/hyde/wall.thmb. The launcher sidebar and the autostart's `awww img`
line both name the link, so they cannot drift apart.

Which wallpaper it points at is the first in *sorted* order. read_dir returns
whatever the filesystem feels like, and an arbitrary choice is fine where an
unrepeatable one is not: re-running the import would otherwise change the
wallpaper at random. The link is repointed even when every wallpaper was
skipped as already installed -- the copies are theme-specific and unchanged,
but the link is global and has to follow the theme just imported.

Repointing has to handle a *dangling* link, which is exactly what a previous
import leaves behind once its theme directory is gone: symlink(2) fails with
EEXIST rather than replacing, and Path::exists follows the link, so it answers
false for the one case that needs removing. symlink_metadata asks about the
link itself.

Values reaching a generated config come from a theme directory that may have
been downloaded from anywhere, so quote_rasi_string strips quotes, backslashes
and control characters -- .rasi has no escape syntax worth relying on.

Four existing tests counted actions and broke once every plan carried two more.
Fixed with a theme_assets() filter rather than by bumping the numbers, so what
they are actually asserting stays visible and the next generated file does not
break them again.

125 unit, 5 bin and 4 integration tests pass; clippy --all-targets and
`cargo fmt --check` are clean. A real import of Tokyo Night installed all 13
paths, and `cosmic-conf apply --diff` reports no changes afterwards.
2026-08-10 11:48:02 +07:00

525 lines
17 KiB
Rust

//! HyDE `hypr.theme` -> `cosmic.conf`.
//!
//! One-way, and into the conf file rather than straight into cosmic-config, so
//! the result is readable and editable before it touches the desktop.
//!
//! The guiding rule is that **nothing is dropped silently**. A HyDE theme
//! contains a good deal that COSMIC has no equivalent for — gradient borders,
//! blur tuning, layer rules — and a converter that quietly ignored them would
//! leave the user wondering why their desktop looks wrong. Every unhandled key
//! is reported with a reason.
use crate::parser::{parse, Item, ParseError, Span};
/// Why a source key did not make it into the output.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Reason {
/// COSMIC has no equivalent concept.
NoEquivalent(&'static str),
/// Needs a compositor patch that does not exist yet (spec Phase 2).
NeedsCompositorPatch(&'static str),
/// Belongs to another program entirely; copied verbatim, not translated.
DifferentProgram(&'static str),
/// Translated, but with a loss worth knowing about.
Lossy(String),
}
impl Reason {
pub fn describe(&self) -> String {
match self {
Reason::NoEquivalent(d) => format!("no COSMIC equivalent: {d}"),
Reason::NeedsCompositorPatch(d) => format!("needs a cosmic-comp patch: {d}"),
Reason::DifferentProgram(d) => format!("handled by another program: {d}"),
Reason::Lossy(d) => format!("translated with loss: {d}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Note {
pub key: String,
pub value: String,
pub reason: Reason,
pub span: Span,
}
#[derive(Debug, Default)]
pub struct Import {
/// Generated `cosmic.conf` text.
pub conf: String,
/// Everything that did not translate cleanly.
pub notes: Vec<Note>,
/// The theme's `$ICON_THEME`, if it names one.
///
/// Also present in `conf` as `theme.icon_theme`, but repeated here as a
/// field because `assets.rs` needs it to generate rofi's `local.rasi` and
/// re-parsing the text this function just rendered to get it back would be
/// absurd.
pub icon_theme: Option<String>,
}
impl Import {
/// Keys that produced no output at all, as opposed to lossy translations.
pub fn dropped(&self) -> impl Iterator<Item = &Note> {
self.notes
.iter()
.filter(|n| !matches!(n.reason, Reason::Lossy(_)))
}
}
/// HyDE prefixes each `.theme` file with a destination line such as
/// `$HOME/.config/hypr/themes/theme.conf|> $HOME/.../colors.conf`.
///
/// It is metadata for HyDE's own installer, not config, and it has no `=`, so
/// the parser would reject the file outright. Strip it before parsing.
fn strip_hyde_header(src: &str) -> &str {
let mut lines = src.lines();
let Some(first) = lines.next() else {
return src;
};
let is_destination_header =
!first.contains('=') && (first.contains("|>") || first.contains('|'));
if is_destination_header {
// Preserve line numbering by keeping the newline count intact: callers
// report spans against the stripped text, so re-add a blank line.
match src.find('\n') {
Some(i) => &src[i + 1..],
None => "",
}
} else {
src
}
}
/// First colour of a possibly-gradient Hyprland border spec.
/// `rgba(ca9ee6ff) rgba(f2d5cfff) 45deg` -> `ca9ee6`.
fn first_color_rgb(value: &str) -> Option<String> {
let token = value.split_whitespace().next()?;
let inner = token
.strip_prefix("rgba(")
.or_else(|| token.strip_prefix("rgb("))?
.strip_suffix(')')?;
let hex = inner.trim_start_matches('#');
if hex.len() >= 6 && hex[..6].chars().all(|c| c.is_ascii_hexdigit()) {
Some(hex[..6].to_string())
} else {
None
}
}
fn is_gradient(value: &str) -> bool {
value.split_whitespace().count() > 1
}
/// Flatten to dotted keys, keeping variables separate — HyDE carries
/// `$GTK_THEME` / `$ICON_THEME` as variables rather than config keys.
fn walk(
items: &[Item],
prefix: &str,
out: &mut Vec<(String, String, Span)>,
vars: &mut Vec<(String, String, Span)>,
) {
for item in items {
match item {
Item::Section { name, items } => {
let next = if prefix.is_empty() {
name.value.clone()
} else {
format!("{prefix}.{}", name.value)
};
walk(items, &next, out, vars);
}
Item::Assign { key, value } => {
let dotted = if prefix.is_empty() {
key.value.clone()
} else {
format!("{prefix}.{}", key.value)
};
out.push((dotted, value.value.clone(), key.span));
}
Item::VarDef { name, value } => {
vars.push((name.value.clone(), value.value.clone(), name.span));
}
Item::Source { .. } => {}
}
}
}
/// Translate a HyDE `hypr.theme` into a `cosmic.conf`.
pub fn import_hypr_theme(src: &str, theme_name: &str) -> Result<Import, ParseError> {
let body = strip_hyde_header(src);
let ast = parse(body)?;
let mut keys = Vec::new();
let mut vars = Vec::new();
walk(&ast.items, "", &mut keys, &mut vars);
let mut general: Vec<(String, String)> = Vec::new();
let mut decoration: Vec<(String, String)> = Vec::new();
let mut theme: Vec<(String, String)> = Vec::new();
let mut notes = Vec::new();
let mut icon_theme = None;
for (name, value, span) in &vars {
match name.as_str() {
"ICON_THEME" => {
theme.push(("icon_theme".into(), value.clone()));
icon_theme = Some(value.clone());
}
"COLOR_SCHEME" => {
let mode = if value.contains("light") {
"light"
} else {
"dark"
};
theme.push(("mode".into(), mode.into()));
}
"GTK_THEME" => notes.push(Note {
key: format!("${name}"),
value: value.clone(),
reason: Reason::DifferentProgram(
"GTK theme applies to GTK apps directly; COSMIC apps use cosmic-theme",
),
span: *span,
}),
_ => {}
}
}
for (key, value, span) in &keys {
let note = |reason| Note {
key: key.clone(),
value: value.clone(),
reason,
span: *span,
};
match key.as_str() {
"general.gaps_in" => general.push(("gaps_in".into(), value.clone())),
"general.gaps_out" => general.push(("gaps_out".into(), value.clone())),
"decoration.rounding" => decoration.push(("rounding".into(), value.clone())),
// Border colour is the closest thing a HyDE theme has to an accent.
"general.col.active_border" => match first_color_rgb(value) {
Some(hex) => {
theme.push(("accent".into(), format!("rgb({hex})")));
if is_gradient(value) {
notes.push(note(Reason::Lossy(format!(
"used first stop rgb({hex}) as the accent; COSMIC's active_hint \
is a solid colour with no gradient or angle"
))));
}
}
None => notes.push(note(Reason::NoEquivalent("unrecognised colour syntax"))),
},
"general.col.inactive_border"
| "group.col.border_active"
| "group.col.border_inactive"
| "group.col.border_locked_active"
| "group.col.border_locked_inactive" => {
notes.push(note(Reason::NoEquivalent(
"COSMIC draws a single active hint; per-state border colours do not exist",
)));
}
"general.border_size" => notes.push(note(Reason::NoEquivalent(
"COSMIC's active_hint is a boolean, not a width",
))),
"general.layout" => notes.push(note(Reason::NoEquivalent(
"cosmic-comp uses a BSP tiler; dwindle/master are not available",
))),
"general.resize_on_border" => {
notes.push(note(Reason::NoEquivalent("no equivalent setting")))
}
k if k.starts_with("decoration.blur") => {
notes.push(note(Reason::NeedsCompositorPatch(
"COSMIC blur is client-requested via \
ext-background-effect; rule-driven blur is spec Phase 2",
)))
}
k if k.starts_with("decoration.shadow") => notes.push(note(
Reason::NeedsCompositorPatch("shadow.frag exists but is not configurable yet"),
)),
"decoration.active_opacity" | "decoration.inactive_opacity" => notes.push(note(
Reason::NeedsCompositorPatch("window opacity is not configurable yet"),
)),
"layerrule" => notes.push(note(Reason::DifferentProgram(
"layer rules target the bar; waybar is configured directly",
))),
"exec" => notes.push(note(Reason::DifferentProgram(
"HyDE runs gsettings here; icon and GTK themes are handled above",
))),
_ => notes.push(note(Reason::NoEquivalent("unrecognised key"))),
}
}
Ok(Import {
conf: render_conf(theme_name, &general, &decoration, &theme, &notes),
notes,
icon_theme,
})
}
fn render_conf(
theme_name: &str,
general: &[(String, String)],
decoration: &[(String, String)],
theme: &[(String, String)],
notes: &[Note],
) -> String {
let mut out = format!(
"# Generated by `cosmic-conf import-theme` from the HyDE theme {theme_name:?}.\n\
# Edit freely — this file is the source of truth; cosmic-settings changes\n\
# are overwritten on the next `cosmic-conf apply`.\n"
);
let dropped: Vec<&Note> = notes
.iter()
.filter(|n| !matches!(n.reason, Reason::Lossy(_)))
.collect();
if !dropped.is_empty() {
out.push_str(&format!(
"#\n# {} setting(s) from the source theme were not translated.\n\
# Run with --report to see them.\n",
dropped.len()
));
}
let section = |name: &str, rows: &[(String, String)], out: &mut String| {
if rows.is_empty() {
return;
}
out.push_str(&format!("\n{name} {{\n"));
let width = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (k, v) in rows {
out.push_str(&format!(" {k:<width$} = {v}\n"));
}
out.push_str("}\n");
};
section("general", general, &mut out);
section("decoration", decoration, &mut out);
section("theme", theme, &mut out);
out
}
/// Human-readable report of everything that did not translate cleanly.
pub fn render_report(import: &Import) -> String {
if import.notes.is_empty() {
return "Everything in the source theme translated cleanly.\n".into();
}
let mut out = String::new();
let lossy: Vec<&Note> = import
.notes
.iter()
.filter(|n| matches!(n.reason, Reason::Lossy(_)))
.collect();
let dropped: Vec<&Note> = import.dropped().collect();
if !lossy.is_empty() {
out.push_str("Translated with loss:\n");
for n in &lossy {
out.push_str(&format!(
" {} = {}\n {}\n",
n.key,
n.value,
n.reason.describe()
));
}
}
if !dropped.is_empty() {
if !lossy.is_empty() {
out.push('\n');
}
out.push_str("Not translated:\n");
for n in &dropped {
out.push_str(&format!(
" {} = {}\n {}\n",
n.key,
n.value,
n.reason.describe()
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// The genuine Catppuccin-Mocha `hypr.theme` from HyDE-Project/hyde-themes,
/// verbatim including its destination header. Synthetic fixtures would miss
/// the header, the colon-keys and the gradient syntax.
const HYDE_CATPPUCCIN: &str = r#"$HOME/.config/hypr/themes/theme.conf|> $HOME/.config/hypr/themes/colors.conf
# // P̳r̳a̳s̳a̳n̳t̳h̳ R̳a̳n̳g̳a̳n̳
$GTK_THEME=Catppuccin-Mocha
$ICON_THEME = Tela-circle-dracula
$COLOR_SCHEME = prefer-dark
exec = gsettings set org.gnome.desktop.interface icon-theme $ICON_THEME
general {
gaps_in = 3
gaps_out = 8
border_size = 2
col.active_border = rgba(ca9ee6ff) rgba(f2d5cfff) 45deg
col.inactive_border = rgba(b4befecc) rgba(6c7086cc) 45deg
layout = dwindle
resize_on_border = true
}
group {
col.border_active = rgba(ca9ee6ff) rgba(f2d5cfff) 45deg
col.border_inactive = rgba(b4befecc) rgba(6c7086cc) 45deg
}
decoration {
rounding = 10
shadow:enabled = false
blur {
enabled = yes
size = 6
passes = 3
}
}
layerrule = blur,waybar
"#;
fn import() -> Import {
import_hypr_theme(HYDE_CATPPUCCIN, "Catppuccin Mocha").expect("real HyDE theme must parse")
}
#[test]
fn real_hyde_theme_parses_despite_its_destination_header() {
// The header has no `=` and would otherwise be a parse error.
let _ = import();
}
#[test]
fn geometry_is_translated() {
let c = import().conf;
assert!(c.contains("gaps_in = 3"), "{c}");
assert!(c.contains("gaps_out = 8"), "{c}");
assert!(c.contains("rounding = 10"), "{c}");
}
#[test]
fn accent_comes_from_the_first_gradient_stop() {
let c = import().conf;
assert!(c.contains("accent"), "{c}");
assert!(c.contains("rgb(ca9ee6)"), "{c}");
}
#[test]
fn gradient_loss_is_reported_not_silent() {
let i = import();
let lossy: Vec<_> = i
.notes
.iter()
.filter(|n| matches!(n.reason, Reason::Lossy(_)))
.collect();
assert_eq!(lossy.len(), 1, "{:?}", i.notes);
assert_eq!(lossy[0].key, "general.col.active_border");
assert!(lossy[0].reason.describe().contains("gradient"));
}
#[test]
fn icon_theme_and_color_scheme_come_from_variables() {
let c = import().conf;
assert!(c.contains("icon_theme = Tela-circle-dracula"), "{c}");
assert!(c.contains("mode"), "{c}");
assert!(c.contains("dark"), "{c}");
}
#[test]
fn blur_is_flagged_as_needing_a_compositor_patch() {
let i = import();
let blur: Vec<_> = i
.notes
.iter()
.filter(|n| n.key.starts_with("decoration.blur"))
.collect();
assert!(!blur.is_empty(), "blur settings must be reported");
assert!(blur
.iter()
.all(|n| matches!(n.reason, Reason::NeedsCompositorPatch(_))));
}
#[test]
fn unsupported_concepts_are_each_reported() {
let i = import();
let keys: Vec<&str> = i.dropped().map(|n| n.key.as_str()).collect();
for expected in [
"general.border_size",
"general.layout",
"general.col.inactive_border",
"group.col.border_active",
"layerrule",
] {
assert!(
keys.contains(&expected),
"`{expected}` missing from {keys:?}"
);
}
}
#[test]
fn nothing_is_dropped_without_a_note() {
// Every source key must either appear in the output or carry a note.
let i = import();
let translated = ["general.gaps_in", "general.gaps_out", "decoration.rounding"];
let noted: Vec<&str> = i.notes.iter().map(|n| n.key.as_str()).collect();
let body = strip_hyde_header(HYDE_CATPPUCCIN);
let ast = parse(body).unwrap();
let (mut keys, mut vars) = (Vec::new(), Vec::new());
walk(&ast.items, "", &mut keys, &mut vars);
for (k, _, _) in &keys {
assert!(
translated.contains(&k.as_str()) || noted.contains(&k.as_str()),
"`{k}` was neither translated nor reported"
);
}
}
#[test]
fn generated_conf_is_valid_input_to_our_own_parser() {
// The importer must not emit something `apply` cannot read.
let c = import().conf;
parse(&c).expect("generated cosmic.conf must parse");
}
#[test]
fn generated_conf_resolves_against_the_registry() {
// Stronger: every key it emits must actually exist in the schema.
let c = import().conf;
let ast = parse(&c).unwrap();
crate::resolve(&ast).expect("generated conf must resolve cleanly");
}
#[test]
fn report_separates_lossy_from_dropped() {
let r = render_report(&import());
assert!(r.contains("Translated with loss:"), "{r}");
assert!(r.contains("Not translated:"), "{r}");
}
#[test]
fn header_without_pipe_is_not_stripped() {
let src = "general {\n gaps_in = 4\n}\n";
let i = import_hypr_theme(src, "t").unwrap();
// Single key, so no alignment padding.
assert!(i.conf.contains("gaps_in = 4"), "{}", i.conf);
}
}