mod activations;
mod frames;
mod messages;
mod notes;
use crate::error::Error;
use crate::layout::prim;
use crate::layout::{Bbox, PlacedNode, RoutedLink};
use crate::resolve::{AttrMap, NodeKind, Program, ResolvedInst, ResolvedLink, ResolvedValue};
use crate::span::Span;
use std::collections::HashMap;
const NON_PARTICIPANT: &[&str] = &["loop", "opt", "alt", "else", "note"];
pub(super) fn is_sequence(attrs: &AttrMap) -> bool {
matches!(attrs.get("layout"), Some(ResolvedValue::Ident(s)) if s == "sequence")
}
pub(super) fn layout_node(
inst: &ResolvedInst,
path: &str,
program: &Program,
) -> Result<PlacedNode, Error> {
let mut participants = Vec::new();
let mut notes = Vec::new();
for c in &inst.children {
let placed =
|| super::layout_inst(c, &super::child_path(path, c), program, super::Ctx::sheet());
if is_participant(&c.kind, &c.type_chain) {
participants.push(placed()?);
} else if is_note(&c.type_chain) {
notes.push(placed()?);
}
}
let messages = if inst.id.is_some() {
messages_for(program, path)
} else {
Vec::new()
};
let (children, bbox, wires) = lay_out(
&inst.attrs,
participants,
notes,
&messages,
&inst.children,
inst.span,
)?;
let mut node = prim::container(inst, bbox, children);
node.links = wires;
Ok(node)
}
pub(super) fn layout_root(
scene_nodes: &mut Vec<PlacedNode>,
program: &Program,
) -> Result<(Bbox, Vec<RoutedLink>), Error> {
let mut participants = Vec::new();
let mut notes = Vec::new();
for p in std::mem::take(scene_nodes) {
if is_participant(&p.kind, &p.type_chain) {
participants.push(p);
} else if is_note(&p.type_chain) {
notes.push(p);
}
}
let messages = messages_for(program, "");
let (children, bbox, wires) = lay_out(
&program.scene.attrs,
participants,
notes,
&messages,
&program.scene.nodes,
Span::empty(),
)?;
*scene_nodes = children;
Ok((bbox, wires))
}
pub(crate) fn is_sequence_scope(program: &Program, scope: &str) -> bool {
super::scope_attrs(program, scope).is_some_and(is_sequence)
}
fn messages_for<'a>(program: &'a Program, scope: &str) -> Vec<&'a ResolvedLink> {
let mut msgs: Vec<&ResolvedLink> = program.links.iter().filter(|w| w.scope == scope).collect();
msgs.sort_by_key(|w| w.span.start);
msgs
}
fn lay_out(
attrs: &AttrMap,
mut participants: Vec<PlacedNode>,
notes: Vec<PlacedNode>,
messages: &[&ResolvedLink],
frame_src: &[ResolvedInst],
span: Span,
) -> Result<(Vec<PlacedNode>, Bbox, Vec<RoutedLink>), Error> {
if participants.is_empty() {
return Err(Error::at(span, "a sequence needs at least one participant"));
}
let (gap_row, gap_col) = super::primitives::gap(attrs, span)?;
let pairs = messages::pairs(messages);
let seq_frames = frames::collect(frame_src);
let widths: Vec<f64> = participants.iter().map(|p| p.bbox.w()).collect();
let ids: Vec<&str> = participants
.iter()
.map(|p| p.id.as_deref().unwrap_or(""))
.collect();
let centres = messages::columns(&widths, &ids, &pairs, gap_col);
let note_rows: Vec<(usize, f64)> = notes.iter().map(|n| (n.span.start, n.bbox.h())).collect();
let mut timeline = frames::timeline(&pairs, &seq_frames, ¬e_rows, gap_row);
let header_h = participants
.iter()
.map(|p| p.bbox.h())
.fold(0.0_f64, f64::max);
let total_h = header_h + timeline.foot_y;
let top = -total_h / 2.0;
let header_bottom = top + header_h;
timeline.shift(header_bottom);
let foot_y = timeline.foot_y;
let msg_y = &timeline.msg_y;
let row_y = |i: usize| if i < msg_y.len() { msg_y[i] } else { foot_y };
let mut lifelines = Vec::with_capacity(participants.len());
let mut lifeline_x: HashMap<String, f64> = HashMap::new();
let mut paint: HashMap<String, Apparatus> = HashMap::new();
for (p, &cx) in participants.iter_mut().zip(¢res) {
p.cx = cx;
p.cy = top + p.bbox.h() / 2.0;
let head_bottom = p.cy + p.bbox.h() / 2.0;
let a = Apparatus::of(&p.attrs);
lifelines.push(prim::line(
vec![(cx, head_bottom), (cx, foot_y)],
a.stroke.clone(),
a.width,
));
if let Some(id) = p.id.as_deref() {
lifeline_x.insert(id.to_string(), cx);
paint.insert(id.to_string(), a);
}
}
let bars = if activations_on(attrs) {
activations::bars(&pairs)
} else {
Vec::new()
};
let endpoint_x = |id: &str, row: usize, toward: f64| {
let cx = lifeline_x.get(id).copied().unwrap_or(0.0);
activations::edge(&bars, id, row, cx, toward).unwrap_or(cx)
};
let wires = messages::draw(&pairs, &lifeline_x, endpoint_x, row_y);
let bar_nodes = activations::draw(&bars, &lifeline_x, row_y, &paint);
let (frames_behind, frames_front) =
frames::draw(&seq_frames, &timeline.geom, &pairs, &lifeline_x);
let placed_notes = place_notes(notes, &timeline.note_y, &lifeline_x);
let mut children = frames_behind;
children.extend(lifelines);
children.extend(bar_nodes);
children.extend(participants);
children.extend(frames_front);
children.extend(placed_notes);
let bbox = enclosing_bbox(&children, &wires);
Ok((children, bbox, wires))
}
fn enclosing_bbox(children: &[PlacedNode], wires: &[RoutedLink]) -> Bbox {
let mut ext = Bbox::empty();
for c in children {
ext = ext.union(c.bbox.shifted(c.cx, c.cy));
}
for w in wires {
for &(x, y) in &w.path {
ext = ext.union(Bbox {
min_x: x,
min_y: y,
max_x: x,
max_y: y,
});
}
for t in &w.texts {
let size = t.attrs.number("font-size").unwrap_or(0.0);
let (hw, hh) = (
prim::text_width(&t.content, size) / 2.0,
crate::layout::approx_height(&t.content, size, 0.0) / 2.0,
);
ext = ext.union(Bbox {
min_x: t.position.0 - hw,
min_y: t.position.1 - hh,
max_x: t.position.0 + hw,
max_y: t.position.1 + hh,
});
}
}
let w = 2.0 * ext.min_x.abs().max(ext.max_x.abs());
let h = 2.0 * ext.min_y.abs().max(ext.max_y.abs());
Bbox::centered(w.max(1.0), h.max(1.0))
}
pub(super) struct Apparatus {
pub fill: ResolvedValue,
pub stroke: ResolvedValue,
pub width: f64,
}
impl Apparatus {
fn of(attrs: &AttrMap) -> Self {
Self {
fill: attrs.get("fill").cloned().unwrap_or_else(|| live("fill")),
stroke: attrs
.get("stroke")
.cloned()
.unwrap_or_else(|| live("stroke")),
width: attrs.number("stroke-width").unwrap_or(2.0),
}
}
}
fn activations_on(attrs: &AttrMap) -> bool {
!matches!(attrs.get("activation"), Some(ResolvedValue::Ident(s)) if s == "none")
}
pub(super) fn live(name: &str) -> ResolvedValue {
ResolvedValue::LiveVar {
name: name.to_string(),
raw: false,
}
}
fn is_participant(kind: &NodeKind, type_chain: &[String]) -> bool {
*kind != NodeKind::Text
&& !type_chain
.iter()
.any(|t| NON_PARTICIPANT.contains(&t.as_str()))
}
fn is_note(type_chain: &[String]) -> bool {
type_chain.iter().any(|t| t == "note")
}
const SEQ_PROPS: &[&str] = &["over", "left", "right", "activation"];
pub(crate) fn validate(program: &Program) -> Result<(), Error> {
let in_seq = is_sequence(&program.scene.attrs);
for n in &program.scene.nodes {
check_node(n, in_seq, false)?;
}
Ok(())
}
fn check_node(inst: &ResolvedInst, in_seq: bool, in_alt: bool) -> Result<(), Error> {
let is = |t: &str| inst.type_chain.iter().any(|x| x == t);
let seq_ctx = in_seq || is_sequence(&inst.attrs);
for ty in ["loop", "opt", "alt"] {
if is(ty) && !in_seq {
return Err(Error::at(
inst.span,
format!("'|{ty}|' belongs in a 'layout: sequence'"),
));
}
}
if is("else") && !in_alt {
return Err(Error::at(
inst.span,
"'|else|' separates an '|alt|' — write it inside one",
));
}
if in_seq && is("note") && notes::placement(&inst.attrs).is_none() {
return Err(Error::at(
inst.span,
"a sequence '|note|' needs 'over:', 'left:', or 'right:'",
));
}
if !seq_ctx {
for p in SEQ_PROPS {
if inst.attrs.get(p).is_some() {
return Err(Error::at(
inst.span,
format!("'{p}' is valid only in a 'layout: sequence'"),
));
}
}
}
let child_in_seq =
is_sequence(&inst.attrs) || (in_seq && (is("loop") || is("opt") || is("alt")));
for c in &inst.children {
check_node(c, child_in_seq, is("alt"))?;
}
Ok(())
}
fn place_notes(
notes: Vec<PlacedNode>,
note_y: &[f64],
lifeline_x: &HashMap<String, f64>,
) -> Vec<PlacedNode> {
notes
.into_iter()
.zip(note_y)
.filter_map(|(mut n, &y)| {
let placement = notes::placement(&n.attrs)?;
n.cx = notes::centre_x(&placement, n.bbox.w(), lifeline_x)?;
n.cy = y;
if let Ok(Some((dx, dy))) = super::anchors::translate(&n.attrs, n.span) {
n.cx += dx;
n.cy += dy;
}
Some(n)
})
.collect()
}
#[cfg(test)]
mod tests {
fn svg(src: &str) -> String {
crate::compile_str(src).expect("compile")
}
fn layout_err(src: &str) -> String {
let toks = crate::lexer::lex(src).expect("lex");
let file = crate::syntax::parser::parse(src, &toks).expect("parse");
let lowered = crate::desugar::desugar(&file).expect("desugar");
let program = crate::resolve::resolve_with_theme(&lowered, &[]).expect("resolve");
crate::layout::layout(&program)
.err()
.expect("expected a layout error")
.to_string()
}
#[test]
fn root_sequence_draws_participant_headers_and_lifelines() {
let s = svg("{ layout: sequence }\n|box#user| \"User\"\n|cyl#db| \"Store\"\n");
assert!(s.contains(">User</text>"), "participant header: {s}");
assert!(s.contains(">Store</text>"), "participant header: {s}");
assert!(s.contains("lini-line"), "a lifeline per participant: {s}");
}
#[test]
fn node_sequence_is_a_container_with_lifelines() {
let s = svg("|sequence#s| [\n |box#a| \"A\"\n |box#b| \"B\"\n]\n");
assert!(
s.contains("lini-sequence"),
"the sequence container class: {s}"
);
assert!(
s.contains(">A</text>") && s.contains(">B</text>"),
"headers: {s}"
);
assert!(s.contains("lini-line"), "lifelines: {s}");
}
#[test]
fn participants_sit_in_a_row_left_to_right() {
let src = "|sequence#s| [\n |box#a| \"A\"\n |box#b| \"B\"\n]\n";
let toks = crate::lexer::lex(src).expect("lex");
let file = crate::syntax::parser::parse(src, &toks).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");
let seq = &laid.nodes[0];
let xs: Vec<f64> = seq
.children
.iter()
.filter(|c| c.id.as_deref() == Some("a") || c.id.as_deref() == Some("b"))
.map(|c| c.cx)
.collect();
assert_eq!(xs.len(), 2, "two participants placed");
assert!(xs[0] < xs[1], "a left of b: {xs:?}");
}
#[test]
fn an_empty_sequence_errors() {
assert!(layout_err("|sequence#s|\n").contains("at least one participant"));
}
#[test]
fn a_call_renders_as_a_straight_time_row_wire() {
let s = svg("{ layout: sequence }\n|box#a| \"A\"\n|box#b| \"B\"\na -> b \"hi\"\n");
assert!(s.contains(">hi</text>"), "the message label: {s}");
assert!(s.contains("lini-marker"), "an arrowhead: {s}");
assert!(
s.contains(r#"data-from="a" data-to="b""#),
"the message is a drawn link: {s}"
);
}
#[test]
fn a_return_message_is_dashed() {
let s = svg("{ layout: sequence }\n|box#a| \"A\"\n|box#b| \"B\"\nb --> a \"ok\"\n");
assert!(
s.contains("stroke-dasharray: 6,4.5"),
"the return is dashed: {s}"
);
}
#[test]
fn an_async_message_is_wavy() {
let s = svg("{ layout: sequence }\n|box#a| \"A\"\n|box#b| \"B\"\na ~> b \"event\"\n");
assert!(
s.contains("<path d=\"M"),
"the async message is a wavy path: {s}"
);
}
#[test]
fn a_self_message_draws_a_hook() {
let s = svg("{ layout: sequence }\n|box#a| \"A\"\na -> a \"retry\"\n");
assert!(
s.contains(" A "),
"the self-message hook bends through an arc: {s}"
);
assert!(
s.contains("lini-marker-arrow"),
"the hook returns with an arrowhead: {s}"
);
assert!(s.contains(">retry</text>"), "its label: {s}");
}
fn bar_count(src: &str) -> usize {
let toks = crate::lexer::lex(src).expect("lex");
let file = crate::syntax::parser::parse(src, &toks).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");
laid.nodes[0]
.children
.iter()
.filter(|c| c.kind == crate::resolve::NodeKind::Block && c.id.is_none())
.count()
}
#[test]
fn a_call_opens_one_activation_bar() {
let n = bar_count(
"|sequence#s| [\n |box#a| \"A\"\n |box#b| \"B\"\n a -> b \"q\"\n b --> a \"r\"\n]\n",
);
assert_eq!(n, 1, "one activation bar");
}
#[test]
fn nested_calls_stack_two_bars() {
let n = bar_count(
"|sequence#s| [\n |box#a| \"A\"\n |box#b| \"B\"\n a -> b \"c1\"\n a -> b \"c2\"\n b --> a \"r2\"\n b --> a \"r1\"\n]\n",
);
assert_eq!(n, 2, "two stacked bars");
}
#[test]
fn self_and_async_open_no_bar() {
let n = bar_count(
"|sequence#s| [\n |box#a| \"A\"\n |box#b| \"B\"\n a -> a \"loop\"\n a ~> b \"event\"\n]\n",
);
assert_eq!(n, 0, "self and async open no activation");
}
#[test]
fn activation_none_draws_no_bars() {
let n = bar_count(
"|sequence#s| { activation: none } [\n |box#a| \"A\"\n |box#b| \"B\"\n a -> b \"q\"\n b --> a \"r\"\n]\n",
);
assert_eq!(n, 0, "activation: none suppresses bars");
}
#[test]
fn a_loop_frame_draws_its_tab_and_guard() {
let s = svg(
"{ layout: sequence }\n|box#a| \"A\"\n|box#b| \"B\"\n|loop| \"5x\" [\n a -> b \"poll\"\n]\n",
);
assert!(s.contains(">loop</text>"), "the operator tab: {s}");
assert!(s.contains(">[5x]</text>"), "the guard: {s}");
}
#[test]
fn an_alt_splits_into_guarded_compartments() {
let s = svg(
"{ layout: sequence }\n|box#a| \"A\"\n|box#b| \"B\"\n|alt| \"ok\" [\n a -> b \"x\"\n |else| \"no\"\n a -> b \"y\"\n]\n",
);
assert!(s.contains(">alt</text>"), "the alt tab: {s}");
assert!(
s.contains(">[ok]</text>"),
"the first compartment guard: {s}"
);
assert!(
s.contains(">[no]</text>"),
"the else compartment guard: {s}"
);
}
#[test]
fn frames_nest() {
let s = svg(
"{ layout: sequence }\n|box#a| \"A\"\n|box#b| \"B\"\n|loop| \"r\" [\n |opt| \"o\" [\n a -> b \"x\"\n ]\n]\n",
);
assert!(
s.contains(">loop</text>") && s.contains(">opt</text>"),
"both nested frame tabs render: {s}"
);
}
#[test]
fn a_note_renders_over_its_lifelines() {
let s = svg(
"{ layout: sequence }\n|box#a| \"A\"\n|box#b| \"B\"\n|note| \"spanning\" { over: a b }\na -> b \"x\"\n",
);
assert!(s.contains(">spanning</text>"), "the note text renders: {s}");
}
#[test]
fn a_frame_outside_a_sequence_errors() {
assert!(layout_err("|loop| [\n |box#a|\n]\n").contains("belongs in a 'layout: sequence'"));
}
#[test]
fn an_else_outside_an_alt_errors() {
assert!(
layout_err("{ layout: sequence }\n|box#a| \"A\"\n|else| \"x\"\n")
.contains("separates an '|alt|'")
);
}
#[test]
fn a_note_without_placement_errors() {
assert!(
layout_err("{ layout: sequence }\n|box#a| \"A\"\n|note| \"hi\"\n")
.contains("needs 'over:', 'left:', or 'right:'")
);
}
#[test]
fn a_sequence_property_off_a_sequence_errors() {
assert!(
layout_err("|box#a| { activation: none }\n")
.contains("valid only in a 'layout: sequence'")
);
}
}