use super::values::format_value;
use crate::Options;
use crate::layout::{LaidOut, PlacedNode};
use crate::resolve::{AttrMap, MarkerKind, ResolvedValue, ShapeKind, VarTable};
use std::collections::{BTreeSet, HashMap};
pub const PAINT_PROPS: &[(&str, &str)] = &[
("fill", "fill"),
("stroke", "stroke"),
("stroke-width", "stroke-width"),
("opacity", "opacity"),
("color", "color"),
("font-family", "font-family"),
("font-size", "font-size"),
("font-weight", "font-weight"),
("font-style", "font-style"),
("text-transform", "text-transform"),
("text-decoration", "text-decoration"),
("text-shadow", "text-shadow"),
];
pub struct Rule {
pub class: String,
pub props: Vec<(String, String)>,
}
pub struct RuleSet {
pub rules: Vec<Rule>,
}
impl RuleSet {
pub fn emit(&self, out: &mut String) {
for rule in &self.rules {
if rule.props.is_empty() {
continue;
}
if rule.class == "lini" {
out.push_str(" .lini {");
} else {
out.push_str(" .lini .");
out.push_str(&rule.class);
out.push_str(" {");
}
for (prop, value) in &rule.props {
out.push(' ');
out.push_str(prop);
out.push_str(": ");
out.push_str(value);
out.push(';');
}
out.push_str(" }\n");
}
}
pub fn provided(&self, classes: &[String], prop: &str) -> Option<&str> {
let mut hit = None;
for rule in &self.rules {
if !classes.contains(&rule.class) {
continue;
}
if let Some((_, v)) = rule.props.iter().find(|(p, _)| p == prop) {
hit = Some(v.as_str());
}
}
hit
}
pub fn marker_fill(&self, classes: &[String]) -> Option<&str> {
let mut hit = None;
for rule in &self.rules {
let matches = rule.class == "lini-marker"
|| rule
.class
.strip_suffix(" .lini-marker")
.is_some_and(|prefix| classes.iter().any(|c| c == prefix));
if matches && let Some((_, v)) = rule.props.iter().find(|(p, _)| p == "fill") {
hit = Some(v.as_str());
}
}
hit
}
}
pub fn build(laid: &LaidOut, opts: &Options) -> RuleSet {
let vars = &laid.vars;
let live = |name: &str| {
format_value(
&ResolvedValue::LiveVar {
name: name.to_string(),
raw: false,
},
vars,
opts,
)
};
let mut present: BTreeSet<&str> = BTreeSet::new();
let mut used_styles: BTreeSet<&str> = BTreeSet::new();
let mut has_markers = false;
let mut has_crow = false;
for node in &laid.nodes {
collect(
node,
&mut present,
&mut used_styles,
&mut has_markers,
&mut has_crow,
);
}
for wire in &laid.wires {
for style in &wire.applied_styles {
used_styles.insert(style.as_str());
}
has_markers |=
wire.markers.start != MarkerKind::None || wire.markers.end != MarkerKind::None;
has_crow |= wire.markers.start == MarkerKind::Crow || wire.markers.end == MarkerKind::Crow;
}
let has_labels = laid.wires.iter().any(|w| !w.texts.is_empty());
let mut rules: Vec<Rule> = Vec::new();
let font_size = laid.sheet.root_font_size;
let rt = &laid.sheet.root_text;
let global = |attr: &str, var: &str| match rt.get(attr) {
Some(v) => super::values::css_value(attr, v, vars, opts),
None => live(var),
};
let mut root_props = vec![
("font-family".into(), global("font-family", "font-family")),
(
"font-size".into(),
format!("{}px", super::values::num(font_size)),
),
("font-weight".into(), global("font-weight", "font-weight")),
("color".into(), global("color", "text-color")),
];
for attr in [
"font-style",
"text-transform",
"text-decoration",
"text-shadow",
] {
if let Some(v) = rt.get(attr) {
root_props.push((
attr.to_string(),
super::values::css_value(attr, v, vars, opts),
));
}
}
rules.push(Rule {
class: "lini".into(),
props: root_props,
});
let class_map: HashMap<&str, &AttrMap> = laid
.sheet
.class_rules
.iter()
.map(|(n, a)| (n.as_str(), a))
.collect();
let shape_paint = |class: &str| -> Vec<(String, String)> {
class_map
.get(class)
.map(|a| paint_props(a, vars, opts))
.unwrap_or_default()
};
const CLOSED: &[ShapeKind] = &[
ShapeKind::Box,
ShapeKind::Oval,
ShapeKind::Hex,
ShapeKind::Slant,
ShapeKind::Cyl,
ShapeKind::Diamond,
ShapeKind::Cloud,
ShapeKind::Poly,
ShapeKind::Path,
];
for kind in CLOSED {
if present.contains(kind.as_str()) {
let class = format!("lini-{}", kind.as_str());
let mut props = shape_paint(&class);
ensure_dash_none(&mut props);
rules.push(Rule { class, props });
}
}
if present.contains("line") {
let mut props = shape_paint("lini-line");
ensure_dash_none(&mut props);
rules.push(Rule {
class: "lini-line".into(),
props,
});
}
if present.contains("text") {
rules.push(Rule {
class: "lini-text".into(),
props: vec![
("fill".into(), "currentColor".into()),
("stroke".into(), "none".into()),
("text-anchor".into(), "middle".into()),
("dominant-baseline".into(), "central".into()),
],
});
}
if present.contains("icon") {
rules.push(Rule {
class: "lini-icon".into(),
props: shape_paint("lini-icon"),
});
}
for (name, attrs) in &laid.sheet.class_rules {
if let Some(tn) = name.strip_prefix("lini-")
&& ShapeKind::parse(tn).is_none()
&& present.contains(tn)
{
rules.push(Rule {
class: name.clone(),
props: paint_props(attrs, vars, opts),
});
}
}
if !laid.wires.is_empty() || !laid.airwires.is_empty() {
let defaults = &laid.sheet.wire_defaults;
let dp = paint_props(defaults, vars, opts);
let from_defaults = |p: &str| dp.iter().find(|(k, _)| k == p).map(|(_, v)| v.clone());
let mut props = vec![
("fill".into(), "none".into()),
(
"stroke".into(),
from_defaults("stroke").unwrap_or_else(|| live("stroke")),
),
(
"stroke-width".into(),
from_defaults("stroke-width").unwrap_or_else(|| "2".into()),
),
(
"stroke-dasharray".into(),
from_defaults("stroke-dasharray").unwrap_or_else(|| "none".into()),
),
];
for (k, v) in &dp {
if !k.starts_with("font")
&& !matches!(k.as_str(), "stroke" | "stroke-width" | "stroke-dasharray")
{
props.push((k.clone(), v.clone()));
}
}
rules.push(Rule {
class: "lini-wire".into(),
props,
});
let wire_width = laid
.sheet
.wire_defaults
.number("stroke-width")
.unwrap_or(0.0);
let mut wire_styles: BTreeSet<&str> = BTreeSet::new();
for w in &laid.wires {
if let Some(ResolvedValue::Ident(s)) = w.attrs.get("stroke-style")
&& (s == "dashed" || s == "dotted")
{
wire_styles.insert(s.as_str());
}
}
for style in wire_styles {
rules.push(Rule {
class: format!("lini-wire-{style}"),
props: vec![(
"stroke-dasharray".into(),
super::values::dash_pattern(style, wire_width),
)],
});
}
}
if has_labels {
let wfs = laid.sheet.wire_defaults.number("font-size").unwrap_or(11.0);
rules.push(Rule {
class: "lini-wire-label".into(),
props: vec![
("fill".into(), "currentColor".into()),
("stroke".into(), "none".into()),
("text-anchor".into(), "middle".into()),
("dominant-baseline".into(), "central".into()),
("font-size".into(), format!("{}px", super::values::num(wfs))),
("font-weight".into(), live("wire-font-weight")),
],
});
rules.push(Rule {
class: "lini-cut-bg".into(),
props: vec![
("fill".into(), "white".into()),
("stroke".into(), "none".into()),
],
});
rules.push(Rule {
class: "lini-cut".into(),
props: vec![
("fill".into(), "black".into()),
("stroke".into(), "none".into()),
],
});
}
if has_markers {
rules.push(Rule {
class: "lini-marker".into(),
props: vec![
("fill".into(), live("stroke")),
("stroke".into(), "none".into()),
],
});
}
for (name, attrs) in &laid.sheet.class_rules {
if name.starts_with("lini-") || !used_styles.contains(name.as_str()) {
continue;
}
rules.push(Rule {
class: format!("lini-style-{}", name),
props: paint_props(attrs, vars, opts),
});
if has_markers && let Some(v) = attrs.get("stroke") {
rules.push(Rule {
class: format!("lini-style-{} .lini-marker", name),
props: vec![("fill".into(), format_value(v, vars, opts))],
});
}
}
if has_crow {
rules.push(Rule {
class: "lini-marker.lini-marker-crow".into(),
props: vec![
("fill".into(), "none".into()),
("stroke".into(), "inherit".into()),
("stroke-linecap".into(), "round".into()),
("stroke-dasharray".into(), "none".into()),
],
});
}
rules.retain(|r| !r.props.is_empty());
RuleSet { rules }
}
pub fn effective_stroke(
attrs: &AttrMap,
classes: &[String],
set: &RuleSet,
vars: &VarTable,
opts: &Options,
) -> String {
if let Some(v) = attrs.get("stroke") {
return format_value(v, vars, opts);
}
if let Some(v) = set.provided(classes, "stroke") {
return v.to_string();
}
super::values::attr_or_var(&AttrMap::default(), "stroke", "stroke", vars, opts)
}
pub fn paint_props(attrs: &AttrMap, vars: &VarTable, opts: &Options) -> Vec<(String, String)> {
let mut out = Vec::new();
for (lini, css) in PAINT_PROPS {
if let Some(v) = attrs.get(lini) {
out.push((
css.to_string(),
super::values::css_value(lini, v, vars, opts),
));
}
}
if attrs.get("stroke-style").is_some() {
let width = attrs.number("stroke-width").unwrap_or(0.0);
let dash = super::values::dasharray_value(attrs, width);
out.push((
"stroke-dasharray".to_string(),
if dash.is_empty() {
"none".to_string()
} else {
dash
},
));
}
out
}
fn ensure_dash_none(props: &mut Vec<(String, String)>) {
if !props.iter().any(|(p, _)| p == "stroke-dasharray") {
props.push(("stroke-dasharray".into(), "none".into()));
}
}
fn collect<'a>(
node: &'a PlacedNode,
present: &mut BTreeSet<&'a str>,
used_styles: &mut BTreeSet<&'a str>,
has_markers: &mut bool,
has_crow: &mut bool,
) {
present.insert(node.shape.as_str());
for name in &node.type_chain {
present.insert(name.as_str());
}
for name in &node.applied_styles {
used_styles.insert(name.as_str());
}
*has_markers |= node.markers.start != MarkerKind::None || node.markers.end != MarkerKind::None;
*has_crow |= node.markers.start == MarkerKind::Crow || node.markers.end == MarkerKind::Crow;
for child in &node.children {
collect(child, present, used_styles, has_markers, has_crow);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rules_for(src: &str) -> RuleSet {
let tokens = crate::lexer::lex(src).expect("lex");
let file = crate::syntax::parser::parse(&tokens).expect("parse");
let lowered = crate::desugar::desugar(&file).expect("desugar");
let program = crate::resolve::resolve_with_theme(&lowered, &[]).expect("resolve");
let laid = crate::layout::layout(&program).expect("layout");
build(&laid, &Options::default())
}
fn emit_str(set: &RuleSet) -> String {
let mut s = String::new();
set.emit(&mut s);
s
}
#[test]
fn root_rule_carries_inherited_text_props() {
let css = emit_str(&rules_for("x |box|\n"));
assert!(
css.contains(".lini { font-family: var(--lini-font-family); font-size: 15px; font-weight: var(--lini-font-weight); color: var(--lini-text-color); }"),
"{}",
css
);
}
#[test]
fn shape_rules_only_for_present_types() {
let css = emit_str(&rules_for("x |box|\n"));
assert!(css.contains(".lini .lini-box {"), "{}", css);
assert!(!css.contains("lini-oval"), "{}", css);
}
#[test]
fn shape_rules_complete_over_inheritable_paint() {
let set = rules_for("x |box|\ny |oval|\nz |line| { points: 0 0, 10 0; }\n");
for rule in &set.rules {
if rule.class == "lini-text" {
assert!(
rule.props.iter().any(|(p, v)| p == "stroke" && v == "none"),
"text rule lacks the stroke mask"
);
continue;
}
if rule.class.starts_with("lini-") && rule.class != "lini-icon" {
assert!(
rule.props.iter().any(|(p, _)| p == "stroke-dasharray"),
"rule {} lacks the dasharray mask",
rule.class
);
}
}
}
#[test]
fn style_defs_emit_in_defs_order_used_only() {
let css = emit_str(&rules_for(
"{ .a { stroke: red; }\n.b { stroke: blue; }\n.unused { stroke: green; } }\nx |box| .b.a\n",
));
let a = css.find(".lini .lini-style-a").expect("a rule");
let b = css.find(".lini .lini-style-b").expect("b rule");
assert!(a < b, "definition order: {}", css);
assert!(!css.contains("lini-style-unused"), "{}", css);
}
#[test]
fn wire_rule_states_defaults() {
let css = emit_str(&rules_for("a -> b\n"));
assert!(
css.contains(
".lini .lini-wire { fill: none; stroke: var(--lini-stroke); stroke-width: 2; stroke-dasharray: none; }"
),
"{}",
css
);
}
#[test]
fn marker_rule_states_fill_and_stroke_none() {
let css = emit_str(&rules_for("a -> b\n"));
assert!(
css.contains(".lini .lini-marker { fill: var(--lini-stroke); stroke: none; }"),
"{}",
css
);
let plain = emit_str(&rules_for("a - b\n"));
assert!(!plain.contains("lini-marker"), "{}", plain);
}
#[test]
fn wire_label_rule_states_constants() {
let css = emit_str(&rules_for("a -> b \"x\"\n"));
assert!(
css.contains(
".lini .lini-wire-label { fill: currentColor; stroke: none; text-anchor: middle; dominant-baseline: central; font-size: 11px; font-weight: var(--lini-wire-font-weight); }"
),
"{}",
css
);
let plain = emit_str(&rules_for("a -> b\n"));
assert!(!plain.contains("lini-wire-label"), "{}", plain);
}
#[test]
fn type_defaults_merge_into_shape_rule() {
let css = emit_str(&rules_for("{ |box| { fill: lightyellow; } }\nx |box|\n"));
assert!(
css.contains(".lini .lini-box { fill: lightyellow;"),
"{}",
css
);
}
#[test]
fn group_template_rule_follows_rect_rule() {
let css = emit_str(&rules_for("g |group| [ x |box| ]\n"));
let rect = css.find(".lini .lini-box").expect("rect rule");
let group = css.find(".lini .lini-group").expect("group rule");
assert!(rect < group, "{}", css);
assert!(
css.contains("lini-group { fill: var(--lini-group-fill); stroke: var(--lini-group-stroke); stroke-width: 1; stroke-dasharray:"),
"{}",
css
);
}
#[test]
fn user_shape_rule_carries_its_paint() {
let css = emit_str(&rules_for(
"{ |treat::box| { fill: pink; radius: 5; } }\nx |treat|\n",
));
assert!(
css.contains(".lini .lini-treat { fill: pink; }"),
"geometry (radius) must not ride CSS: {}",
css
);
}
}