use super::super::*;
use crate::model::TreeViewNodeLayout;
use crate::svg::icon_registry::mermaid_unknown_icon_svg;
use crate::tree_view::{
TREE_VIEW_DESCRIPTION_FONT_STYLE, TREE_VIEW_DIRECTORY_FONT_WEIGHT,
TREE_VIEW_HIGHLIGHT_RECT_EXTENSION, TREE_VIEW_HIGHLIGHT_WIDTH_GROWTH, TREE_VIEW_ICON_SIZE,
is_tree_view_highlight_class,
};
use merman_core::diagrams::tree_view::TreeViewDiagramRenderModel;
use std::collections::{BTreeMap, BTreeSet};
const TREE_VIEW_ICON_PREFIX: &str = "mermaid-treeview";
const TREE_VIEW_DIRECTORY_NODE_TYPE: &str = "directory";
pub(crate) fn render_tree_view_diagram_svg_model(
layout: &TreeViewDiagramLayout,
model: &TreeViewDiagramRenderModel,
effective_config: &merman_core::MermaidConfig,
options: &SvgExecution<'_>,
) -> Result<root_svg::RootedSvg> {
let effective_config_value = effective_config.as_value();
let diagram_id = options.diagram_id.as_deref().unwrap_or("treeView");
let diagram_id_esc = escape_xml(diagram_id);
let acc_title = model
.acc_title
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty());
let acc_descr = model
.acc_descr
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty());
let aria_labelledby = acc_title.map(|_| format!("chart-title-{diagram_id}"));
let aria_describedby = acc_descr.map(|_| format!("chart-desc-{diagram_id}"));
let root_bounds = root_svg::DiagramBounds::from_view_box(
-layout.line_thickness / 2.0,
0.0,
layout.total_width,
layout.total_height,
);
let root_spec = root_svg::RootViewportSpec::mermaid(root_bounds, layout.use_max_width);
let mut out = String::new();
let mut root_chrome = root_svg::RootChrome::new(diagram_id, "treeView");
root_chrome.aria_labelledby = aria_labelledby.as_deref();
root_chrome.aria_describedby = aria_describedby.as_deref();
root_chrome.dom.trailing_newline = false;
let root_document =
root_svg::RootViewportContext::new(crate::family::RenderFamilyKind::TreeView, diagram_id)
.write_open(&mut out, root_spec, root_chrome)?;
let css = tree_view_css(effective_config_value);
if let Some(title) = acc_title {
let _ = write!(
&mut out,
r#"<title id="chart-title-{}">{}</title>"#,
diagram_id_esc,
escape_xml_display(title)
);
}
if let Some(descr) = acc_descr {
let _ = write!(
&mut out,
r#"<desc id="chart-desc-{}">{}</desc>"#,
diagram_id_esc,
escape_xml_display(descr)
);
}
let _ = write!(&mut out, "<style>{css}</style>");
let icon_symbol_ids = tree_view_icon_symbol_ids(layout, diagram_id);
push_tree_view_icon_defs(
&mut out,
&icon_symbol_ids,
options.icon_registry(),
effective_config,
options.work_meter(),
)?;
let emit_icon_use =
config_string(effective_config_value, &["securityLevel"]).as_deref() == Some("loose");
out.push_str("<g/>");
out.push_str(r#"<g class="tree-view">"#);
let mut next_node = 0usize;
let highlighted_node_count = layout
.nodes
.iter()
.filter(|node| is_tree_view_highlight_class(node.css_class.as_deref()))
.count();
let mut width_before_highlight =
layout.total_width - highlighted_node_count as f64 * TREE_VIEW_HIGHLIGHT_WIDTH_GROWTH;
for line in &layout.lines {
if line.kind == "horizontal"
&& let Some(node) = layout.nodes.get(next_node)
{
push_tree_view_node(
&mut out,
node,
layout,
&icon_symbol_ids,
emit_icon_use,
&mut width_before_highlight,
);
next_node += 1;
}
let _ = write!(
&mut out,
r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke-width="{}" class="treeView-node-line"></line>"#,
fmt(line.x1),
fmt(line.y1),
fmt(line.x2),
fmt(line.y2),
fmt(line.stroke_width)
);
}
for node in layout.nodes.iter().skip(next_node) {
push_tree_view_node(
&mut out,
node,
layout,
&icon_symbol_ids,
emit_icon_use,
&mut width_before_highlight,
);
}
out.push_str("</g></svg>\n");
root_document.complete(out)
}
fn push_tree_view_node(
out: &mut String,
node: &TreeViewNodeLayout,
layout: &TreeViewDiagramLayout,
icon_symbol_ids: &BTreeMap<&str, String>,
emit_icon_use: bool,
width_before_highlight: &mut f64,
) {
out.push_str("<g>");
let label_classes = tree_view_label_classes(node);
if is_tree_view_highlight_class(node.css_class.as_deref()) {
let rect_width =
(*width_before_highlight - node.x + TREE_VIEW_HIGHLIGHT_RECT_EXTENSION).max(0.0);
let _ = write!(
out,
r#"<rect x="{}" y="{}" width="{}" height="{}" rx="3" class="treeView-highlight-bg"></rect>"#,
fmt(node.x),
fmt(node.y + 1.0),
fmt(rect_width),
fmt((node.height - 2.0).max(0.0))
);
*width_before_highlight += TREE_VIEW_HIGHLIGHT_WIDTH_GROWTH;
}
if emit_icon_use
&& let Some(symbol_id) = node
.resolved_icon
.as_deref()
.and_then(|icon| icon_symbol_ids.get(icon))
{
let _ = write!(
out,
r##"<use xlink:href="#{}" x="{}" y="{}" class="treeView-node-icon"></use>"##,
symbol_id,
fmt(node.x + layout.padding_x),
fmt(node.y + layout.padding_y)
);
}
let _ = write!(
out,
r#"<text dominant-baseline="middle" class="{}" x="{}" y="{}">{}</text>"#,
escape_xml(&label_classes),
fmt(node.label_x),
fmt(node.label_y),
escape_xml(&node.name)
);
if let (Some(description), Some(description_x)) =
(node.description.as_deref(), node.description_x)
{
let _ = write!(
out,
r#"<text dominant-baseline="middle" class="treeView-node-description" x="{}" y="{}">{}</text>"#,
fmt(description_x),
fmt(node.label_y),
escape_xml(description)
);
}
out.push_str("</g>");
}
fn tree_view_css(effective_config: &serde_json::Value) -> String {
let theme = PresentationTheme::new(effective_config).tree_view();
format!(
".treeView-node-label {{ font-size: {}; fill: {}; white-space: pre; }} .treeView-node-dir {{ font-weight: {}; }} .treeView-node-line {{ stroke: {}; }} .treeView-node-icon {{ color: {}; }} .treeView-node-description {{ font-size: {}; fill: {}; font-style: {}; white-space: pre; }} .treeView-highlight-bg {{ fill: {}; stroke: {}; stroke-width: 1; }}",
theme.label_font_size_css,
theme.label_color,
TREE_VIEW_DIRECTORY_FONT_WEIGHT,
theme.line_color,
theme.icon_color,
theme.label_font_size_css,
theme.description_color,
TREE_VIEW_DESCRIPTION_FONT_STYLE,
theme.highlight_bg,
theme.highlight_stroke
)
}
fn tree_view_label_classes(node: &TreeViewNodeLayout) -> String {
let mut classes = vec!["treeView-node-label".to_string()];
if node.node_type == TREE_VIEW_DIRECTORY_NODE_TYPE {
classes.push("treeView-node-dir".to_string());
}
if let Some(css_class) = node.css_class.as_deref() {
classes.extend(
css_class
.split_whitespace()
.filter(|class| !class.is_empty())
.map(str::to_string),
);
}
classes.join(" ")
}
fn push_tree_view_icon_defs(
out: &mut String,
icon_symbol_ids: &BTreeMap<&str, String>,
icon_registry: Option<&crate::svg::IconRegistry>,
effective_config: &merman_core::MermaidConfig,
work_meter: &crate::resources::OperationWorkMeter,
) -> Result<()> {
if icon_symbol_ids.is_empty() {
return Ok(());
}
out.push_str("<defs>");
for (icon, symbol_id) in icon_symbol_ids {
let _ = write!(out, r#"<g id="{symbol_id}">"#);
if let Some(body) = tree_view_icon_body(icon) {
let _ = write!(
out,
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 24 24">{body}</svg>"#,
fmt(TREE_VIEW_ICON_SIZE),
fmt(TREE_VIEW_ICON_SIZE)
);
} else {
let icon_svg = match icon_registry {
Some(registry) => {
registry.render_icon(crate::svg::icon_registry::IconRenderRequest {
icon_name: icon,
width_px: TREE_VIEW_ICON_SIZE,
height_px: TREE_VIEW_ICON_SIZE,
fallback_prefix: None,
extra_class: None,
id_scope: symbol_id,
effective_config,
work_meter,
})?
}
None => None,
}
.unwrap_or_else(|| {
mermaid_unknown_icon_svg(fmt(TREE_VIEW_ICON_SIZE), fmt(TREE_VIEW_ICON_SIZE))
});
out.push_str(&icon_svg);
}
out.push_str("</g>");
}
out.push_str("</defs>");
Ok(())
}
fn tree_view_icon_symbol_ids<'a>(
layout: &'a TreeViewDiagramLayout,
diagram_id: &str,
) -> BTreeMap<&'a str, String> {
let base_ids = layout
.nodes
.iter()
.filter_map(|node| node.resolved_icon.as_deref())
.collect::<BTreeSet<_>>()
.into_iter()
.map(|icon| (icon, tree_view_icon_symbol_id_base(diagram_id, icon)))
.collect::<Vec<_>>();
let reserved_ids = base_ids
.iter()
.map(|(_, symbol_id)| symbol_id.as_str())
.collect::<BTreeSet<_>>();
let mut next_suffixes = BTreeMap::new();
let mut symbol_ids = BTreeMap::new();
for (icon, base_id) in &base_ids {
let next_suffix = next_suffixes.entry(base_id.as_str()).or_insert(1usize);
let symbol_id = if *next_suffix == 1 {
*next_suffix = 2;
base_id.clone()
} else {
loop {
let candidate = format!("{base_id}-{next_suffix}");
*next_suffix += 1;
if !reserved_ids.contains(candidate.as_str()) {
break candidate;
}
}
};
symbol_ids.insert(*icon, symbol_id);
}
symbol_ids
}
fn tree_view_icon_symbol_id_base(diagram_id: &str, icon: &str) -> String {
let mut id = format!("tv-icon-{diagram_id}-");
for ch in icon.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
id.push(ch);
} else {
id.push('-');
}
}
id
}
fn tree_view_icon_body(icon: &str) -> Option<&'static str> {
match icon
.strip_prefix(TREE_VIEW_ICON_PREFIX)?
.strip_prefix(':')?
{
"folder" => Some(
r#"<path fill="currentColor" d="M10.59 4.59A2 2 0 0 0 9.17 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.17z"/>"#,
),
"file" => Some(
r#"<path fill="currentColor" fill-rule="evenodd" d="M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8.83a2 2 0 0 0-.59-1.42l-4.82-4.82A2 2 0 0 0 13.17 2H6Zm7.5 1.9l4.6 4.6h-3.6a1 1 0 0 1-1-1V3.9Z" clip-rule="evenodd"/>"#,
),
_ => None,
}
}