use std::collections::{BTreeMap, HashSet};
use super::edges::Tip;
use super::shapes::Glyph;
use super::{
labels, panel, shapes, svg, theme, ClusterSection, Diagram, Label, PlacedActivation,
PlacedCluster, PlacedEdge, PlacedEdgeLabel, PlacedLifeline, PlacedNode, Size,
};
use crate::preview::mermaid::flowchart::{Shape, Stroke};
use crate::preview::mermaid::layout::Point;
fn label(text: &str, w: f64) -> Label {
Label {
lines: text.split('\n').map(str::to_string).collect(),
width: w,
height: text.split('\n').count() as f64 * labels::line_height(),
}
}
fn blank() -> Label {
label("", 0.0)
}
fn drawn(d: &Diagram) -> String {
svg::emit(d, &theme::DARK)
.lines()
.filter(|l| {
!l.is_empty()
&& !l.starts_with("<svg")
&& !l.starts_with("</svg")
&& !l.starts_with("<rect w")
&& !l.starts_with("<g ")
&& !l.starts_with("</g")
})
.collect::<Vec<_>>()
.join("\n")
}
fn canvas() -> Diagram {
Diagram {
width: 400.0,
height: 300.0,
..Diagram::default()
}
}
fn a_class_panel() -> panel::Panel {
panel::class_panel(&[
panel::Compartment {
lines: vec![label("Shape", 40.0)],
centered: true,
},
panel::Compartment {
lines: vec![label("+int sides", 66.0)],
centered: false,
},
])
}
fn an_er_panel() -> panel::Panel {
panel::er_panel(
&label("PERSON", 55.0),
&[panel::AttributeRow {
kind: label("string", 40.0),
name: label("name", 34.0),
keys: blank(),
comment: blank(),
}],
)
}
fn an_actor_panel() -> panel::Panel {
let name = label("Alice", 36.0);
let w = (name.width + shapes::PARTICIPANT_PAD_X * 2.0).max(shapes::PARTICIPANT_MIN_WIDTH);
let text_band = name.height + shapes::PARTICIPANT_PAD_Y;
panel::Panel {
rows: vec![panel::PanelRow {
cells: vec![panel::PanelCell {
label: name,
x: w / 2.0,
centered: true,
}],
y: shapes::ACTOR_FIGURE_HEIGHT + text_band / 2.0,
}],
rules: Vec::new(),
columns: Vec::new(),
size: Size::new(w, shapes::ACTOR_FIGURE_HEIGHT + text_band),
}
}
fn glyph_document(glyph: Glyph) -> String {
let (size, panel, lab) = match glyph {
Glyph::Wedge | Glyph::Graticule => (Size::new(120.0, 120.0), None, blank()),
Glyph::Ribbon | Glyph::ChartBar | Glyph::PlotFrame => {
(Size::new(120.0, 60.0), None, blank())
}
Glyph::ChartPoint => (Size::new(7.0, 7.0), None, blank()),
Glyph::ChartLabel => {
let l = label("Node", 40.0);
(Size::new(l.width, l.height), None, l)
}
Glyph::ClassBox | Glyph::ErBox => {
let p = a_class_panel();
(p.size, Some(p), blank())
}
Glyph::Actor => {
let p = an_actor_panel();
(shapes::size(Glyph::Actor, p.size), Some(p), blank())
}
Glyph::TitledBox => {
let l = label("Name\ndescription", 90.0);
(shapes::size(glyph, Size::new(l.width, l.height)), None, l)
}
_ => {
let l = label("Node", 40.0);
(shapes::size(glyph, Size::new(l.width, l.height)), None, l)
}
};
let mut d = canvas();
d.nodes.push(PlacedNode {
id: "n".to_string(),
shape: glyph,
center: Point::new(200.0, 150.0),
size,
label: lab,
panel,
series: chart_series(glyph),
mark: chart_mark(glyph),
});
drawn(&d)
}
fn chart_series(glyph: Glyph) -> Option<usize> {
match glyph {
Glyph::Wedge | Glyph::ChartBar | Glyph::ChartPoint | Glyph::Ribbon | Glyph::ChartLabel => {
Some(0)
}
_ => None,
}
}
fn chart_mark(glyph: Glyph) -> Option<shapes::Mark> {
match glyph {
Glyph::Wedge => Some(shapes::Mark::Wedge {
start: 30.0,
sweep: 100.0,
}),
Glyph::Ribbon => Some(shapes::Mark::Ribbon {
left_top: -20.0,
left_bottom: 4.0,
right_top: -6.0,
right_bottom: 20.0,
}),
Glyph::Graticule => Some(shapes::Mark::Graticule {
rings: 3,
spokes: 5,
polygon: false,
}),
Glyph::Face => Some(shapes::Mark::Face { score: 4.0 }),
Glyph::BlockArrow => Some(shapes::Mark::BlockArrow {
left: false,
right: true,
up: false,
down: false,
}),
_ => None,
}
}
#[allow(clippy::type_complexity)]
fn glyph_table() -> Vec<(&'static str, Glyph, &'static str, &'static str)> {
vec![
(
"rect",
Glyph::Flow(Shape::Rect),
"square rectangle",
"square rectangle",
),
(
"rounded",
Glyph::Flow(Shape::RoundedRect),
"rounded rectangle",
"rounded rectangle",
),
("stadium", Glyph::Flow(Shape::Stadium), "stadium", "stadium"),
("circle", Glyph::Flow(Shape::Circle), "circle", "circle"),
(
"double-circle",
Glyph::Flow(Shape::DoubleCircle),
"two rings",
"two rings",
),
("diamond", Glyph::Flow(Shape::Diamond), "diamond", "diamond"),
("hexagon", Glyph::Flow(Shape::Hexagon), "hexagon", "hexagon"),
(
"subroutine",
Glyph::Flow(Shape::Subroutine),
"framed rectangle",
"framed rectangle",
),
(
"cylinder",
Glyph::Flow(Shape::Cylinder),
"cylinder",
"cylinder",
),
(
"trapezoid",
Glyph::Flow(Shape::Trapezoid),
"trapezoid",
"trapezoid",
),
(
"inv-trapezoid",
Glyph::Flow(Shape::InvTrapezoid),
"inverted trapezoid",
"inverted trapezoid",
),
(
"lean-right",
Glyph::Flow(Shape::LeanRight),
"lean right",
"lean right",
),
(
"lean-left",
Glyph::Flow(Shape::LeanLeft),
"lean left",
"lean left",
),
(
"odd",
Glyph::Flow(Shape::Odd),
"notched rectangle",
"notched rectangle",
),
("text", Glyph::Flow(Shape::Text), "bare words", "nothing"),
(
"state-start",
Glyph::StateStart,
"filled dot",
"disc of half the width",
),
(
"state-end",
Glyph::StateEnd,
"ring around a dot",
"ring around a dot",
),
("choice", Glyph::Choice, "empty diamond", "diamond"),
(
"fork-h",
Glyph::Bar { horizontal: true },
"wide bar",
"solid bar",
),
(
"fork-v",
Glyph::Bar { horizontal: false },
"tall bar",
"solid bar",
),
("note", Glyph::Note, "folded corner", "folded corner"),
(
"titled-box",
Glyph::TitledBox,
"box with a rule",
"rounded rectangle",
),
(
"class-box",
Glyph::ClassBox,
"box with a table",
"square rectangle",
),
(
"er-box",
Glyph::ErBox,
"box with a table",
"square rectangle",
),
(
"participant",
Glyph::Participant,
"wide rounded box",
"rounded rectangle",
),
("actor", Glyph::Actor, "stick figure", "stick figure"),
("wedge", Glyph::Wedge, "pie slice", "pie slice"),
(
"chart-bar",
Glyph::ChartBar,
"filled rectangle",
"square rectangle",
),
(
"chart-point",
Glyph::ChartPoint,
"small disc",
"disc inscribed in the box",
),
("ribbon", Glyph::Ribbon, "flow band", "flow band"),
(
"chart-label",
Glyph::ChartLabel,
"words on a fill",
"nothing",
),
(
"plot-frame",
Glyph::PlotFrame,
"empty frame",
"square rectangle",
),
(
"graticule",
Glyph::Graticule,
"rings and spokes",
"rings and spokes",
),
("cloud", Glyph::Cloud, "bumpy blob", "bumpy blob"),
("bang", Glyph::Bang, "spiky burst", "spiky burst"),
("underline", Glyph::Underline, "words on a rule", "a rule"),
("face", Glyph::Face, "mood face", "mood face"),
(
"block-arrow",
Glyph::BlockArrow,
"arrow block",
"arrow block",
),
("reverted", Glyph::Reverted, "crossed dot", "crossed dot"),
]
}
#[test]
fn every_glyph_draws_a_mark_that_tells_it_from_its_siblings() {
fn covered(g: Glyph) -> bool {
match g {
Glyph::Flow(shape) => match shape {
Shape::Rect
| Shape::RoundedRect
| Shape::Stadium
| Shape::Circle
| Shape::DoubleCircle
| Shape::Diamond
| Shape::Hexagon
| Shape::Subroutine
| Shape::Cylinder
| Shape::Trapezoid
| Shape::InvTrapezoid
| Shape::LeanRight
| Shape::LeanLeft
| Shape::Odd
| Shape::Text => true,
},
Glyph::StateStart
| Glyph::StateEnd
| Glyph::Choice
| Glyph::Bar { .. }
| Glyph::Note
| Glyph::TitledBox
| Glyph::ClassBox
| Glyph::ErBox
| Glyph::Participant
| Glyph::Actor
| Glyph::Wedge
| Glyph::ChartBar
| Glyph::ChartPoint
| Glyph::Ribbon
| Glyph::ChartLabel
| Glyph::PlotFrame
| Glyph::Graticule
| Glyph::Cloud
| Glyph::Bang
| Glyph::Underline
| Glyph::Face
| Glyph::BlockArrow
| Glyph::Reverted => true,
}
}
let table = glyph_table();
let listed: HashSet<Glyph> = table.iter().map(|(_, g, _, _)| *g).collect();
assert_eq!(listed.len(), table.len(), "a glyph is listed twice");
for g in &listed {
assert!(covered(*g));
}
assert!(listed.contains(&Glyph::Bar { horizontal: true }));
assert!(listed.contains(&Glyph::Bar { horizontal: false }));
let probe = Size::new(120.0, 48.0);
partition(
"outline",
table.iter().map(|(name, g, _, outline)| {
(
*name,
*outline,
format!("{:?}", shapes::outline(*g, probe, chart_mark(*g))),
)
}),
);
partition(
"drawing",
table.iter().map(|(name, g, appearance, _)| {
let doc = glyph_document(*g);
assert!(
!doc.trim().is_empty(),
"{name}: drew nothing at all — a glyph that emits no markup is invisible"
);
(*name, *appearance, doc)
}),
);
}
fn partition<'a>(what: &str, cases: impl Iterator<Item = (&'a str, &'a str, String)>) {
let mut by_print: BTreeMap<String, Vec<(&str, &str)>> = BTreeMap::new();
let mut declared: HashSet<&str> = HashSet::new();
let mut n = 0usize;
for (name, class, print) in cases {
declared.insert(class);
n += 1;
by_print.entry(print).or_default().push((name, class));
}
for (print, members) in &by_print {
let classes: HashSet<&str> = members.iter().map(|(_, c)| *c).collect();
assert_eq!(
classes.len(),
1,
"{what}: these are identical but are declared different: {members:?}\n\
--- what they share ---\n{print}"
);
}
assert_eq!(
by_print.len(),
declared.len(),
"{what}: {} distinct results across {n} glyphs, but {} classes are declared — one class \
is coming out two ways, or two classes have merged",
by_print.len(),
declared.len()
);
}
#[test]
fn a_chart_glyph_with_no_mark_draws_nothing_rather_than_a_default() {
for glyph in [Glyph::Wedge, Glyph::Ribbon, Glyph::Graticule] {
assert!(
matches!(
shapes::outline(glyph, Size::new(100.0, 100.0), None),
super::shapes::Outline::None
),
"{glyph:?} invented geometry it was not given"
);
assert!(
!matches!(
shapes::outline(glyph, Size::new(100.0, 100.0), chart_mark(glyph)),
super::shapes::Outline::None
),
"{glyph:?} draws nothing even with its mark"
);
}
}
fn tip_document(tip: Tip) -> String {
let mut d = canvas();
d.edges.push(PlacedEdge {
from: "a".to_string(),
to: "b".to_string(),
points: vec![Point::new(40.0, 150.0), Point::new(360.0, 150.0)],
tip_start: Tip::None,
tip_end: tip,
stroke: Stroke::Normal,
label: None,
start_label: None,
end_label: None,
badge: None,
series: None,
straight: false,
});
drawn(&d)
}
#[test]
fn every_line_end_draws_a_mark_that_tells_it_from_its_siblings() {
fn covered(t: Tip) -> bool {
match t {
Tip::None
| Tip::Arrow
| Tip::Cross
| Tip::Circle
| Tip::HollowTriangle
| Tip::FilledDiamond
| Tip::HollowDiamond
| Tip::Lollipop
| Tip::ErOnlyOne
| Tip::ErZeroOrOne
| Tip::ErOneOrMore
| Tip::ErZeroOrMore
| Tip::Async => true,
}
}
let table = [
Tip::None,
Tip::Arrow,
Tip::Cross,
Tip::Circle,
Tip::HollowTriangle,
Tip::FilledDiamond,
Tip::HollowDiamond,
Tip::Lollipop,
Tip::ErOnlyOne,
Tip::ErZeroOrOne,
Tip::ErOneOrMore,
Tip::ErZeroOrMore,
Tip::Async,
];
let listed: HashSet<Tip> = table.iter().copied().collect();
assert_eq!(listed.len(), table.len(), "a terminator is listed twice");
for t in &listed {
assert!(covered(*t));
}
let bare = tip_document(Tip::None);
let mut by_mark: BTreeMap<String, Vec<Tip>> = BTreeMap::new();
for tip in table {
let doc = tip_document(tip);
let mark: String = doc
.lines()
.filter(|l| !bare.lines().any(|b| b == *l))
.collect::<Vec<_>>()
.join("\n");
if tip == Tip::None {
assert!(
mark.is_empty(),
"`---` is spelled with no mark on the end, so it must draw none: {mark}"
);
} else {
assert!(
!mark.is_empty(),
"{tip:?}: drew no mark of its own — the end is indistinguishable from `---`"
);
}
by_mark.entry(mark).or_default().push(tip);
}
for (mark, tips) in &by_mark {
assert_eq!(
tips.len(),
1,
"these terminators draw the same mark, so a reader cannot tell them apart: \
{tips:?}\n--- the mark ---\n{mark}"
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Decoration {
SectionRule,
SectionTitle,
ClusterTitle,
ClusterDash,
ClusterFill,
Badge,
Lifeline,
ActivationBar,
DestroyCross,
PanelRule,
PanelColumn,
EdgeLabelPatch,
SideLabel,
StrokeStyle,
}
fn decoration_document(decoration: Decoration, on: bool) -> String {
let mut d = canvas();
match decoration {
Decoration::SectionRule | Decoration::SectionTitle => {
let title = if decoration == Decoration::SectionTitle && on {
label("is well", 44.0)
} else {
blank()
};
d.clusters.push(PlacedCluster {
id: "alt".to_string(),
title: blank(),
center: Point::new(200.0, 150.0),
size: Size::new(240.0, 160.0),
parent: None,
depth: 0,
dashed: true,
filled: false,
sections: if decoration == Decoration::SectionTitle || on {
vec![ClusterSection { y: 150.0, title }]
} else {
Vec::new()
},
});
}
Decoration::ClusterTitle | Decoration::ClusterDash | Decoration::ClusterFill => {
d.clusters.push(PlacedCluster {
id: "g".to_string(),
title: if decoration == Decoration::ClusterTitle && on {
label("Block", 44.0)
} else {
blank()
},
center: Point::new(200.0, 150.0),
size: Size::new(240.0, 160.0),
parent: None,
depth: 0,
dashed: decoration == Decoration::ClusterDash && on,
filled: decoration == Decoration::ClusterFill && on,
sections: Vec::new(),
});
}
Decoration::Badge
| Decoration::EdgeLabelPatch
| Decoration::SideLabel
| Decoration::StrokeStyle => {
let text = label("2", 12.0);
d.edges.push(PlacedEdge {
from: "a".to_string(),
to: "b".to_string(),
points: vec![Point::new(40.0, 150.0), Point::new(360.0, 150.0)],
tip_start: Tip::None,
tip_end: Tip::Arrow,
stroke: if decoration == Decoration::StrokeStyle && on {
Stroke::Dotted
} else {
Stroke::Normal
},
label: (decoration == Decoration::EdgeLabelPatch && on).then(|| PlacedEdgeLabel {
center: Point::new(200.0, 150.0),
size: Size::new(40.0, 18.0),
label: label("yes", 30.0),
}),
start_label: (decoration == Decoration::SideLabel && on).then(|| PlacedEdgeLabel {
center: Point::new(80.0, 130.0),
size: Size::new(24.0, 18.0),
label: label("1", 10.0),
}),
end_label: None,
badge: (decoration == Decoration::Badge && on).then(|| PlacedEdgeLabel {
center: Point::new(60.0, 150.0),
size: Size::new(22.0, 22.0),
label: text,
}),
series: None,
straight: false,
});
}
Decoration::Lifeline | Decoration::ActivationBar | Decoration::DestroyCross => {
if decoration == Decoration::Lifeline && !on {
} else {
d.lifelines.push(PlacedLifeline {
id: "A".to_string(),
x: 200.0,
top: 40.0,
bottom: 260.0,
destroyed: decoration == Decoration::DestroyCross && on,
activations: if decoration == Decoration::ActivationBar && on {
vec![PlacedActivation {
depth: 0,
top: 90.0,
bottom: 200.0,
}]
} else {
Vec::new()
},
});
}
}
Decoration::PanelRule | Decoration::PanelColumn => {
let mut p = if decoration == Decoration::PanelColumn {
an_er_panel()
} else {
a_class_panel()
};
if !on {
if decoration == Decoration::PanelRule {
p.rules.clear();
} else {
p.columns.clear();
}
}
d.nodes.push(PlacedNode {
id: "n".to_string(),
shape: if decoration == Decoration::PanelColumn {
Glyph::ErBox
} else {
Glyph::ClassBox
},
center: Point::new(200.0, 150.0),
size: p.size,
label: blank(),
panel: Some(p),
series: None,
mark: None,
});
}
}
drawn(&d)
}
#[test]
fn every_decoration_leaves_a_mark_of_its_own_on_the_page() {
fn covered(d: Decoration) -> bool {
match d {
Decoration::SectionRule
| Decoration::SectionTitle
| Decoration::ClusterTitle
| Decoration::ClusterDash
| Decoration::ClusterFill
| Decoration::Badge
| Decoration::Lifeline
| Decoration::ActivationBar
| Decoration::DestroyCross
| Decoration::PanelRule
| Decoration::PanelColumn
| Decoration::EdgeLabelPatch
| Decoration::SideLabel
| Decoration::StrokeStyle => true,
}
}
let table = [
Decoration::SectionRule,
Decoration::SectionTitle,
Decoration::ClusterTitle,
Decoration::ClusterDash,
Decoration::ClusterFill,
Decoration::Badge,
Decoration::Lifeline,
Decoration::ActivationBar,
Decoration::DestroyCross,
Decoration::PanelRule,
Decoration::PanelColumn,
Decoration::EdgeLabelPatch,
Decoration::SideLabel,
Decoration::StrokeStyle,
];
let listed: HashSet<Decoration> = table.iter().copied().collect();
assert_eq!(listed.len(), table.len(), "a decoration is listed twice");
for d in &listed {
assert!(covered(*d));
}
let mut by_change: BTreeMap<String, Vec<Decoration>> = BTreeMap::new();
for decoration in table {
let with = decoration_document(decoration, true);
let without = decoration_document(decoration, false);
assert_ne!(
with, without,
"{decoration:?}: switching it on changed nothing on the page — whatever it means, the \
reader cannot see it"
);
let added: String = with
.lines()
.filter(|l| !without.lines().any(|b| b == *l))
.collect::<Vec<_>>()
.join("\n");
assert!(
!added.is_empty(),
"{decoration:?}: switching it on removed markup instead of adding any"
);
by_change.entry(added).or_default().push(decoration);
}
for (added, decorations) in &by_change {
assert_eq!(
decorations.len(),
1,
"these decorations put the same marks on the page, so they cannot be told apart: \
{decorations:?}\n--- the marks ---\n{added}"
);
}
}