Skip to main content

merman_render/
tree_view.rs

1use crate::config::{config_bool, config_f64};
2use crate::model::{Bounds, TreeViewDiagramLayout, TreeViewLineLayout, TreeViewNodeLayout};
3use crate::text::{TextMeasurer, TextStyle};
4use crate::theme::PresentationTheme;
5use crate::{Error, Result};
6use merman_core::MAX_DIAGRAM_NESTING_DEPTH;
7use merman_core::diagrams::tree_view::{
8    TreeViewDiagramRenderModel, TreeViewNodeRenderModel as TreeViewNode,
9};
10use serde_json::Value;
11use std::collections::HashMap;
12
13#[derive(Debug, Clone)]
14struct TreeViewConfig {
15    row_indent: f64,
16    padding_x: f64,
17    padding_y: f64,
18    line_thickness: f64,
19    use_max_width: bool,
20    label_font_size: f64,
21}
22
23pub fn layout_tree_view_diagram(
24    semantic: &Value,
25    effective_config: &Value,
26    measurer: &dyn TextMeasurer,
27) -> Result<TreeViewDiagramLayout> {
28    let model: TreeViewDiagramRenderModel = crate::json::from_value_ref(semantic)?;
29    layout_tree_view_diagram_typed(&model, effective_config, measurer)
30}
31
32pub fn layout_tree_view_diagram_typed(
33    model: &TreeViewDiagramRenderModel,
34    effective_config: &Value,
35    measurer: &dyn TextMeasurer,
36) -> Result<TreeViewDiagramLayout> {
37    let cfg = tree_view_config(effective_config);
38    validate_tree_view_render_depth(&model.root)?;
39    let style = TextStyle {
40        font_size: cfg.label_font_size,
41        ..Default::default()
42    };
43    let mut ctx = LayoutCtx {
44        cfg: cfg.clone(),
45        measurer,
46        style,
47        total_height: 0.0,
48        total_width: 0.0,
49        nodes: Vec::new(),
50        lines: Vec::new(),
51    };
52
53    layout_tree(&mut ctx, &model.root);
54
55    let min_x = -ctx.cfg.line_thickness / 2.0;
56    let total_width = ctx.total_width.max(1.0);
57    let total_height = ctx.total_height.max(1.0);
58    Ok(TreeViewDiagramLayout {
59        bounds: Some(Bounds {
60            min_x,
61            min_y: 0.0,
62            max_x: total_width,
63            max_y: total_height,
64        }),
65        total_width,
66        total_height,
67        row_indent: ctx.cfg.row_indent,
68        padding_x: ctx.cfg.padding_x,
69        padding_y: ctx.cfg.padding_y,
70        line_thickness: ctx.cfg.line_thickness,
71        use_max_width: ctx.cfg.use_max_width,
72        label_font_size: ctx.cfg.label_font_size,
73        nodes: ctx.nodes,
74        lines: ctx.lines,
75    })
76}
77
78fn validate_tree_view_render_depth(root: &TreeViewNode) -> Result<()> {
79    let mut stack = vec![(root, 0usize)];
80    while let Some((node, depth)) = stack.pop() {
81        if depth > MAX_DIAGRAM_NESTING_DEPTH {
82            return Err(Error::InvalidModel {
83                message: format!("treeView nesting depth exceeds {MAX_DIAGRAM_NESTING_DEPTH}"),
84            });
85        }
86        for child in &node.children {
87            stack.push((child, depth.saturating_add(1)));
88        }
89    }
90    Ok(())
91}
92
93struct LayoutCtx<'a> {
94    cfg: TreeViewConfig,
95    measurer: &'a dyn TextMeasurer,
96    style: TextStyle,
97    total_height: f64,
98    total_width: f64,
99    nodes: Vec<TreeViewNodeLayout>,
100    lines: Vec<TreeViewLineLayout>,
101}
102
103enum LayoutFrame<'a> {
104    Enter {
105        node: &'a TreeViewNode,
106        depth: usize,
107    },
108    Exit {
109        node: &'a TreeViewNode,
110        node_index: usize,
111    },
112}
113
114fn layout_tree(ctx: &mut LayoutCtx<'_>, root: &TreeViewNode) {
115    let mut stack = vec![LayoutFrame::Enter {
116        node: root,
117        depth: 0,
118    }];
119    let mut node_indices: HashMap<*const TreeViewNode, usize> = HashMap::new();
120
121    while let Some(frame) = stack.pop() {
122        match frame {
123            LayoutFrame::Enter { node, depth } => {
124                let node_index = push_node_layout(ctx, node, depth);
125                node_indices.insert(std::ptr::from_ref(node), node_index);
126                stack.push(LayoutFrame::Exit { node, node_index });
127                for child in node.children.iter().rev() {
128                    stack.push(LayoutFrame::Enter {
129                        node: child,
130                        depth: depth.saturating_add(1),
131                    });
132                }
133            }
134            LayoutFrame::Exit { node, node_index } => {
135                if let Some(last_child) = node.children.last() {
136                    if let Some(last_child_idx) =
137                        node_indices.get(&std::ptr::from_ref(last_child)).copied()
138                    {
139                        push_vertical_line(ctx, node_index, last_child_idx);
140                    }
141                }
142            }
143        }
144    }
145}
146
147fn push_node_layout(ctx: &mut LayoutCtx<'_>, node: &TreeViewNode, depth: usize) -> usize {
148    let indent = depth as f64 * (ctx.cfg.row_indent + ctx.cfg.padding_x);
149    let label_width = tree_view_label_bbox_width_px(ctx.measurer, &node.name, &ctx.style);
150    let label_height = tree_view_label_bbox_height_px(ctx.cfg.label_font_size);
151    let height = label_height + ctx.cfg.padding_y * 2.0;
152    let width = label_width + ctx.cfg.padding_x * 2.0;
153    let y = ctx.total_height;
154    let idx = ctx.nodes.len();
155
156    ctx.nodes.push(TreeViewNodeLayout {
157        id: node.id,
158        level: node.level,
159        name: node.name.clone(),
160        depth,
161        x: indent,
162        y,
163        width,
164        height,
165        label_x: indent + ctx.cfg.padding_x,
166        label_y: y + height / 2.0,
167        label_width,
168        label_height,
169    });
170    ctx.lines.push(TreeViewLineLayout {
171        x1: indent - ctx.cfg.row_indent,
172        y1: y + height / 2.0,
173        x2: indent,
174        y2: y + height / 2.0,
175        stroke_width: ctx.cfg.line_thickness,
176        kind: "horizontal".to_string(),
177    });
178
179    ctx.total_width = ctx.total_width.max(indent + width);
180    ctx.total_height += height;
181
182    idx
183}
184
185fn push_vertical_line(ctx: &mut LayoutCtx<'_>, node_index: usize, last_child_idx: usize) {
186    let Some(current) = ctx.nodes.get(node_index) else {
187        return;
188    };
189    let Some(last_child) = ctx.nodes.get(last_child_idx) else {
190        return;
191    };
192    let current_x = current.x;
193    let current_y = current.y;
194    let current_height = current.height;
195    let last_child_y = last_child.y;
196    let last_child_height = last_child.height;
197
198    ctx.lines.push(TreeViewLineLayout {
199        x1: current_x + ctx.cfg.padding_x,
200        y1: current_y + current_height,
201        x2: current_x + ctx.cfg.padding_x,
202        y2: last_child_y + last_child_height / 2.0 + ctx.cfg.line_thickness / 2.0,
203        stroke_width: ctx.cfg.line_thickness,
204        kind: "vertical".to_string(),
205    });
206}
207
208fn tree_view_config(effective_config: &Value) -> TreeViewConfig {
209    let theme = PresentationTheme::new(effective_config).tree_view();
210    TreeViewConfig {
211        row_indent: config_f64(effective_config, &["treeView", "rowIndent"])
212            .unwrap_or(10.0)
213            .max(0.0),
214        padding_x: config_f64(effective_config, &["treeView", "paddingX"])
215            .unwrap_or(5.0)
216            .max(0.0),
217        padding_y: config_f64(effective_config, &["treeView", "paddingY"])
218            .unwrap_or(5.0)
219            .max(0.0),
220        line_thickness: config_f64(effective_config, &["treeView", "lineThickness"])
221            .unwrap_or(1.0)
222            .max(0.0),
223        use_max_width: config_bool(effective_config, &["treeView", "useMaxWidth"]).unwrap_or(true),
224        label_font_size: theme.label_font_size,
225    }
226}
227
228fn tree_view_label_bbox_height_px(font_size: f64) -> f64 {
229    (font_size.max(1.0) * 1.15).ceil()
230}
231
232fn tree_view_label_bbox_width_px(
233    measurer: &dyn TextMeasurer,
234    label: &str,
235    style: &TextStyle,
236) -> f64 {
237    if style.font_size > 16.0 {
238        measurer.measure(label, style).width.max(0.0)
239    } else {
240        measurer
241            .measure_svg_raw_text_bbox_width_px(label, style)
242            .max(0.0)
243    }
244}