Add persisted state diagram authoring and context-bound gestures
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,12 @@ pub enum Event {
|
||||
Viewport(Context, iced::Size),
|
||||
Fit(Context),
|
||||
EditTransition(Context, Id),
|
||||
ContextMenu {
|
||||
context: Context,
|
||||
object: Id,
|
||||
placement: Option<Id>,
|
||||
anchor: iced::Point,
|
||||
},
|
||||
CreateAt {
|
||||
context: Context,
|
||||
owner: Id,
|
||||
@@ -71,6 +77,7 @@ impl Event {
|
||||
| Self::Viewport(c, _)
|
||||
| Self::Fit(c)
|
||||
| Self::EditTransition(c, _)
|
||||
| Self::ContextMenu { context: c, .. }
|
||||
| Self::CreateAt { context: c, .. }
|
||||
| Self::Place { context: c, .. }
|
||||
| Self::Move { context: c, .. }
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! State actions capture their own diagram; architecture selection stays separate.
|
||||
use super::{
|
||||
state_edit::{self, Endpoint, Event, Tool},
|
||||
states,
|
||||
};
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
context_menu::{Entry, Menu},
|
||||
model_menu::{self, Opened},
|
||||
};
|
||||
use oa_core::*;
|
||||
|
||||
pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
|
||||
let project = app.project();
|
||||
let machine = super::selected_owner(app)
|
||||
.filter(|id| project.elements[id].kind() == Kind::StateMachine)?;
|
||||
let context = app.state_context();
|
||||
let request = &opened.request;
|
||||
let id = request.object;
|
||||
let item = |caption: &str, message: Message, enabled: bool| {
|
||||
if clean && enabled {
|
||||
Entry::action(
|
||||
caption,
|
||||
Message::Menu(model_menu::Event::Invoke(opened.token, Box::new(message))),
|
||||
)
|
||||
} else {
|
||||
Entry::disabled(caption)
|
||||
}
|
||||
};
|
||||
let diagram_event = Message::StateDiagram;
|
||||
let form = |tool| {
|
||||
Message::Behavior(super::Event::States(states::Event::Start(
|
||||
machine, tool, id,
|
||||
)))
|
||||
};
|
||||
let mut entries = vec![Entry::title(crate::views::short(project.named(id), 38))];
|
||||
if !clean {
|
||||
entries.push(Entry::title("Apply or cancel pending edits first"));
|
||||
}
|
||||
|
||||
if let Some(context) = context.filter(|context| context.diagram == id) {
|
||||
entries.extend([
|
||||
item("View properties", Message::EditProperties(id), true),
|
||||
item("Fit diagram", diagram_event(Event::Fit(context)), true),
|
||||
item(
|
||||
"Create another state view",
|
||||
diagram_event(Event::CreateView(machine)),
|
||||
true,
|
||||
),
|
||||
Entry::separator(),
|
||||
item(
|
||||
"Create region…",
|
||||
diagram_event(Event::Choose(Tool::Region)),
|
||||
true,
|
||||
),
|
||||
item(
|
||||
"New transition with details…",
|
||||
form(states::Tool::Transition),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
entries.extend([
|
||||
item("Properties", Message::EditProperties(id), true),
|
||||
item("Open source", Message::SourceForSelection, true),
|
||||
item(
|
||||
"Connections & references",
|
||||
Message::InspectorTab(crate::navigation::InspectorTab::Connections),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
if let Some(element) = project.elements.get(&id)
|
||||
&& projection::state_machine_contains(project, machine, id)
|
||||
{
|
||||
entries.push(Entry::separator());
|
||||
let creates = if element.kind() == Kind::Region {
|
||||
Some((
|
||||
"Create state…",
|
||||
Kind::State,
|
||||
states::Tool::Vertex,
|
||||
Tool::Vertex(Kind::State),
|
||||
))
|
||||
} else if element.kind() == Kind::StateMachine || element.kind().is_stable_state() {
|
||||
Some((
|
||||
"Create region…",
|
||||
Kind::Region,
|
||||
states::Tool::Region,
|
||||
Tool::Region,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((caption, kind, typed, tool)) = creates {
|
||||
entries.push(if let Some(context) = context {
|
||||
item(
|
||||
caption,
|
||||
diagram_event(Event::Choose(tool)),
|
||||
state_edit::eligible_owner(project, context.diagram, id, kind),
|
||||
)
|
||||
} else {
|
||||
item(caption, form(typed), true)
|
||||
});
|
||||
}
|
||||
entries.push(item(
|
||||
"New transition with details…",
|
||||
form(states::Tool::Transition),
|
||||
true,
|
||||
));
|
||||
if let Some(context) = context
|
||||
&& let Some(node) = request
|
||||
.placement
|
||||
.and_then(|placement| project.diagrams[&context.diagram].nodes.get(&placement))
|
||||
.filter(|node| node.element == id)
|
||||
{
|
||||
entries.push(Entry::separator());
|
||||
entries.push(item(
|
||||
"Placement properties",
|
||||
Message::EditProperties(node.id),
|
||||
true,
|
||||
));
|
||||
if element.kind() == Kind::StateMachine
|
||||
|| element.kind() == Kind::Region
|
||||
|| element.kind().is_stable_state()
|
||||
{
|
||||
let mut changed = node.clone();
|
||||
changed.collapsed = !changed.collapsed;
|
||||
entries.push(item(
|
||||
if node.collapsed {
|
||||
"Unfold contents"
|
||||
} else {
|
||||
"Fold contents"
|
||||
},
|
||||
Message::Command(Command::EditNode {
|
||||
diagram: context.diagram,
|
||||
original: node.id,
|
||||
node: changed,
|
||||
}),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Hide from this diagram",
|
||||
Message::Command(Command::Delete(node.id)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(relation) = project.relations.get(&id)
|
||||
&& relation.kind() == RelationKind::StateTransition
|
||||
&& projection::state_machine_contains(project, machine, relation.source())
|
||||
&& projection::state_machine_contains(project, machine, relation.target())
|
||||
{
|
||||
entries.push(Entry::separator());
|
||||
let edit = context.map_or_else(
|
||||
|| Message::Behavior(super::Event::States(states::Event::Edit(id))),
|
||||
|context| diagram_event(Event::EditTransition(context, id)),
|
||||
);
|
||||
entries.push(item("Edit transition", edit, true));
|
||||
if let Some(context) = context {
|
||||
let visible = projection::project_view(project, context.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == id);
|
||||
for (caption, endpoint) in [
|
||||
("Reconnect source", Endpoint::Source),
|
||||
("Reconnect target", Endpoint::Target),
|
||||
] {
|
||||
entries.push(item(
|
||||
caption,
|
||||
diagram_event(Event::Choose(Tool::Reconnect {
|
||||
relation: id,
|
||||
endpoint,
|
||||
})),
|
||||
visible,
|
||||
));
|
||||
}
|
||||
if !visible {
|
||||
entries.push(Entry::title(
|
||||
"Use transition details for hidden or internal endpoints",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if id != project.id {
|
||||
entries.push(Entry::separator());
|
||||
entries.push(item("Delete from model…", Message::Delete, true));
|
||||
}
|
||||
}
|
||||
Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(model_menu::Event::Close),
|
||||
})
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
//! State geometry follows semantic containment; presentation never reparents records.
|
||||
use super::*;
|
||||
|
||||
fn internal_transitions(p: &Project, id: Id) -> impl Iterator<Item = &Relation> {
|
||||
p.relations.values().filter(move |r| {
|
||||
matches!(&r.body, RelationBody::StateTransition {
|
||||
source, transition_kind: TransitionKind::Internal, ..
|
||||
} if *source == id)
|
||||
})
|
||||
}
|
||||
|
||||
fn content_width(p: &Project, owner: Id) -> f32 {
|
||||
p.children(owner)
|
||||
.map(|e| match e.kind() {
|
||||
Kind::Region => content_width(p, e.id) + 120.,
|
||||
k if k.is_stable_state() => content_width(p, e.id) + 32.,
|
||||
_ => 300.,
|
||||
})
|
||||
.fold(300., f32::max)
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
pub(super) fn states(p: &Project, owner: Id, selected: Id) -> Self {
|
||||
let mut scene = Self {
|
||||
scale: 1.,
|
||||
boxes: vec![],
|
||||
lines: vec![],
|
||||
size: Size::new(400., 180.),
|
||||
selected,
|
||||
};
|
||||
if !p.children(owner).any(|e| e.kind() == Kind::Region) {
|
||||
return scene;
|
||||
}
|
||||
let width = content_width(p, owner) + 40.;
|
||||
scene.boxes.push(BoxItem {
|
||||
id: owner,
|
||||
rect: Rectangle::new(Point::new(24., 20.), Size::new(width, 100.)),
|
||||
title: p.named(owner).into(),
|
||||
detail: String::new(),
|
||||
marker: Some(Kind::StateMachine),
|
||||
});
|
||||
let mut y = 64.;
|
||||
for region in p.children(owner).filter(|e| e.kind() == Kind::Region) {
|
||||
y = scene.state_region(p, region.id, 44., y, width - 40.) + 14.;
|
||||
}
|
||||
scene.boxes[0].rect.height = y - 20.;
|
||||
|
||||
// Entry/exit symbols are region-owned but attach to the containing boundary.
|
||||
let rectangles: BTreeMap<_, _> = scene.boxes.iter().map(|b| (b.id, b.rect)).collect();
|
||||
for b in &mut scene.boxes {
|
||||
if !matches!(b.marker, Some(Kind::EntryPoint | Kind::ExitPoint)) {
|
||||
continue;
|
||||
}
|
||||
let boundary = p
|
||||
.elements
|
||||
.get(&b.id)
|
||||
.and_then(|e| e.parent)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.and_then(|e| e.parent);
|
||||
if let Some(rect) = boundary.and_then(|id| rectangles.get(&id)) {
|
||||
// The symbol is centered 16 px into its 32 px hit target.
|
||||
b.rect.x = if b.marker == Some(Kind::EntryPoint) {
|
||||
rect.x - 16.
|
||||
} else {
|
||||
rect.x + rect.width - 16.
|
||||
};
|
||||
}
|
||||
}
|
||||
let vertices: BTreeMap<_, _> = scene
|
||||
.boxes
|
||||
.iter()
|
||||
.filter(|b| b.marker.is_some_and(|k| k.is_state()))
|
||||
.map(|b| (b.id, (b.rect, b.marker.unwrap())))
|
||||
.collect();
|
||||
for (index, r) in p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::StateTransition
|
||||
&& vertices.contains_key(&r.source())
|
||||
&& vertices.contains_key(&r.target())
|
||||
})
|
||||
.enumerate()
|
||||
{
|
||||
if matches!(
|
||||
&r.body,
|
||||
RelationBody::StateTransition {
|
||||
transition_kind: TransitionKind::Internal,
|
||||
..
|
||||
}
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let (a, ak) = vertices[&r.source()];
|
||||
let (b, bk) = vertices[&r.target()];
|
||||
let ay = a.y + if ak.is_stable_state() { 25. } else { 28. };
|
||||
let by = if r.source() == r.target() {
|
||||
ay + 28.
|
||||
} else {
|
||||
b.y + if bk.is_stable_state() { 25. } else { 28. }
|
||||
};
|
||||
let ax = if ak.is_stable_state() {
|
||||
a.x + a.width
|
||||
} else {
|
||||
a.x + 32.
|
||||
};
|
||||
let bx = if bk.is_stable_state() {
|
||||
b.x + b.width
|
||||
} else {
|
||||
b.x + 32.
|
||||
};
|
||||
let lane = (ax.max(bx) + 28. + (index % 4) as f32 * 20.).max(360.);
|
||||
let activation = states::transition_summary(p, r);
|
||||
scene.lines.push(Line {
|
||||
id: r.id,
|
||||
points: vec![
|
||||
Point::new(ax, ay),
|
||||
Point::new(lane, ay),
|
||||
Point::new(lane, by),
|
||||
Point::new(bx, by),
|
||||
],
|
||||
label: format!("{}. {}", index + 1, short(&activation, 24)),
|
||||
label_at: Point::new(lane + 5., (ay + by) / 2.),
|
||||
dashed: matches!(
|
||||
&r.body,
|
||||
RelationBody::StateTransition {
|
||||
transition_kind: TransitionKind::Local,
|
||||
..
|
||||
}
|
||||
),
|
||||
});
|
||||
scene.size.width = scene.size.width.max(lane + 220.);
|
||||
}
|
||||
scene.size.width = scene.size.width.max(width + 180.);
|
||||
scene.size.height = (y + 24.).max(180.);
|
||||
scene
|
||||
}
|
||||
|
||||
fn state_region(&mut self, p: &Project, id: Id, x: f32, top: f32, width: f32) -> f32 {
|
||||
let index = self.boxes.len();
|
||||
self.boxes.push(BoxItem {
|
||||
id,
|
||||
rect: Rectangle::new(Point::new(x, top), Size::new(width, 90.)),
|
||||
title: format!("REGION · {}", p.named(id)),
|
||||
detail: String::new(),
|
||||
marker: Some(Kind::Region),
|
||||
});
|
||||
let mut y = top + 42.;
|
||||
for state in p.children(id).filter(|e| e.kind().is_state()) {
|
||||
let stable = state.kind().is_stable_state();
|
||||
let inner_width = if stable { width - 100. } else { 300. };
|
||||
let index = self.boxes.len();
|
||||
let detail = match state.shared() {
|
||||
Some(SharedBody::State {
|
||||
entry,
|
||||
exit,
|
||||
do_activity,
|
||||
..
|
||||
}) => [("entry", entry), ("do", do_activity), ("exit", exit)]
|
||||
.into_iter()
|
||||
.filter(|(_, value)| !value.is_empty())
|
||||
.map(|(label, value)| format!("{label} / {value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · "),
|
||||
_ => String::new(),
|
||||
};
|
||||
self.boxes.push(BoxItem {
|
||||
id: state.id,
|
||||
rect: Rectangle::new(
|
||||
Point::new(x + 18., y),
|
||||
Size::new(inner_width, if stable { 76. } else { 56. }),
|
||||
),
|
||||
title: state.name.clone(),
|
||||
detail,
|
||||
marker: Some(state.kind()),
|
||||
});
|
||||
let start = y;
|
||||
y += if stable { 76. } else { 56. };
|
||||
for r in internal_transitions(p, state.id) {
|
||||
self.boxes.push(BoxItem {
|
||||
id: r.id,
|
||||
rect: Rectangle::new(Point::new(x + 28., y), Size::new(inner_width - 20., 55.)),
|
||||
title: format!("INTERNAL · {}", r.name),
|
||||
detail: states::transition_summary(p, r),
|
||||
marker: None,
|
||||
});
|
||||
y += 61.;
|
||||
}
|
||||
for region in p.children(state.id).filter(|e| e.kind() == Kind::Region) {
|
||||
y = self.state_region(p, region.id, x + 34., y, inner_width - 32.) + 12.;
|
||||
}
|
||||
self.boxes[index].rect.height = y - start;
|
||||
y += 18.;
|
||||
}
|
||||
self.boxes[index].rect.height = (y - top).max(90.);
|
||||
top + self.boxes[index].rect.height
|
||||
}
|
||||
|
||||
pub(super) fn is_container(&self, item: &BoxItem) -> bool {
|
||||
matches!(
|
||||
item.marker,
|
||||
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
|
||||
) || (item.marker.is_some_and(|kind| kind.is_stable_state())
|
||||
&& self.boxes.iter().any(|other| {
|
||||
other.id != item.id
|
||||
&& item.rect.contains(other.rect.position())
|
||||
&& item.rect.contains(Point::new(
|
||||
other.rect.x + other.rect.width,
|
||||
other.rect.y + other.rect.height,
|
||||
))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pseudostates use actual vector shapes, with a full row as their hit target.
|
||||
pub(super) fn draw_symbol(f: &mut Frame, b: &BoxItem, color: Color) {
|
||||
let center = Point::new(b.rect.x + 16., b.rect.y + 28.);
|
||||
let stroke = Stroke::default().with_color(color).with_width(1.8);
|
||||
let circle = Path::circle(center, 11.);
|
||||
match b.marker.unwrap() {
|
||||
Kind::Initial | Kind::Junction => f.fill(
|
||||
&Path::circle(
|
||||
center,
|
||||
if b.marker == Some(Kind::Initial) {
|
||||
8.
|
||||
} else {
|
||||
5.
|
||||
},
|
||||
),
|
||||
color,
|
||||
),
|
||||
Kind::Final => {
|
||||
f.stroke(&circle, stroke);
|
||||
f.fill(&Path::circle(center, 7.), color);
|
||||
}
|
||||
Kind::Choice => f.stroke(
|
||||
&Path::new(|p| {
|
||||
p.move_to(Point::new(center.x, center.y - 12.));
|
||||
p.line_to(Point::new(center.x + 12., center.y));
|
||||
p.line_to(Point::new(center.x, center.y + 12.));
|
||||
p.line_to(Point::new(center.x - 12., center.y));
|
||||
p.close();
|
||||
}),
|
||||
stroke,
|
||||
),
|
||||
Kind::Fork | Kind::Join => f.fill_rectangle(
|
||||
Point::new(center.x - 3., center.y - 18.),
|
||||
Size::new(6., 36.),
|
||||
color,
|
||||
),
|
||||
Kind::ShallowHistory | Kind::DeepHistory => {
|
||||
f.stroke(&circle, stroke);
|
||||
f.fill_text(Text {
|
||||
content: if b.marker == Some(Kind::DeepHistory) {
|
||||
"H*"
|
||||
} else {
|
||||
"H"
|
||||
}
|
||||
.into(),
|
||||
position: Point::new(center.x - 8., center.y - 9.),
|
||||
color,
|
||||
size: 13.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Kind::EntryPoint => {
|
||||
f.fill(&circle, Color::WHITE);
|
||||
f.stroke(&circle, stroke);
|
||||
}
|
||||
Kind::ExitPoint | Kind::Terminate => {
|
||||
if b.marker == Some(Kind::ExitPoint) {
|
||||
f.fill(&circle, Color::WHITE);
|
||||
f.stroke(&circle, stroke);
|
||||
}
|
||||
f.stroke(
|
||||
&Path::new(|p| {
|
||||
p.move_to(Point::new(center.x - 7., center.y - 7.));
|
||||
p.line_to(Point::new(center.x + 7., center.y + 7.));
|
||||
p.move_to(Point::new(center.x - 7., center.y + 7.));
|
||||
p.line_to(Point::new(center.x + 7., center.y - 7.));
|
||||
}),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! State workspace controls. Persistent semantics and layout live in the model.
|
||||
use super::state_edit::{Event, Tool};
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, opaque, stack};
|
||||
|
||||
pub struct State {
|
||||
pub diagram: Option<Id>,
|
||||
pub placement: Option<Id>,
|
||||
pub tool: Tool,
|
||||
pub viewport: Option<Size>,
|
||||
pub zoom: f32,
|
||||
pub pan: iced::Vector,
|
||||
pub fit: bool,
|
||||
}
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
diagram: None,
|
||||
placement: None,
|
||||
tool: Tool::Select,
|
||||
viewport: None,
|
||||
zoom: 0.8,
|
||||
pan: iced::Vector::new(24., 48.),
|
||||
fit: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diagram(app: &App, machine: Id) -> Option<Id> {
|
||||
let valid = |id: &Id| {
|
||||
app.project()
|
||||
.diagrams
|
||||
.get(id)
|
||||
.is_some_and(|d| d.projection.state_machine() == Some(machine))
|
||||
};
|
||||
app.behavior
|
||||
.diagram
|
||||
.diagram
|
||||
.filter(valid)
|
||||
.or_else(|| app.project().diagrams.keys().find(|id| valid(id)).copied())
|
||||
}
|
||||
|
||||
fn event(e: Event) -> Message {
|
||||
Message::StateDiagram(e)
|
||||
}
|
||||
|
||||
pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
let Some(id) = diagram(app, machine) else {
|
||||
let base = out.push(container(column![
|
||||
text("Create a state diagram").size(20),
|
||||
label("Place the machine’s existing regions and states in an editable view. Creating another view leaves the existing views unchanged."),
|
||||
button("Create diagram").padding(12).on_press(event(Event::CreateView(machine))),
|
||||
row![
|
||||
action("Add region…", Message::Behavior(super::Event::States(states::Event::Start(machine, states::Tool::Region, app.selected)))),
|
||||
action("Add state…", Message::Behavior(super::Event::States(states::Event::Start(machine, states::Tool::Vertex, app.selected)))),
|
||||
transition_details(machine, app.selected),
|
||||
].spacing(5),
|
||||
].spacing(14)).padding(24).style(style::card)).padding(18).into();
|
||||
return with_form(app, machine, base);
|
||||
};
|
||||
let context = app
|
||||
.state_context()
|
||||
.expect("a visible state diagram has a context");
|
||||
let p = app.project();
|
||||
let state = &app.behavior.diagram;
|
||||
let views = p
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.projection.state_machine() == Some(machine))
|
||||
.map(|d| Choice {
|
||||
id: d.id,
|
||||
label: d.name.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let current = views.iter().find(|v| v.id == id).cloned();
|
||||
out = out.push(
|
||||
row![
|
||||
pick_list(views, current, Message::BehaviorDiagram)
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
action("+ View", event(Event::CreateView(machine))),
|
||||
action("Fit", event(Event::Fit(context))),
|
||||
action("−", event(Event::Zoom(context, state.zoom / 1.2))),
|
||||
label(format!("{:.0}%", state.zoom * 100.)),
|
||||
action("+", event(Event::Zoom(context, state.zoom * 1.2))),
|
||||
]
|
||||
.spacing(5)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
|
||||
let tool = |name: &'static str, value: Tool| {
|
||||
button(text(name).size(12))
|
||||
.padding([8, 10])
|
||||
.style(if state.tool == value {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Choose(value)))
|
||||
};
|
||||
let kinds = [
|
||||
Kind::State,
|
||||
Kind::Mode,
|
||||
Kind::Initial,
|
||||
Kind::Final,
|
||||
Kind::Choice,
|
||||
Kind::Junction,
|
||||
Kind::Fork,
|
||||
Kind::Join,
|
||||
Kind::ShallowHistory,
|
||||
Kind::DeepHistory,
|
||||
Kind::EntryPoint,
|
||||
Kind::ExitPoint,
|
||||
Kind::Terminate,
|
||||
];
|
||||
let selected_kind = if let Tool::Vertex(kind) = state.tool {
|
||||
Some(kind)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
out = out.push(
|
||||
row![
|
||||
tool("Select / move", Tool::Select),
|
||||
pick_list(kinds.to_vec(), selected_kind, |k| event(Event::Choose(
|
||||
Tool::Vertex(k)
|
||||
)))
|
||||
.placeholder("+ State / symbol")
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
tool("+ Region", Tool::Region),
|
||||
tool("Transition", Tool::Transition),
|
||||
]
|
||||
.spacing(4),
|
||||
);
|
||||
|
||||
let missing = state_edit::unplaced(p, id)
|
||||
.into_iter()
|
||||
.map(|element| Choice {
|
||||
id: element,
|
||||
label: format!("{} · {}", p.named(element), p.elements[&element].kind()),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !missing.is_empty() {
|
||||
let position = Position {
|
||||
x: (48. - state.pan.x) / state.zoom,
|
||||
y: (90. - state.pan.y) / state.zoom,
|
||||
};
|
||||
out = out.push(
|
||||
pick_list(missing, None::<Choice>, move |c| {
|
||||
event(Event::Place {
|
||||
context,
|
||||
element: c.id,
|
||||
position,
|
||||
})
|
||||
})
|
||||
.placeholder("Show an element not yet in this view…")
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
let canvas: Element<'_, Event> = canvas::Canvas::new(super::state_canvas::StateCanvas {
|
||||
project: p,
|
||||
diagram: id,
|
||||
context,
|
||||
selected: app.selected,
|
||||
tool: state.tool,
|
||||
zoom: state.zoom,
|
||||
pan: state.pan,
|
||||
editable: !app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending(),
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into();
|
||||
out = out.push(canvas.map(event));
|
||||
let base: Element<'_, Message> = container(out.spacing(8))
|
||||
.padding(14)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into();
|
||||
with_form(app, machine, base)
|
||||
}
|
||||
|
||||
fn transition_details(machine: Id, selected: Id) -> Element<'static, Message> {
|
||||
action(
|
||||
"New transition with details…",
|
||||
Message::Behavior(super::Event::States(states::Event::Start(
|
||||
machine,
|
||||
states::Tool::Transition,
|
||||
selected,
|
||||
))),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn with_form<'a>(app: &'a App, machine: Id, base: Element<'a, Message>) -> Element<'a, Message> {
|
||||
if let Some(form) = states::form(app, machine) {
|
||||
stack![
|
||||
base,
|
||||
opaque(
|
||||
container(
|
||||
container(
|
||||
scrollable(form).height((app.window_size.height - 250.).clamp(280., 610.))
|
||||
)
|
||||
.padding(18)
|
||||
.width(600)
|
||||
.style(style::card)
|
||||
)
|
||||
.center(Fill)
|
||||
.style(|_| container::Style {
|
||||
background: Some(Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
|
||||
..Default::default()
|
||||
})
|
||||
)
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! State authoring through the same validated commands as source and REST.
|
||||
use super::*;
|
||||
use iced::widget::{Column, checkbox, column, text_input};
|
||||
use iced::widget::{checkbox, column, text_input};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
@@ -41,15 +41,10 @@ pub enum Event {
|
||||
Timed(bool),
|
||||
TimeKind(String),
|
||||
Expression(String),
|
||||
Viewport(Size),
|
||||
Zoom(f32),
|
||||
Fit,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
draft: Option<Draft>,
|
||||
viewport: Option<Size>,
|
||||
zoom: Option<f32>,
|
||||
}
|
||||
struct Draft {
|
||||
machine: Id,
|
||||
@@ -272,9 +267,6 @@ impl State {
|
||||
}
|
||||
Event::Cancel => self.acknowledge_commit(),
|
||||
Event::Submit => return self.prepare_pending(p).map(Some),
|
||||
Event::Viewport(size) => self.viewport = Some(size),
|
||||
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
|
||||
Event::Fit => self.zoom = None,
|
||||
event => {
|
||||
if let Some(d) = &mut self.draft {
|
||||
match event {
|
||||
@@ -359,359 +351,218 @@ fn picker<'a>(
|
||||
.width(Fill)
|
||||
.into()
|
||||
}
|
||||
pub(super) fn transition_summary(p: &Project, r: &Relation) -> String {
|
||||
let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
triggers,
|
||||
trigger,
|
||||
guard,
|
||||
effects,
|
||||
effect,
|
||||
..
|
||||
} = &r.body
|
||||
else {
|
||||
return r.name.clone();
|
||||
};
|
||||
let activation = if !triggers.is_empty() {
|
||||
triggers
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" OR ")
|
||||
} else if !trigger.trim().is_empty() {
|
||||
format!("Unresolved trigger: {trigger}")
|
||||
} else if p
|
||||
.elements
|
||||
.get(&r.source())
|
||||
.is_some_and(|e| e.kind().is_stable_state())
|
||||
{
|
||||
"Completion".into()
|
||||
} else {
|
||||
"Transient routing".into()
|
||||
};
|
||||
let mut result = format!("{transition_kind:?} · {activation}");
|
||||
if !triggers.is_empty() && !trigger.is_empty() {
|
||||
result.push_str(&format!(" · {trigger}"));
|
||||
}
|
||||
if !guard.is_empty() {
|
||||
result.push_str(&format!(" [{guard}]"));
|
||||
}
|
||||
if !effects.is_empty() {
|
||||
result.push_str(&format!(
|
||||
" / {}",
|
||||
effects
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
if !effect.is_empty() {
|
||||
result.push_str(&format!(" / {effect}"));
|
||||
}
|
||||
result
|
||||
}
|
||||
pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
pub(super) fn form(app: &App, machine: Id) -> Option<Element<'_, Message>> {
|
||||
let p = app.project();
|
||||
let state = &app.behavior.states;
|
||||
out = out.push(
|
||||
row![
|
||||
action(
|
||||
"+ Vertex",
|
||||
event(Event::Start(machine, Tool::Vertex, app.selected))
|
||||
),
|
||||
action(
|
||||
"+ Region",
|
||||
event(Event::Start(machine, Tool::Region, app.selected))
|
||||
),
|
||||
action(
|
||||
"+ Transition",
|
||||
event(Event::Start(machine, Tool::Transition, app.selected))
|
||||
),
|
||||
action("Properties", Message::EditProperties(machine)),
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.machine == machine) {
|
||||
let mut form = column![
|
||||
text(if d.original.is_some() {
|
||||
"Edit transition"
|
||||
} else {
|
||||
match d.tool {
|
||||
Tool::Vertex => "Add state or pseudostate",
|
||||
Tool::Region => "Add region",
|
||||
Tool::Transition => "Connect states",
|
||||
}
|
||||
})
|
||||
.size(17),
|
||||
text_input("Engineering name (optional)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(9);
|
||||
if d.tool != Tool::Transition {
|
||||
if d.tool == Tool::Vertex {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|k| k.is_state())
|
||||
.collect::<Vec<_>>(),
|
||||
Some(d.vertex),
|
||||
|k| event(Event::Vertex(k)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
let parents = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
in_machine(p, e.id, machine)
|
||||
&& if d.tool == Tool::Vertex {
|
||||
e.kind() == Kind::Region
|
||||
} else {
|
||||
e.kind() == Kind::StateMachine || e.kind().is_stable_state()
|
||||
}
|
||||
})
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
form = form.push(picker(parents, d.parent, "Choose owner…", Event::Parent));
|
||||
form = form.push(label(if d.tool == Tool::Region { "Sibling regions are concurrent. Select a State or Mode to create a nested region." } else { "Incomplete vertices remain editable; Model checks explain the connections still needed." }));
|
||||
let d = app
|
||||
.behavior
|
||||
.states
|
||||
.draft
|
||||
.as_ref()
|
||||
.filter(|d| d.machine == machine)?;
|
||||
let mut form = column![
|
||||
text(if d.original.is_some() {
|
||||
"Edit transition"
|
||||
} else {
|
||||
let mut context = p.clone();
|
||||
if let Some(id) = d.original {
|
||||
context.relations.remove(&id);
|
||||
match d.tool {
|
||||
Tool::Vertex => "Add state or pseudostate",
|
||||
Tool::Region => "Add region",
|
||||
Tool::Transition => "Connect states",
|
||||
}
|
||||
let vertices: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind().is_state() && in_machine(p, e.id, machine))
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
let targets = vertices
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
d.source.is_some_and(|source| {
|
||||
if d.kind == TransitionKind::Internal {
|
||||
return c.id == source && p.elements[&source].kind().is_stable_state();
|
||||
}
|
||||
if d.kind == TransitionKind::Local
|
||||
&& p.elements[&source].kind().is_stable_state()
|
||||
{
|
||||
return c.id != source && in_machine(p, c.id, source);
|
||||
}
|
||||
validation::can_connect(
|
||||
&context,
|
||||
RelationKind::StateTransition,
|
||||
source,
|
||||
c.id,
|
||||
)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
form = form
|
||||
.push(picker(vertices, d.source, "From state…", Event::Source))
|
||||
.push(picker(targets, d.target, "To state…", Event::Target))
|
||||
.push(
|
||||
pick_list(
|
||||
[
|
||||
TransitionChoice(TransitionKind::External),
|
||||
TransitionChoice(TransitionKind::Local),
|
||||
TransitionChoice(TransitionKind::Internal),
|
||||
],
|
||||
Some(TransitionChoice(d.kind)),
|
||||
|k| event(Event::Kind(k)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
let layer = p.elements[&machine].layer();
|
||||
let triggers = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.layer() == layer
|
||||
&& (e.kind() == Kind::Event || e.kind().is_work())
|
||||
&& !d.triggers.contains(&e.id)
|
||||
})
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
form = form.push(label("Trigger events / work (alternatives)"));
|
||||
for id in &d.triggers {
|
||||
form = form.push(
|
||||
row![
|
||||
text(p.named(*id)).size(12).width(Fill),
|
||||
action("Remove", event(Event::RemoveTrigger(*id)))
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(picker(
|
||||
triggers,
|
||||
None,
|
||||
"Add existing trigger…",
|
||||
Event::Trigger,
|
||||
))
|
||||
.push(
|
||||
checkbox(d.timed)
|
||||
.label("Add a time event with this transition")
|
||||
.on_toggle(|b| event(Event::Timed(b))),
|
||||
);
|
||||
if d.timed {
|
||||
form = form.push(
|
||||
row![
|
||||
pick_list(
|
||||
["After".to_string(), "At".to_string()],
|
||||
Some(format!("{:?}", d.time_kind)),
|
||||
|s| event(Event::TimeKind(s))
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12),
|
||||
text_input("Time expression, e.g. 30 s", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(
|
||||
text_input("Trigger notes (optional)", &d.trigger)
|
||||
.on_input(|s| event(Event::TriggerNotes(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
})
|
||||
.size(17),
|
||||
text_input("Engineering name (optional)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(9);
|
||||
if d.tool != Tool::Transition {
|
||||
if d.tool == Tool::Vertex {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|k| k.is_state())
|
||||
.collect::<Vec<_>>(),
|
||||
Some(d.vertex),
|
||||
|k| event(Event::Vertex(k)),
|
||||
)
|
||||
.push(
|
||||
text_input("Guard condition (optional)", &d.guard)
|
||||
.on_input(|s| event(Event::Guard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
let effects = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.layer() == layer && e.kind().is_work() && !d.effects.contains(&e.id))
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
for id in &d.effects {
|
||||
form = form.push(
|
||||
row![
|
||||
text(p.named(*id)).size(12).width(Fill),
|
||||
action("Remove", event(Event::RemoveEffect(*id)))
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(picker(effects, None, "Add effect work…", Event::Effect))
|
||||
.push(
|
||||
text_input("Effect notes (optional)", &d.effect)
|
||||
.on_input(|s| event(Event::EffectNotes(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
if d.triggers.is_empty() && !d.timed && d.trigger.trim().is_empty() {
|
||||
form=form.push(label("No event trigger: completion from a state, transient routing from a pseudostate."));
|
||||
}
|
||||
}
|
||||
let candidate = d.command(p).and_then(|command| {
|
||||
execute_batch(p, &[command])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
if let Err(error) = &candidate {
|
||||
form = form.push(text(error.clone()).size(12).color(style::MUTED));
|
||||
}
|
||||
form = form.push(
|
||||
row![
|
||||
button("Apply")
|
||||
.padding(9)
|
||||
.on_press_maybe(candidate.is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
out = out.push(container(form).padding(14).style(style::card));
|
||||
}
|
||||
let mut scene = Scene::states(p, machine, app.selected);
|
||||
if scene.boxes.is_empty() {
|
||||
out=out.push(container(column![text("Start with a region").size(18),label("Add a region, then an Initial vertex and a State or Mode. Connect them to describe the default entry.")].spacing(12)).padding(24).style(style::card));
|
||||
} else {
|
||||
let width = state
|
||||
.viewport
|
||||
.map_or((app.window_size.width - 600.).max(200.), |s| s.width);
|
||||
let scale = state.zoom.unwrap_or_else(|| {
|
||||
((width - 24.).max(1.) / scene.size.width)
|
||||
.min(440. / scene.size.height)
|
||||
.clamp(0.05, 1.)
|
||||
});
|
||||
scene.scale = scale;
|
||||
let size = Size::new(scene.size.width * scale, scene.size.height * scale);
|
||||
out = out
|
||||
.push(
|
||||
row![
|
||||
action("Fit", event(Event::Fit)),
|
||||
action("−", event(Event::Zoom(scale / 1.25))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
action("+", event(Event::Zoom(scale * 1.25))),
|
||||
action("100%", event(Event::Zoom(1.)))
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.push(
|
||||
iced::widget::sensor(
|
||||
container(
|
||||
scrollable(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(size.height.clamp(180., 460.)),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.on_show(|s| event(Event::Viewport(s)))
|
||||
.on_resize(|s| event(Event::Viewport(s))),
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
for r in p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::StateTransition && in_machine(p, r.source(), machine))
|
||||
{
|
||||
out = out.push(
|
||||
container(
|
||||
column![
|
||||
action(
|
||||
&format!("{} → {}", p.named(r.source()), p.named(r.target())),
|
||||
event(Event::Edit(r.id))
|
||||
)
|
||||
.width(Fill),
|
||||
text(transition_summary(p, r)).size(12),
|
||||
action("Properties", Message::EditProperties(r.id))
|
||||
}
|
||||
let parents = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
in_machine(p, e.id, machine)
|
||||
&& if d.tool == Tool::Vertex {
|
||||
e.kind() == Kind::Region
|
||||
} else {
|
||||
e.kind() == Kind::StateMachine || e.kind().is_stable_state()
|
||||
}
|
||||
})
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
form = form.push(picker(parents, d.parent, "Choose owner…", Event::Parent));
|
||||
form = form.push(label(if d.tool == Tool::Region { "Sibling regions are concurrent. Select a State or Mode to create a nested region." } else { "Incomplete vertices remain editable; Model checks explain the connections still needed." }));
|
||||
} else {
|
||||
let mut context = p.clone();
|
||||
if let Some(id) = d.original {
|
||||
context.relations.remove(&id);
|
||||
}
|
||||
let vertices: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind().is_state() && in_machine(p, e.id, machine))
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
let targets = vertices
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
d.source.is_some_and(|source| {
|
||||
if d.kind == TransitionKind::Internal {
|
||||
return c.id == source && p.elements[&source].kind().is_stable_state();
|
||||
}
|
||||
if d.kind == TransitionKind::Local
|
||||
&& p.elements[&source].kind().is_stable_state()
|
||||
{
|
||||
return c.id != source && in_machine(p, c.id, source);
|
||||
}
|
||||
validation::can_connect(&context, RelationKind::StateTransition, source, c.id)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
form = form
|
||||
.push(picker(vertices, d.source, "From state…", Event::Source))
|
||||
.push(picker(targets, d.target, "To state…", Event::Target))
|
||||
.push(
|
||||
pick_list(
|
||||
[
|
||||
TransitionChoice(TransitionKind::External),
|
||||
TransitionChoice(TransitionKind::Local),
|
||||
TransitionChoice(TransitionKind::Internal),
|
||||
],
|
||||
Some(TransitionChoice(d.kind)),
|
||||
|k| event(Event::Kind(k)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
let layer = p.elements[&machine].layer();
|
||||
let triggers = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.layer() == layer
|
||||
&& (e.kind() == Kind::Event || e.kind().is_work())
|
||||
&& !d.triggers.contains(&e.id)
|
||||
})
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
form = form.push(label("Trigger events / work (alternatives)"));
|
||||
for id in &d.triggers {
|
||||
form = form.push(
|
||||
row![
|
||||
text(p.named(*id)).size(12).width(Fill),
|
||||
action("Remove", event(Event::RemoveTrigger(*id)))
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(picker(
|
||||
triggers,
|
||||
None,
|
||||
"Add existing trigger…",
|
||||
Event::Trigger,
|
||||
))
|
||||
.push(
|
||||
checkbox(d.timed)
|
||||
.label("Add a time event with this transition")
|
||||
.on_toggle(|b| event(Event::Timed(b))),
|
||||
);
|
||||
if d.timed {
|
||||
form = form.push(
|
||||
row![
|
||||
pick_list(
|
||||
["After".to_string(), "At".to_string()],
|
||||
Some(format!("{:?}", d.time_kind)),
|
||||
|s| event(Event::TimeKind(s))
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12),
|
||||
text_input("Time expression, e.g. 30 s", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(
|
||||
text_input("Trigger notes (optional)", &d.trigger)
|
||||
.on_input(|s| event(Event::TriggerNotes(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
)
|
||||
.padding(12)
|
||||
.style(style::card),
|
||||
);
|
||||
.push(
|
||||
text_input("Guard condition (optional)", &d.guard)
|
||||
.on_input(|s| event(Event::Guard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
let effects = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.layer() == layer && e.kind().is_work() && !d.effects.contains(&e.id))
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
for id in &d.effects {
|
||||
form = form.push(
|
||||
row![
|
||||
text(p.named(*id)).size(12).width(Fill),
|
||||
action("Remove", event(Event::RemoveEffect(*id)))
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(picker(effects, None, "Add effect work…", Event::Effect))
|
||||
.push(
|
||||
text_input("Effect notes (optional)", &d.effect)
|
||||
.on_input(|s| event(Event::EffectNotes(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
if d.triggers.is_empty() && !d.timed && d.trigger.trim().is_empty() {
|
||||
form = form.push(label(
|
||||
"No event trigger: completion from a state, transient routing from a pseudostate.",
|
||||
));
|
||||
}
|
||||
}
|
||||
scrollable(out.padding(24)).into()
|
||||
let candidate = d.command(p).and_then(|command| {
|
||||
execute_batch(p, &[command])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
if let Err(error) = &candidate {
|
||||
form = form.push(text(error.clone()).size(12).color(style::MUTED));
|
||||
}
|
||||
form = form.push(
|
||||
row![
|
||||
button("Apply")
|
||||
.padding(9)
|
||||
.on_press_maybe(candidate.is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
Some(container(form).padding(4).into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -783,7 +634,7 @@ mod tests {
|
||||
assert_eq!(guard, "link unavailable");
|
||||
assert!(triggers.iter().any(|id| matches!(result.elements[id].shared(), Some(SharedBody::Event { body: EventBody::Time { kind:TimeEventKind::After, expression }}) if expression == "30 s")));
|
||||
assert_eq!(&language::parse(&language::format(result)).unwrap(), result);
|
||||
assert!(transition_summary(result, r).contains(" OR "));
|
||||
assert!(routing::state_transition_summary(result, r).contains(" OR "));
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
}
|
||||
@@ -841,14 +692,7 @@ mod tests {
|
||||
result.relations[&id].source(),
|
||||
result.relations[&id].target()
|
||||
);
|
||||
let scene = Scene::states(&result, machine, id);
|
||||
assert!(
|
||||
scene
|
||||
.boxes
|
||||
.iter()
|
||||
.any(|b| b.id == id && b.title.starts_with("INTERNAL"))
|
||||
);
|
||||
assert!(!scene.lines.iter().any(|l| l.id == id));
|
||||
assert!(!routing::state_transition_summary(&result, &result.relations[&id]).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn selected_composite_owns_new_region_and_history_uses_selected_region() {
|
||||
@@ -881,29 +725,6 @@ mod tests {
|
||||
.children(region)
|
||||
.any(|e| e.kind() == Kind::DeepHistory)
|
||||
);
|
||||
let mut scene = Scene::states(&result, machine, ready);
|
||||
let container = scene.boxes.iter().find(|b| b.id == ready).unwrap().rect;
|
||||
let child = scene.boxes.iter().find(|b| b.id == region).unwrap().rect;
|
||||
assert!(container.contains(child.position()));
|
||||
assert!(container.contains(Point::new(child.x + child.width, child.y + child.height)));
|
||||
let history = scene
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|b| b.marker == Some(Kind::DeepHistory))
|
||||
.unwrap();
|
||||
let id = history.id;
|
||||
let point = Point::new((history.rect.x + 16.) * 0.5, (history.rect.y + 28.) * 0.5);
|
||||
scene.scale = 0.5;
|
||||
let action = canvas::Program::update(
|
||||
&scene,
|
||||
&mut (),
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Rectangle::new(Point::ORIGIN, scene.size),
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(action, Some(Message::Select(selected)) if selected == id));
|
||||
assert_eq!(language::parse(&language::format(&result)).unwrap(), result);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user