Establish OA/SA prototype baseline and first-release work
This commit is contained in:
@@ -0,0 +1,761 @@
|
||||
//! Readable behavioral projections. Selection edits the same typed records as text.
|
||||
use crate::{
|
||||
app::{App, Choice, Message},
|
||||
style,
|
||||
views::{action, heading, label, short},
|
||||
};
|
||||
use iced::{
|
||||
Color, Element, Fill, Point, Rectangle, Renderer, Size, Theme, mouse,
|
||||
widget::{
|
||||
Action, button,
|
||||
canvas::{self, Frame, Path, Stroke, Text},
|
||||
column, container, pick_list, row, scrollable, text,
|
||||
},
|
||||
};
|
||||
use oa_core::*;
|
||||
use std::collections::BTreeMap;
|
||||
mod scenario;
|
||||
mod state_scene;
|
||||
mod states;
|
||||
mod topology;
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Topology(topology::Event),
|
||||
Scenario(scenario::Event),
|
||||
States(states::Event),
|
||||
Submit,
|
||||
}
|
||||
impl Event {
|
||||
pub fn is_submit(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Submit
|
||||
| Self::Topology(topology::Event::Submit)
|
||||
| Self::Scenario(scenario::Event::Submit)
|
||||
| Self::States(states::Event::Submit)
|
||||
)
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
topology: topology::State,
|
||||
scenario: scenario::State,
|
||||
states: states::State,
|
||||
}
|
||||
impl State {
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
match event {
|
||||
Event::Topology(event) => self.topology.update(p, event),
|
||||
Event::Scenario(event) => self.scenario.update(p, event),
|
||||
Event::States(event) => self.states.update(p, event),
|
||||
Event::Submit => self.topology.update(p, topology::Event::Submit),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
let p = app.project();
|
||||
let choices = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine)
|
||||
})
|
||||
.map(|e| Choice {
|
||||
id: e.id,
|
||||
label: format!("{} · {}", e.kind(), e.name),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let owner = selected_behavior(p, app.selected);
|
||||
let selected = owner
|
||||
.and_then(|id| choices.iter().find(|c| c.id == id))
|
||||
.or(choices.first())
|
||||
.cloned();
|
||||
let mut out = column![heading("Behavior & scenarios", "Follow exchanges and operating conditions. Select a message, state or transition to edit it.")].spacing(14);
|
||||
let Some(selected) = selected else {
|
||||
return out.push(label("Start with a Process, Scenario or State machine using Create element. Add occurrences or regions inside it to describe behavior.")).padding(24).into();
|
||||
};
|
||||
let e = &p.elements[&selected.id];
|
||||
out = out.push(
|
||||
pick_list(choices, Some(selected), |c| Message::Select(c.id))
|
||||
.width(Fill)
|
||||
.text_size(13)
|
||||
.padding(10),
|
||||
);
|
||||
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
return topology::view(app, e.id, out);
|
||||
}
|
||||
if e.kind() == Kind::Scenario {
|
||||
return scenario::view(app, e.id, out);
|
||||
}
|
||||
states::view(app, e.id, out)
|
||||
}
|
||||
/// Mirror the core's legacy-versus-explicit process interpretation. Existing
|
||||
/// exchange-only lists still describe ordered paths until explicitly migrated.
|
||||
fn uses_explicit_topology(p: &Project, owner: Id) -> bool {
|
||||
let Some(e) = p.elements.get(&owner) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
return false;
|
||||
}
|
||||
p.relations.values().any(|r| {
|
||||
r.kind() == RelationKind::SequenceLink
|
||||
&& p.elements.get(&r.source()).and_then(|e| e.parent) == Some(owner)
|
||||
&& p.elements.get(&r.target()).and_then(|e| e.parent) == Some(owner)
|
||||
}) || e.steps().into_iter().flatten().any(|id| {
|
||||
matches!(
|
||||
p.elements.get(id).and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action { .. }
|
||||
| OccurrenceBody::Control { .. }
|
||||
| OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
fn selected_behavior(p: &Project, selected: Id) -> Option<Id> {
|
||||
let mut current = Some(
|
||||
p.relations
|
||||
.get(&selected)
|
||||
.filter(|r| {
|
||||
matches!(
|
||||
r.kind(),
|
||||
RelationKind::StateTransition | RelationKind::SequenceLink
|
||||
)
|
||||
})
|
||||
.map(|r| r.source())
|
||||
.unwrap_or(selected),
|
||||
);
|
||||
while let Some(id) = current {
|
||||
let e = p.elements.get(&id)?;
|
||||
if e.kind().is_sequence() || e.kind() == Kind::StateMachine {
|
||||
return Some(id);
|
||||
}
|
||||
current = e.parent;
|
||||
}
|
||||
None
|
||||
}
|
||||
fn in_machine(p: &Project, id: Id, machine: Id) -> bool {
|
||||
let mut current = Some(id);
|
||||
while let Some(id) = current {
|
||||
if id == machine {
|
||||
return true;
|
||||
}
|
||||
current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
struct BoxItem {
|
||||
id: Id,
|
||||
rect: Rectangle,
|
||||
title: String,
|
||||
detail: String,
|
||||
marker: Option<Kind>,
|
||||
}
|
||||
struct Line {
|
||||
id: Id,
|
||||
points: Vec<Point>,
|
||||
label: String,
|
||||
label_at: Point,
|
||||
dashed: bool,
|
||||
}
|
||||
struct Scene {
|
||||
boxes: Vec<BoxItem>,
|
||||
lines: Vec<Line>,
|
||||
size: Size,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
}
|
||||
impl Scene {
|
||||
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
|
||||
let mut scene = Self {
|
||||
scale: 1.,
|
||||
boxes: vec![],
|
||||
lines: vec![],
|
||||
size: Size::new(700., 200.),
|
||||
selected,
|
||||
};
|
||||
let mut lanes = p
|
||||
.children(owner)
|
||||
.filter(|e| e.kind() == Kind::Lifeline)
|
||||
.map(|e| {
|
||||
(
|
||||
e.id,
|
||||
e.name.clone(),
|
||||
match e.shared() {
|
||||
Some(SharedBody::Lifeline {
|
||||
represented: Some(id),
|
||||
}) => p.named(*id).to_owned(),
|
||||
_ => "Unassigned participant".into(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let scenario = p.elements[&owner].kind() == Kind::Scenario;
|
||||
if !scenario {
|
||||
fn endpoints(p: &Project, steps: &[Id], ids: &mut Vec<Id>) {
|
||||
for id in steps {
|
||||
if let Some(SharedBody::Occurrence { body, .. }) =
|
||||
p.elements.get(id).and_then(|e| e.shared())
|
||||
{
|
||||
match body {
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(id), ..
|
||||
} => {
|
||||
if let Some(r) = p.relations.get(id) {
|
||||
for endpoint in [r.source(), r.target()] {
|
||||
if !ids.contains(&endpoint) {
|
||||
ids.push(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OccurrenceBody::Fragment { branches, .. } => {
|
||||
endpoints(p, branches, ids)
|
||||
}
|
||||
OccurrenceBody::Branch { occurrences, .. } => {
|
||||
endpoints(p, occurrences, ids)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ids = vec![];
|
||||
endpoints(p, p.elements[&owner].steps().unwrap_or(&vec![]), &mut ids);
|
||||
lanes = ids
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
(
|
||||
id,
|
||||
p.named(id).to_owned(),
|
||||
p.elements
|
||||
.get(&id)
|
||||
.map(|e| e.kind().label())
|
||||
.unwrap_or("Endpoint")
|
||||
.into(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let lane_x = lanes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.);
|
||||
let mut y = 120.;
|
||||
scene.occurrences(
|
||||
p,
|
||||
p.elements[&owner].steps().map(Vec::as_slice).unwrap_or(&[]),
|
||||
scenario,
|
||||
&lane_x,
|
||||
&mut y,
|
||||
0,
|
||||
);
|
||||
scene.size.height = (y + 45.).max(210.);
|
||||
for (id, name, detail) in lanes {
|
||||
let x = lane_x[&id];
|
||||
scene.lines.insert(
|
||||
0,
|
||||
Line {
|
||||
id,
|
||||
points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
|
||||
label: String::new(),
|
||||
label_at: Point::ORIGIN,
|
||||
dashed: false,
|
||||
},
|
||||
);
|
||||
scene.boxes.push(BoxItem {
|
||||
id,
|
||||
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)),
|
||||
title: name,
|
||||
detail,
|
||||
marker: None,
|
||||
});
|
||||
}
|
||||
scene
|
||||
}
|
||||
fn occurrences(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
steps: &[Id],
|
||||
scenario: bool,
|
||||
lanes: &BTreeMap<Id, f32>,
|
||||
y: &mut f32,
|
||||
depth: usize,
|
||||
) {
|
||||
for (index, id) in steps.iter().enumerate() {
|
||||
let Some(e) = p.elements.get(id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(SharedBody::Occurrence { body, .. }) = e.shared() else {
|
||||
continue;
|
||||
};
|
||||
match body {
|
||||
OccurrenceBody::Fragment { operator, branches } => {
|
||||
let top = *y;
|
||||
*y += 55.;
|
||||
self.occurrences(p, branches, scenario, lanes, y, depth + 1);
|
||||
let rect = Rectangle::new(
|
||||
Point::new(18. + depth as f32 * 10., top),
|
||||
Size::new(
|
||||
self.size.width - 36. - depth as f32 * 20.,
|
||||
(*y - top).max(95.),
|
||||
),
|
||||
);
|
||||
self.boxes.insert(
|
||||
0,
|
||||
BoxItem {
|
||||
id: *id,
|
||||
rect,
|
||||
title: format!("{operator:?} · {}", e.name),
|
||||
detail: String::new(),
|
||||
marker: Some(Kind::Occurrence),
|
||||
},
|
||||
);
|
||||
*y += 20.;
|
||||
}
|
||||
OccurrenceBody::Branch { guard, occurrences } => {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
rect: Rectangle::new(
|
||||
Point::new(36. + depth as f32 * 10., *y),
|
||||
Size::new(self.size.width - 85. - depth as f32 * 20., 43.),
|
||||
),
|
||||
title: format!(
|
||||
"[{}]",
|
||||
if guard.is_empty() {
|
||||
"guard not specified"
|
||||
} else {
|
||||
guard
|
||||
}
|
||||
),
|
||||
detail: String::new(),
|
||||
marker: Some(Kind::Occurrence),
|
||||
});
|
||||
*y += 62.;
|
||||
self.occurrences(p, occurrences, scenario, lanes, y, depth + 1);
|
||||
}
|
||||
OccurrenceBody::Exchange {
|
||||
exchange,
|
||||
sender,
|
||||
receiver,
|
||||
message_kind,
|
||||
..
|
||||
} => {
|
||||
let r = exchange.and_then(|id| p.relations.get(&id));
|
||||
let source = if scenario {
|
||||
*sender
|
||||
} else {
|
||||
r.map(|r| r.source())
|
||||
};
|
||||
let target = if scenario {
|
||||
if matches!(message_kind, MessageKind::Timer { .. }) {
|
||||
*sender
|
||||
} else {
|
||||
*receiver
|
||||
}
|
||||
} else {
|
||||
r.map(|r| r.target())
|
||||
};
|
||||
if let (Some(a), Some(b)) = (
|
||||
source.and_then(|id| lanes.get(&id)),
|
||||
target.and_then(|id| lanes.get(&id)),
|
||||
) {
|
||||
let points = if a == b {
|
||||
vec![
|
||||
Point::new(*a, *y),
|
||||
Point::new(a + 75., *y),
|
||||
Point::new(a + 75., *y + 24.),
|
||||
Point::new(*a, *y + 24.),
|
||||
]
|
||||
} else {
|
||||
vec![Point::new(*a, *y), Point::new(*b, *y)]
|
||||
};
|
||||
let kind = if !scenario {
|
||||
""
|
||||
} else {
|
||||
match message_kind {
|
||||
MessageKind::Asynchronous => "signal · ",
|
||||
MessageKind::Call => "call · ",
|
||||
MessageKind::Reply => "reply · ",
|
||||
MessageKind::Create => "create · ",
|
||||
MessageKind::Delete => "delete · ",
|
||||
MessageKind::Timer { .. } => "timer · ",
|
||||
}
|
||||
};
|
||||
let message_label = if let MessageKind::Timer { expression } = message_kind
|
||||
{
|
||||
expression.as_str()
|
||||
} else {
|
||||
r.map(|r| r.name.as_str()).unwrap_or(&e.name)
|
||||
};
|
||||
self.lines.push(Line {
|
||||
id: *id,
|
||||
points,
|
||||
label: format!("{}. {kind}{}", index + 1, message_label),
|
||||
label_at: Point::new(a.min(*b) + 12., *y - 26.),
|
||||
dashed: matches!(message_kind, MessageKind::Reply),
|
||||
});
|
||||
if scenario && matches!(message_kind, MessageKind::Call) {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
rect: Rectangle::new(Point::new(*b - 4., *y), Size::new(8., 80.)),
|
||||
title: String::new(),
|
||||
detail: String::new(),
|
||||
marker: None,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
rect: Rectangle::new(
|
||||
Point::new(65., *y - 20.),
|
||||
Size::new(self.size.width - 130., 65.),
|
||||
),
|
||||
title: e.name.clone(),
|
||||
detail: "Choose an exchange and its lifelines in Properties".into(),
|
||||
marker: None,
|
||||
});
|
||||
}
|
||||
*y += 92.;
|
||||
}
|
||||
OccurrenceBody::Action { action, lifeline }
|
||||
| OccurrenceBody::State {
|
||||
state: action,
|
||||
lifeline,
|
||||
} => {
|
||||
let x = lifeline
|
||||
.and_then(|id| lanes.get(&id))
|
||||
.copied()
|
||||
.unwrap_or(180.);
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
rect: Rectangle::new(Point::new(x - 110., *y - 20.), Size::new(220., 70.)),
|
||||
title: e.name.clone(),
|
||||
detail: action
|
||||
.map(|id| p.named(id).to_owned())
|
||||
.unwrap_or("Choose observed behavior".into()),
|
||||
marker: None,
|
||||
});
|
||||
*y += 95.;
|
||||
}
|
||||
OccurrenceBody::Control { kind } => {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(240., 70.)),
|
||||
title: format!("{kind:?} · {}", e.name),
|
||||
detail: "Contextual process control".into(),
|
||||
marker: None,
|
||||
});
|
||||
*y += 95.;
|
||||
}
|
||||
OccurrenceBody::SequenceReference { sequence } => {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(280., 70.)),
|
||||
title: e.name.clone(),
|
||||
detail: sequence
|
||||
.map(|id| format!("Use {}", p.named(id)))
|
||||
.unwrap_or("Choose a reusable process or chain".into()),
|
||||
marker: None,
|
||||
});
|
||||
*y += 95.;
|
||||
}
|
||||
OccurrenceBody::ScenarioUse { scenario, bindings } => {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
rect: Rectangle::new(
|
||||
Point::new(65., *y - 20.),
|
||||
Size::new(self.size.width - 130., 70.),
|
||||
),
|
||||
title: format!("ref · {}", e.name),
|
||||
detail: scenario
|
||||
.map(|id| {
|
||||
format!(
|
||||
"{} · {} explicit lifeline bindings",
|
||||
p.named(id),
|
||||
bindings.len()
|
||||
)
|
||||
})
|
||||
.unwrap_or("Choose a scenario".into()),
|
||||
marker: Some(Kind::Occurrence),
|
||||
});
|
||||
*y += 95.;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl canvas::Program<Message> for Scene {
|
||||
type State = ();
|
||||
fn update(
|
||||
&self,
|
||||
_: &mut (),
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) && let Some(p) = cursor.position_in(bounds)
|
||||
{
|
||||
let p = Point::new(p.x / self.scale, p.y / self.scale);
|
||||
if let Some(b) = self
|
||||
.boxes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| b.rect.contains(p) && (!self.is_container(b) || p.y < b.rect.y + 38.))
|
||||
{
|
||||
return Some(Action::publish(Message::Select(b.id)).and_capture());
|
||||
}
|
||||
if let Some(l) = self.lines.iter().find(|l| {
|
||||
!l.label.is_empty()
|
||||
&& (Rectangle::new(
|
||||
l.label_at,
|
||||
Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 25.),
|
||||
)
|
||||
.contains(p)
|
||||
|| l.points
|
||||
.windows(2)
|
||||
.any(|v| crate::diagram::distance_to_segment(p, v[0], v[1]) < 8.))
|
||||
}) {
|
||||
return Some(Action::publish(Message::Select(l.id)).and_capture());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
_: &(),
|
||||
renderer: &Renderer,
|
||||
_: &Theme,
|
||||
bounds: Rectangle,
|
||||
_: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
||||
f.scale(self.scale);
|
||||
for b in &self.boxes {
|
||||
let outline = matches!(
|
||||
b.marker,
|
||||
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
|
||||
);
|
||||
let color = if b.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::LINE
|
||||
};
|
||||
if outline {
|
||||
f.stroke(
|
||||
&Path::rectangle(b.rect.position(), b.rect.size()),
|
||||
Stroke::default().with_color(color),
|
||||
);
|
||||
} else if !b
|
||||
.marker
|
||||
.is_some_and(|k| k.is_state() && !k.is_stable_state())
|
||||
{
|
||||
let path = Path::rounded_rectangle(b.rect.position(), b.rect.size(), 8.into());
|
||||
f.fill(&path, Color::from_rgb8(243, 248, 248));
|
||||
f.stroke(
|
||||
&path,
|
||||
Stroke::default()
|
||||
.with_color(color)
|
||||
.with_width(if b.id == self.selected { 2.5 } else { 1. }),
|
||||
);
|
||||
}
|
||||
}
|
||||
for l in &self.lines {
|
||||
let color = if l.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
};
|
||||
f.stroke(
|
||||
&Path::new(|p| {
|
||||
p.move_to(l.points[0]);
|
||||
for point in &l.points[1..] {
|
||||
p.line_to(*point);
|
||||
}
|
||||
}),
|
||||
Stroke {
|
||||
line_dash: canvas::LineDash {
|
||||
segments: if l.dashed { &[6., 4.] } else { &[] },
|
||||
offset: 0,
|
||||
},
|
||||
..Stroke::default()
|
||||
.with_color(color)
|
||||
.with_width(if l.id == self.selected { 2.5 } else { 1.2 })
|
||||
},
|
||||
);
|
||||
if !l.label.is_empty() {
|
||||
let end = l.points[l.points.len() - 1];
|
||||
let prev = l.points[l.points.len() - 2];
|
||||
let delta = end - prev;
|
||||
let len = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1.);
|
||||
let d = delta / len;
|
||||
f.stroke(
|
||||
&Path::new(|p| {
|
||||
p.move_to(end - d * 9. + iced::Vector::new(-d.y, d.x) * 4.);
|
||||
p.line_to(end);
|
||||
p.line_to(end - d * 9. - iced::Vector::new(-d.y, d.x) * 4.);
|
||||
}),
|
||||
Stroke::default().with_color(color),
|
||||
);
|
||||
f.fill_rectangle(
|
||||
l.label_at,
|
||||
Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 22.),
|
||||
Color::WHITE,
|
||||
);
|
||||
f.fill_text(Text {
|
||||
content: short(&l.label, 32),
|
||||
position: l.label_at,
|
||||
color,
|
||||
size: 12.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
for b in &self.boxes {
|
||||
let pseudo = b
|
||||
.marker
|
||||
.is_some_and(|k| k.is_state() && !k.is_stable_state());
|
||||
if pseudo {
|
||||
state_scene::draw_symbol(
|
||||
&mut f,
|
||||
b,
|
||||
if b.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::INK
|
||||
},
|
||||
);
|
||||
}
|
||||
let prefix = match b.marker {
|
||||
Some(Kind::Initial) => "INITIAL · ",
|
||||
Some(Kind::Final) => "FINAL · ",
|
||||
Some(Kind::Mode) => "MODE · ",
|
||||
Some(Kind::State) => "STATE · ",
|
||||
Some(Kind::Choice) => "CHOICE · ",
|
||||
Some(Kind::Junction) => "JUNCTION · ",
|
||||
Some(Kind::Fork) => "FORK · ",
|
||||
Some(Kind::Join) => "JOIN · ",
|
||||
Some(Kind::ShallowHistory) => "H · ",
|
||||
Some(Kind::DeepHistory) => "H* · ",
|
||||
Some(Kind::EntryPoint) => "ENTRY · ",
|
||||
Some(Kind::ExitPoint) => "EXIT · ",
|
||||
Some(Kind::Terminate) => "TERMINATE · ",
|
||||
_ => "",
|
||||
};
|
||||
f.fill_text(Text {
|
||||
content: format!(
|
||||
"{prefix}{}",
|
||||
short(&b.title, ((b.rect.width - 25.) / 7.) as usize)
|
||||
),
|
||||
position: Point::new(
|
||||
b.rect.x + if pseudo { 38. } else { 12. },
|
||||
b.rect.y + if pseudo { 18. } else { 12. },
|
||||
),
|
||||
size: 13.into(),
|
||||
color: if b.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::INK
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
if !b.detail.is_empty() {
|
||||
f.fill_text(Text {
|
||||
content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize),
|
||||
position: Point::new(b.rect.x + 12., b.rect.y + 39.),
|
||||
size: 11.into(),
|
||||
color: style::MUTED,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
vec![f.into_geometry()]
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
_: &(),
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> mouse::Interaction {
|
||||
if cursor.is_over(bounds) {
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
mouse::Interaction::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn legacy_process_and_chain_keep_their_ordered_exchange_scene() {
|
||||
let p = Project::demo();
|
||||
let owners: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
.collect();
|
||||
assert!(owners.iter().any(|e| e.kind() == Kind::Process));
|
||||
assert!(owners.iter().any(|e| e.kind() == Kind::FunctionalChain));
|
||||
for owner in owners {
|
||||
assert!(!uses_explicit_topology(&p, owner.id));
|
||||
let expected: Vec<_> = owner
|
||||
.steps()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, id)| {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = p.elements[id].shared()
|
||||
else {
|
||||
panic!("Demo legacy sequence should contain exchange occurrences");
|
||||
};
|
||||
format!("{}. {}", index + 1, p.relations[exchange].name)
|
||||
})
|
||||
.collect();
|
||||
assert!(!expected.is_empty());
|
||||
let scene = Scene::sequence(&p, owner.id, owner.id);
|
||||
let actual: Vec<_> = scene
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| !line.label.is_empty())
|
||||
.map(|line| line.label.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"Legacy rendering must preserve the model's ordered exchange path"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn selecting_state_transition_keeps_its_behavior_context() {
|
||||
let p = Project::demo();
|
||||
for r in p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::StateTransition)
|
||||
{
|
||||
let machine = selected_behavior(&p, r.id).expect("Transition remains in its machine");
|
||||
assert_eq!(p.elements[&machine].kind(), Kind::StateMachine);
|
||||
assert!(in_machine(&p, r.source(), machine));
|
||||
assert!(in_machine(&p, r.target(), machine));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user