Add persisted state diagram authoring and context-bound gestures

This commit is contained in:
2026-09-06 03:02:38 +03:00
parent af31c8a11d
commit e0b22108a8
14 changed files with 4316 additions and 753 deletions
+114 -8
View File
@@ -14,6 +14,9 @@ use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
#[cfg(test)]
mod behavior_lifecycle_tests;
mod state_diagram;
#[cfg(test)]
mod state_diagram_tests;
#[derive(Debug, Clone)]
pub enum LoadedSnapshot {
@@ -138,6 +141,9 @@ pub enum Message {
Redo,
Diagram(Choice),
Projection(ProjectionKind),
BehaviorOwner(Choice),
BehaviorDiagram(Choice),
StateDiagram(crate::behavior::state_edit::Event),
PlaceSelected,
HideSelected,
ToggleCollapse,
@@ -382,16 +388,29 @@ impl App {
})
.flatten();
}
fn refresh_draft(&mut self) {
self.selected_node = self
.active_diagram
fn selected_architecture_placement(&self) -> Option<Id> {
self.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
.and_then(|d| {
d.nodes
.values()
.find(|n| n.id == self.selected || n.element == self.selected)
})
.map(|n| n.id);
.map(|n| n.id)
}
fn refresh_draft(&mut self) {
self.selected_node = if self.page == Page::Behavior {
None
} else {
self.selected_architecture_placement()
};
if let Some(context) = self.state_context() {
self.behavior.diagram.placement = self.project().diagrams[&context.diagram]
.nodes
.values()
.find(|n| n.id == self.selected || n.element == self.selected)
.map(|n| n.id);
}
self.draft = Draft::from_project(self.project(), self.selected);
if self.exact_strings
&& let Some(d) = &mut self.draft
@@ -507,6 +526,12 @@ impl App {
if self.active_diagram == Some(original) {
self.active_diagram = Some(id);
}
if self.behavior.active_owner == Some(original) {
self.behavior.active_owner = Some(id);
}
if self.behavior.diagram.diagram == Some(original) {
self.behavior.diagram.diagram = Some(id);
}
if self.selected_node == Some(original) {
self.selected_node = Some(id);
}
@@ -525,8 +550,17 @@ impl App {
.map(|d| (d.id, d.layer, d.nodes.contains_key(&id).then_some(id)))
{
self.layer = layer;
self.active_diagram = Some(diagram);
self.selected_node = node;
if self.page == Page::Behavior
&& let Some(machine) =
self.project().diagrams[&diagram].projection.state_machine()
{
self.behavior.active_owner = Some(machine);
self.behavior.diagram.diagram = Some(diagram);
self.behavior.diagram.placement = node;
} else {
self.active_diagram = Some(diagram);
self.selected_node = node;
}
}
self.sync();
self.status = "Properties applied".into();
@@ -608,6 +642,7 @@ impl App {
if !self.project().contains(id) {
return;
}
let previous_owner = crate::behavior::selected_owner(self);
self.selected = id;
self.selected_node = None;
if let Some(layer) = self.project().layer_of(id)
@@ -617,6 +652,15 @@ impl App {
self.active_diagram = self.diagram_for(layer, id);
self.fit();
}
if self.page == Page::Behavior
&& let Some(owner) = crate::behavior::selected_behavior(self.project(), id)
{
self.behavior.active_owner = Some(owner);
if previous_owner != Some(owner) {
self.behavior.diagram = Default::default();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
self.refresh_draft();
}
fn reveal_explorer_selection(&mut self) {
@@ -885,6 +929,37 @@ impl App {
}
fn handle(&mut self, message: Message) -> Task<Message> {
match message {
Message::StateDiagram(event) => return self.edit_state_diagram(event),
Message::BehaviorOwner(choice) => {
if self.apply()
&& self.project().elements.get(&choice.id).is_some_and(|e| {
e.layer() == self.layer
&& (e.kind().is_sequence() || e.kind() == Kind::StateMachine)
})
{
self.behavior.active_owner = Some(choice.id);
self.behavior.diagram = Default::default();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.select(choice.id);
self.page = Page::Behavior;
}
}
Message::BehaviorDiagram(choice) => {
if self.apply()
&& let Some(owner) = crate::behavior::selected_owner(self)
&& self
.project()
.diagrams
.get(&choice.id)
.is_some_and(|d| d.projection.state_machine() == Some(owner))
{
self.behavior.diagram.diagram = Some(choice.id);
self.behavior.diagram.tool = crate::behavior::state_edit::Tool::Select;
self.behavior.diagram.fit = true;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.fit_state_diagram();
}
}
Message::PortAnchor(event) => {
use crate::port_edit::Event;
let (captured, node, port, command) = match event {
@@ -970,7 +1045,11 @@ impl App {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
let mut request = opened.request;
if request.canvas.is_some() {
request.canvas = self.route_context();
request.canvas = if self.page == Page::Behavior {
self.state_context()
} else {
self.route_context()
};
}
self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence);
}
@@ -1120,7 +1199,21 @@ impl App {
}
Message::Page(page) => {
if page != Page::Text || !self.draft_dirty() || self.apply() {
if page == Page::Behavior && self.page != Page::Behavior {
if let Some(owner) =
crate::behavior::selected_behavior(self.project(), self.selected)
{
self.behavior.active_owner = Some(owner);
}
self.behavior.active_owner = crate::behavior::selected_owner(self);
self.behavior.diagram.fit = true;
}
self.page = page;
self.selected_node = if page == Page::Behavior {
None
} else {
self.selected_architecture_placement()
};
if page == Page::Text {
return self.request_lint();
}
@@ -1499,6 +1592,14 @@ impl App {
}
}
Message::Command(command) => {
let semantic_selection = match &command {
Command::EditNode { diagram, node, .. }
if self.state_context().is_some_and(|c| c.diagram == *diagram) =>
{
Some(node.element)
}
_ => None,
};
let original = self.selected;
if self.apply() {
let command = match command {
@@ -1508,7 +1609,12 @@ impl App {
}
command => command,
};
self.execute(command);
if self.execute(command)
&& let Some(element) = semantic_selection
{
self.selected = element;
self.refresh_draft();
}
}
}
Message::Delete => {
@@ -323,9 +323,6 @@ fn passive_behavior_canvas_events_do_not_apply_property_drafts() {
);
assert!(app.draft_dirty());
for event in [
behavior::Event::States(states::Event::Viewport(iced::Size::new(900., 600.))),
behavior::Event::States(states::Event::Zoom(0.7)),
behavior::Event::States(states::Event::Fit),
behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new(
900., 600.,
))),
+177
View File
@@ -0,0 +1,177 @@
//! Adapter from captured state-canvas events to the shared document transaction.
use super::*;
use crate::behavior::{
self,
state_edit::{self, Context, Event, Tool},
};
impl App {
pub fn state_context(&self) -> Option<Context> {
if self.page != Page::Behavior {
return None;
}
let owner = behavior::selected_owner(self)?;
let diagram = behavior::state_view::diagram(self, owner)?;
Some(Context {
document: self.canvas_document,
diagram,
revision: self.document.revision(),
epoch: self.canvas_epoch,
})
}
pub(super) fn fit_state_diagram(&mut self) {
let Some(context) = self.state_context() else {
return;
};
let diagram = &self.project().diagrams[&context.diagram];
let view = projection::project_diagram(self.project(), diagram);
let bounds = routing::route_diagram(self.project(), diagram, &view).bounds;
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
(self.window_size.width - 610.).max(280.),
(self.window_size.height - 320.).max(240.),
));
let width = (viewport.width - 48.).max(1.);
let height = (viewport.height - 80.).max(1.);
let zoom = (width / bounds.size.width.max(1.))
.min(height / bounds.size.height.max(1.))
.clamp(0.15, 1.25);
self.behavior.diagram.zoom = zoom;
self.behavior.diagram.pan = iced::Vector::new(
24. + (width - bounds.size.width * zoom) / 2. - bounds.position.x * zoom,
56. + (height - bounds.size.height * zoom) / 2. - bounds.position.y * zoom,
);
}
pub(super) fn edit_state_diagram(&mut self, event: Event) -> Task<Message> {
if self.page != Page::Behavior {
return Task::none();
}
if let Some(captured) = event.context() {
let Some(current) = self.state_context() else {
return Task::none();
};
if state_edit::validate_context(self.project(), captured, current).is_err() {
return Task::none();
}
}
match event {
Event::Viewport(_, size) => {
if size.width.is_finite()
&& size.height.is_finite()
&& size.width > 0.
&& size.height > 0.
{
let resized = self.behavior.diagram.viewport != Some(size);
self.behavior.diagram.viewport = Some(size);
if resized && self.behavior.diagram.fit {
self.fit_state_diagram();
}
}
}
Event::Fit(_) => {
self.behavior.diagram.fit = true;
self.fit_state_diagram();
}
Event::Zoom(_, zoom) if zoom.is_finite() => {
let state = &mut self.behavior.diagram;
let new = zoom.clamp(0.15, 3.);
let center = state.viewport.map_or(iced::Vector::new(300., 220.), |s| {
iced::Vector::new(s.width / 2., s.height / 2.)
});
state.pan = center - (center - state.pan) * (new / state.zoom);
state.zoom = new;
state.fit = false;
}
Event::Pan(_, delta) if delta.x.is_finite() && delta.y.is_finite() => {
self.behavior.diagram.pan += delta;
self.behavior.diagram.fit = false;
}
Event::Select(_, id) => {
if !self.behavior.has_pending() {
self.select(id);
self.selected_node = None;
}
}
Event::ContextMenu {
context,
object,
placement,
anchor,
} => {
let mut request = crate::model_menu::Request::object(object, anchor);
request.canvas = Some(context);
request.placement = placement;
return self.handle(Message::Menu(crate::model_menu::Event::Open(request)));
}
Event::EditTransition(_, id) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
return self.handle(Message::Behavior(behavior::Event::States(
behavior::states::Event::Edit(id),
)));
}
Event::Choose(tool) => {
if !self.apply() {
return Task::none();
}
self.behavior.diagram.tool = tool;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
Event::CreateView(machine) => {
if behavior::selected_owner(self) != Some(machine) || !self.apply() {
return Task::none();
}
match state_edit::create_view(self.project(), machine) {
Ok(plan) => {
if self.execute(plan.command) {
self.behavior.active_owner = Some(machine);
self.behavior.diagram.diagram = Some(plan.diagram);
self.behavior.diagram.placement = plan.placement;
self.behavior.diagram.tool = Tool::Select;
self.behavior.diagram.fit = true;
self.selected = plan.selected;
self.selected_node = None;
self.refresh_draft();
self.fit_state_diagram();
self.status = "State diagram created · existing model elements are placed in this view".into();
}
}
Err(error) => self.status = error.to_string(),
}
}
Event::Zoom(..) | Event::Pan(..) => {}
mutation => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before changing the state diagram.".into();
return Task::none();
}
let Some(context) = self.state_context() else {
return Task::none();
};
match state_edit::plan(self.project(), context, &mutation) {
Ok(plan) => {
if self.execute(plan.command) {
self.behavior.diagram.diagram = Some(plan.diagram);
self.behavior.diagram.placement = plan.placement;
self.selected = plan.selected;
self.selected_node = None;
self.refresh_draft();
if !matches!(mutation, Event::Move { .. }) {
self.behavior.diagram.tool = Tool::Select;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.status =
"State diagram updated · Undo restores the complete operation"
.into();
}
}
Err(error) => self.status = error.to_string(),
}
}
}
Task::none()
}
}
@@ -0,0 +1,753 @@
//! App transaction and draft boundaries for captured state-canvas messages.
use super::*;
use crate::behavior::{
self,
state_edit::{self, Context, Endpoint, Event, Tool},
states,
};
struct Fixture {
app: App,
machine: Id,
region: Id,
a: Id,
b: Id,
leaf: Id,
members: BTreeSet<Id>,
}
fn send(app: &mut App, message: Message) {
// Exercise the real App boundary without polling asynchronous I/O tasks.
let _ = app.update(message);
}
fn event(app: &mut App, event: Event) {
send(app, Message::StateDiagram(event));
}
fn form(app: &mut App, event: states::Event) {
send(app, Message::Behavior(behavior::Event::States(event)));
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let mut add = |kind, name, parent| project.add_element(kind, Layer::Sa, name, parent).unwrap();
let owner = add(Kind::System, "System", None);
let machine = add(Kind::StateMachine, "Operating modes", Some(owner));
let region = add(Kind::Region, "Main", Some(machine));
let initial = add(Kind::Initial, "Start", Some(region));
let a = add(Kind::State, "Operating", Some(region));
let b = add(Kind::State, "Standby", Some(region));
let nested = add(Kind::Region, "Work", Some(a));
let leaf = add(Kind::State, "Working", Some(nested));
let other_machine = add(Kind::StateMachine, "Other machine", None);
let other_region = add(Kind::Region, "Other region", Some(other_machine));
add(Kind::State, "Foreign state", Some(other_region));
let mut app = App::with_project(project);
send(&mut app, Message::Select(a));
send(&mut app, Message::Page(Page::Behavior));
assert_eq!(behavior::selected_owner(&app), Some(machine));
Fixture {
app,
machine,
region,
a,
b,
leaf,
members: BTreeSet::from([machine, region, initial, a, b, nested, leaf]),
}
}
fn create_view(f: &mut Fixture) -> Context {
event(&mut f.app, Event::CreateView(f.machine));
f.app
.state_context()
.expect("CreateView must activate the new persisted view")
}
fn node(app: &App, diagram: Id, element: Id) -> Id {
app.project().diagrams[&diagram]
.nodes
.values()
.find(|n| n.element == element)
.unwrap()
.id
}
fn assert_source_model_agree(app: &App) {
assert_eq!(
source_project::compile_bundle(app.document.bundle(), LIMITS)
.unwrap()
.decoded
.project,
*app.project()
);
assert_eq!(
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
*app.document.bundle()
);
assert!(!app.source_dirty());
}
#[test]
fn create_view_places_only_its_machine_and_restores_exact_source_in_one_undo() {
let mut f = fixture();
let before = f.app.document.snapshot();
let context = create_view(&mut f);
let app = &mut f.app;
let diagram = &app.project().diagrams[&context.diagram];
assert_eq!(
diagram.projection,
ProjectionKind::StateMachine { machine: f.machine }
);
assert_eq!(
diagram
.nodes
.values()
.map(|n| n.element)
.collect::<BTreeSet<_>>(),
f.members
);
assert_eq!(app.project().elements, before.decoded.project.elements);
assert_eq!(app.project().relations, before.decoded.project.relations);
assert_eq!(app.selected, f.machine);
assert!(app.selected_node.is_none());
assert_eq!(app.document.revision(), 1);
assert_source_model_agree(app);
let accepted = app.document.snapshot();
send(app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.dirty());
assert_source_model_agree(app);
send(app, Message::Redo);
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_source_model_agree(app);
}
#[test]
fn direct_create_connect_select_and_move_keep_semantic_identity_and_atomic_history() {
let mut f = fixture();
let context = create_view(&mut f);
let app = &mut f.app;
let before_create = app.document.snapshot();
event(
app,
Event::CreateAt {
context,
owner: f.region,
kind: Kind::State,
position: Position { x: 900., y: 700. },
},
);
let created = app.selected;
assert_eq!(app.project().elements[&created].parent, Some(f.region));
assert_eq!(app.project().elements[&created].kind(), Kind::State);
assert_eq!(
app.project().elements.len(),
before_create.decoded.project.elements.len() + 1
);
let created_node = node(app, context.diagram, created);
assert_ne!(created_node, created);
assert_eq!(
app.project().diagrams[&context.diagram].nodes[&created_node].position,
Position { x: 900., y: 700. }
);
assert_source_model_agree(app);
let before_connect = app.document.snapshot();
let context = app.state_context().unwrap();
event(
app,
Event::Connect {
context,
source: f.a,
target: created,
},
);
let relation = app.selected;
assert!(matches!(app.project().relations[&relation].body,
RelationBody::StateTransition { source, target, transition_kind: TransitionKind::External, .. }
if source == f.a && target == created));
assert!(app.selected_node.is_none());
assert_eq!(behavior::selected_owner(app), Some(f.machine));
assert_source_model_agree(app);
let context = app.state_context().unwrap();
let before_select = app.document.snapshot();
event(app, Event::Select(context, f.a));
assert_eq!(app.selected, f.a);
assert_eq!(
app.state_context(),
Some(context),
"The press selection must not invalidate its following drag release"
);
assert!(Arc::ptr_eq(&before_select, &app.document.snapshot()));
let placement = node(app, context.diagram, f.a);
let leaf = node(app, context.diagram, f.leaf);
let original = app.project().diagrams[&context.diagram].nodes[&placement].position;
let leaf_original = app.project().diagrams[&context.diagram].nodes[&leaf].position;
event(
app,
Event::Move {
context,
node: placement,
position: Position {
x: original.x + 31.,
y: original.y + 43.,
},
},
);
assert_eq!(app.selected, f.a);
assert!(app.selected_node.is_none());
assert_eq!(
app.project().diagrams[&context.diagram].nodes[&leaf].position,
Position {
x: leaf_original.x + 31.,
y: leaf_original.y + 43.
}
);
assert_eq!(
app.document.revision(),
4,
"View, vertex, edge, and drag each commit once"
);
assert_source_model_agree(app);
for expected in [before_select, before_connect, before_create] {
send(app, Message::Undo);
assert_eq!(app.project(), &expected.decoded.project);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_source_model_agree(app);
}
assert!(app.project().diagrams.contains_key(&context.diagram));
assert!(!app.project().contains(created));
assert!(!app.project().contains(relation));
}
#[test]
fn all_context_dimensions_reject_stale_selection_camera_and_model_messages() {
let mut f = fixture();
let context = create_view(&mut f);
let app = &mut f.app;
let before = app.document.snapshot();
let selected = app.selected;
let pan = app.behavior.diagram.pan;
let zoom = app.behavior.diagram.zoom;
let viewport = app.behavior.diagram.viewport;
let placement = node(app, context.diagram, f.a);
for changed in 0..4 {
let mut captured = context;
match changed {
0 => captured.document = Id::new_v4(),
1 => captured.diagram = Id::new_v4(),
2 => captured.revision += 1,
_ => captured.epoch += 1,
}
for stale in [
Event::Select(captured, f.b),
Event::Viewport(captured, iced::Size::new(999., 777.)),
Event::Pan(captured, iced::Vector::new(55., 66.)),
Event::Zoom(captured, 2.7),
Event::Move {
context: captured,
node: placement,
position: Position { x: 88., y: 99. },
},
Event::Connect {
context: captured,
source: f.a,
target: f.b,
},
Event::CreateAt {
context: captured,
owner: f.region,
kind: Kind::State,
position: Position { x: 800., y: 800. },
},
] {
event(app, stale);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.selected, selected);
assert_eq!(app.behavior.diagram.pan, pan);
assert_eq!(app.behavior.diagram.zoom, zoom);
assert_eq!(app.behavior.diagram.viewport, viewport);
assert_eq!(app.state_context(), Some(context));
assert!(!app.behavior.has_pending());
}
}
assert_source_model_agree(app);
}
#[test]
fn real_view_tool_and_history_changes_invalidate_old_release_events() {
let mut f = fixture();
let first = create_view(&mut f);
let second = create_view(&mut f);
let app = &mut f.app;
assert_ne!(first.diagram, second.diagram);
let first_choice = Choice {
id: first.diagram,
label: app.project().named(first.diagram).into(),
};
send(app, Message::BehaviorDiagram(first_choice));
let restored_view = app.state_context().unwrap();
assert_eq!(restored_view.diagram, first.diagram);
let before = app.document.snapshot();
for captured in [first, second] {
event(
app,
Event::Connect {
context: captured,
source: f.a,
target: f.b,
},
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
event(app, Event::Choose(Tool::Transition));
let armed = app.state_context().unwrap();
assert_ne!(restored_view.epoch, armed.epoch);
event(
app,
Event::Connect {
context: restored_view,
source: f.a,
target: f.b,
},
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
event(
app,
Event::Connect {
context: armed,
source: f.a,
target: f.b,
},
);
assert_eq!(
app.project().relations.len(),
before.decoded.project.relations.len() + 1
);
let committed = app.state_context().unwrap();
send(app, Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
let undone = app.document.snapshot();
event(
app,
Event::Connect {
context: armed,
source: f.a,
target: f.b,
},
);
event(
app,
Event::Connect {
context: committed,
source: f.a,
target: f.b,
},
);
assert!(Arc::ptr_eq(&undone, &app.document.snapshot()));
send(app, Message::Page(Page::Diagram));
assert!(app.state_context().is_none());
let diagram_count = app.project().diagrams.len();
event(app, Event::CreateView(f.machine));
assert_eq!(app.project().diagrams.len(), diagram_count);
assert!(Arc::ptr_eq(&undone, &app.document.snapshot()));
}
#[test]
fn rejected_creation_keeps_source_and_selection_then_valid_owner_can_retry() {
let mut f = fixture();
let context = create_view(&mut f);
let app = &mut f.app;
let before = app.document.snapshot();
let selected = app.selected;
event(
app,
Event::CreateAt {
context,
owner: f.a,
kind: Kind::State,
position: Position { x: 600., y: 700. },
},
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.selected, selected);
assert_eq!(app.state_context(), Some(context));
event(
app,
Event::CreateAt {
context,
owner: f.region,
kind: Kind::State,
position: Position { x: 600., y: 700. },
},
);
assert_eq!(app.project().elements[&app.selected].parent, Some(f.region));
assert_eq!(app.document.revision(), context.revision + 1);
assert_source_model_agree(app);
send(app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
}
#[test]
fn pending_drafts_block_every_direct_mutation_and_passive_camera_preserves_inputs() {
for pending in ["properties", "source", "behavior"] {
let mut f = fixture();
let context = create_view(&mut f);
event(
&mut f.app,
Event::Connect {
context,
source: f.a,
target: f.b,
},
);
let relation = f.app.selected;
// A valid Place request must have a genuinely absent placement.
let missing = f
.app
.project()
.make_element(Kind::State, Layer::Sa, "Not yet displayed", Some(f.region))
.unwrap();
let missing_id = missing.id;
send(
&mut f.app,
Message::Command(Command::CreateElement(missing)),
);
send(&mut f.app, Message::Select(f.a));
let app = &mut f.app;
let context = app.state_context().unwrap();
let placement = node(app, context.diagram, f.a);
let at = app.project().diagrams[&context.diagram].nodes[&placement].position;
let mutations = [
Event::CreateAt {
context,
owner: f.region,
kind: Kind::State,
position: Position { x: 700., y: 800. },
},
Event::Place {
context,
element: missing_id,
position: Position { x: 800., y: 900. },
},
Event::Move {
context,
node: placement,
position: Position {
x: at.x + 50.,
y: at.y + 70.,
},
},
Event::Connect {
context,
source: f.b,
target: f.a,
},
Event::Reconnect {
context,
relation,
endpoint: Endpoint::Target,
vertex: f.leaf,
},
];
for mutation in &mutations {
let plan = state_edit::plan(app.project(), context, mutation).unwrap();
assert_ne!(
&prepare_command(app.project(), &plan.command)
.unwrap()
.project,
app.project(),
"Each blocked probe must otherwise be a valid, substantive model change"
);
}
match pending {
"properties" => send(app, Message::Field("/name".into(), "Unapplied name".into())),
"source" => {
let draft = format!(
"{}\n// Unapplied source note.\n",
app.source_workspace.text()
);
send(app, Message::SourceEdit(text_editor::Action::SelectAll));
send(
app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
Arc::new(draft),
))),
);
}
_ => {
form(app, states::Event::Edit(relation));
form(app, states::Event::Name("Retained form name".into()));
form(app, states::Event::Guard("temperature nominal".into()));
form(app, states::Event::TriggerNotes("Retained trigger".into()));
}
}
assert_eq!(app.draft_dirty(), pending == "properties");
assert_eq!(app.source_dirty(), pending == "source");
assert_eq!(app.behavior.has_pending(), pending == "behavior");
let before = app.document.snapshot();
let source = app.source_workspace.bundle();
let properties = app.draft.as_ref().unwrap().materialize().unwrap();
let pending_candidate = app
.behavior
.prepare_pending(app.project())
.unwrap()
.map(|command| prepare_command(app.project(), &command).unwrap().project);
let selected = app.selected;
for mutation in mutations {
event(app, mutation);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()), "{pending}");
assert_eq!(app.selected, selected);
assert_eq!(app.source_workspace.bundle(), source);
assert_eq!(
app.draft.as_ref().unwrap().materialize().unwrap(),
properties
);
}
for passive in [
Event::Viewport(context, iced::Size::new(960., 640.)),
Event::Pan(context, iced::Vector::new(12., 18.)),
Event::Zoom(context, 0.7),
Event::Fit(context),
] {
event(app, passive);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()), "{pending}");
assert_eq!(app.source_workspace.bundle(), source);
assert_eq!(
app.draft.as_ref().unwrap().materialize().unwrap(),
properties
);
}
assert_eq!(
app.behavior
.prepare_pending(app.project())
.unwrap()
.map(|command| prepare_command(app.project(), &command).unwrap().project),
pending_candidate
);
match pending {
"properties" => send(app, Message::RevertProperties),
"source" => send(app, Message::RevertSource),
_ => form(app, states::Event::Cancel),
}
assert!(!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending());
let context = app.state_context().unwrap();
event(
app,
Event::Move {
context,
node: placement,
position: Position {
x: at.x + 50.,
y: at.y + 70.,
},
},
);
assert_eq!(app.selected, f.a);
assert_ne!(app.document.bundle(), &before.bundle);
assert_source_model_agree(app);
send(app, Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
}
}
#[test]
fn fallback_owner_first_selection_keeps_the_captured_context_for_its_first_move() {
let f = fixture();
let mut project = f.app.project().clone();
// One eligible machine makes the fallback deterministic. Start from a loaded
// project with no selected behavior element and no explicitly chosen owner.
project
.elements
.retain(|id, element| f.members.contains(id) || element.kind() == Kind::System);
let plan = state_edit::create_view(&project, f.machine).unwrap();
let project = prepare_command(&project, &plan.command).unwrap().project;
let mut app = App::with_project(project);
assert_eq!(app.selected, app.project().id);
assert!(app.behavior.active_owner.is_none());
send(&mut app, Message::Page(Page::Behavior));
let context = app.state_context().unwrap();
assert_eq!(context.diagram, plan.diagram);
assert_eq!(behavior::selected_owner(&app), Some(f.machine));
let before = app.document.snapshot();
let placement = node(&app, context.diagram, f.a);
let original = app.project().diagrams[&context.diagram].nodes[&placement].position;
event(&mut app, Event::Select(context, f.a));
assert_eq!(app.selected, f.a);
assert_eq!(
app.state_context(),
Some(context),
"Pinning the already-visible fallback owner must not advance the gesture epoch"
);
assert_eq!(app.behavior.diagram.placement, Some(placement));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
let destination = Position {
x: original.x + 37.,
y: original.y + 29.,
};
event(
&mut app,
Event::Move {
context,
node: placement,
position: destination,
},
);
assert_eq!(
app.project().diagrams[&context.diagram].nodes[&placement].position,
destination
);
assert_eq!(app.selected, f.a);
assert_eq!(
app.document.revision(),
1,
"The first release must commit without another press"
);
assert_source_model_agree(&app);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn same_size_viewport_after_revision_or_tool_epoch_does_not_refit_the_camera() {
let mut f = fixture();
let context = create_view(&mut f);
let app = &mut f.app;
let size = iced::Size::new(960., 640.);
event(app, Event::Viewport(context, size));
assert!(app.behavior.diagram.fit);
let camera = (app.behavior.diagram.pan, app.behavior.diagram.zoom);
let placement = node(app, context.diagram, f.a);
// Move far enough to change projected bounds. Refitting now would visibly
// jump the camera even though the canvas has not changed size.
event(
app,
Event::Move {
context,
node: placement,
position: Position { x: 4000., y: 3000. },
},
);
let changed = app.state_context().unwrap();
assert_ne!(changed.revision, context.revision);
assert_eq!(
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
camera
);
assert!(app.behavior.diagram.fit);
let after_move = app.document.snapshot();
event(app, Event::Viewport(changed, size));
assert_eq!(
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
camera,
"Context initialization is not a canvas resize"
);
event(app, Event::Choose(Tool::Transition));
let changed_tool = app.state_context().unwrap();
assert_ne!(changed_tool.epoch, changed.epoch);
event(app, Event::Viewport(changed_tool, size));
assert_eq!(
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
camera
);
assert!(Arc::ptr_eq(&after_move, &app.document.snapshot()));
// A real resize still uses automatic fitting while fit mode is active.
event(
app,
Event::Viewport(changed_tool, iced::Size::new(1200., 720.)),
);
assert_ne!(
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
camera
);
assert!(Arc::ptr_eq(&after_move, &app.document.snapshot()));
assert_source_model_agree(app);
}
#[test]
fn shared_semantic_states_keep_architecture_and_behavior_placements_independent() {
let f = fixture();
let mut project = f.app.project().clone();
let architecture = project.ensure_diagram(Layer::Sa);
let arch_a = project.place(architecture, f.a, Position { x: 110., y: 90. });
let arch_b = project.place(architecture, f.b, Position { x: 780., y: 240. });
project.place(architecture, f.leaf, Position { x: 190., y: 310. });
let state_view = state_edit::create_view(&project, f.machine).unwrap();
let project = prepare_command(&project, &state_view.command)
.unwrap()
.project;
let architecture_before = project.diagrams[&architecture].clone();
let mut app = App::with_project(project);
let architecture_choice = Choice {
id: architecture,
label: app.project().named(architecture).into(),
};
send(&mut app, Message::Diagram(architecture_choice));
send(&mut app, Message::Select(f.b));
assert_eq!(app.active_diagram, Some(architecture));
assert_eq!(app.selected_node, Some(arch_b));
send(&mut app, Message::Page(Page::Behavior));
let context = app.state_context().unwrap();
assert_eq!(context.diagram, state_view.diagram);
let state_a = node(&app, context.diagram, f.a);
assert_ne!(state_a, arch_a);
event(&mut app, Event::Select(context, f.a));
assert_eq!(app.selected, f.a);
assert!(
app.selected_node.is_none(),
"Behavior must not borrow the architecture placement"
);
assert_eq!(app.behavior.diagram.placement, Some(state_a));
assert_eq!(app.active_diagram, Some(architecture));
let before_move = app.document.snapshot();
let original = app.project().diagrams[&context.diagram].nodes[&state_a].position;
let destination = Position {
x: original.x + 41.,
y: original.y + 63.,
};
event(
&mut app,
Event::Move {
context,
node: state_a,
position: destination,
},
);
assert_eq!(
app.project().diagrams[&context.diagram].nodes[&state_a].position,
destination
);
assert_eq!(app.project().diagrams[&architecture], architecture_before);
assert_eq!(app.selected, f.a);
assert!(app.selected_node.is_none());
assert_eq!(app.behavior.diagram.placement, Some(state_a));
assert_source_model_agree(&app);
let moved = app.document.snapshot();
send(&mut app, Message::Page(Page::Diagram));
assert_eq!(app.active_diagram, Some(architecture));
assert_eq!(app.selected, f.a);
assert_eq!(
app.selected_node,
Some(arch_a),
"Returning to architecture resolves that view's placement of the same state"
);
assert!(Arc::ptr_eq(&moved, &app.document.snapshot()));
send(&mut app, Message::Page(Page::Behavior));
assert!(app.selected_node.is_none());
assert_eq!(app.behavior.diagram.placement, Some(state_a));
send(&mut app, Message::Undo);
assert_eq!(app.project(), &before_move.decoded.project);
assert_eq!(app.document.bundle(), &before_move.bundle);
assert_eq!(app.project().diagrams[&architecture], architecture_before);
assert_eq!(app.behavior.diagram.placement, Some(state_a));
assert!(!app.document.can_undo());
}
+34 -54
View File
@@ -15,7 +15,10 @@ use iced::{
use oa_core::*;
use std::collections::BTreeMap;
pub(crate) mod scenario;
mod state_scene;
pub(crate) mod state_canvas;
pub(crate) mod state_edit;
pub(crate) mod state_menu;
pub(crate) mod state_view;
pub(crate) mod states;
pub(crate) mod topology;
#[derive(Debug, Clone)]
@@ -27,7 +30,8 @@ pub enum Event {
}
impl Event {
pub fn starts_form(&self) -> bool {
matches!(self,
matches!(
self,
Self::Topology(topology::Event::Start(..))
| Self::Scenario(scenario::Event::Start(..))
| Self::States(states::Event::Start(..) | states::Event::Edit(..))
@@ -45,6 +49,8 @@ impl Event {
}
#[derive(Default)]
pub struct State {
pub active_owner: Option<Id>,
pub diagram: state_view::State,
topology: topology::State,
scenario: scenario::State,
states: states::State,
@@ -130,7 +136,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
label: format!("{} · {}", e.kind(), e.name),
})
.collect::<Vec<_>>();
let owner = app.behavior.pending_owner().or_else(|| selected_behavior(p, app.selected));
let owner = selected_owner(app);
let selected = owner
.and_then(|id| choices.iter().find(|c| c.id == id))
.or(choices.first())
@@ -140,8 +146,11 @@ pub fn view(app: &App) -> Element<'_, Message> {
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];
if e.kind() == Kind::StateMachine {
out = column![text("States & modes").size(20)].spacing(8);
}
out = out.push(
pick_list(choices, Some(selected), |c| Message::Select(c.id))
pick_list(choices, Some(selected), Message::BehaviorOwner)
.width(Fill)
.text_size(13)
.padding(10),
@@ -152,7 +161,20 @@ pub fn view(app: &App) -> Element<'_, Message> {
if e.kind() == Kind::Scenario {
return scenario::view(app, e.id, out);
}
states::view(app, e.id, out)
state_view::view(app, e.id, out)
}
pub fn selected_owner(app: &App) -> Option<Id> {
let valid = |id: &Id| {
app.project().elements.get(id).is_some_and(|e| {
e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine)
})
};
app.behavior
.pending_owner()
.or(app.behavior.active_owner.filter(valid))
.or_else(|| selected_behavior(app.project(), app.selected).filter(valid))
.or_else(|| app.project().elements.keys().find(|id| valid(id)).copied())
}
/// Mirror the core's legacy-versus-explicit process interpretation. Existing
/// exchange-only lists still describe ordered paths until explicitly migrated.
@@ -179,7 +201,7 @@ fn uses_explicit_topology(p: &Project, owner: Id) -> bool {
)
})
}
fn selected_behavior(p: &Project, selected: Id) -> Option<Id> {
pub fn selected_behavior(p: &Project, selected: Id) -> Option<Id> {
let mut current = Some(
p.relations
.get(&selected)
@@ -570,12 +592,9 @@ impl canvas::Program<Message> for Scene {
) && 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.))
{
if let Some(b) = self.boxes.iter().rev().find(|b| {
b.rect.contains(p) && (b.marker != Some(Kind::Occurrence) || 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| {
@@ -620,10 +639,7 @@ impl canvas::Program<Message> for Scene {
&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())
{
} else {
let path = Path::rounded_rectangle(b.rect.position(), b.rect.size(), 8.into());
f.fill(&path, Color::from_rgb8(243, 248, 248));
f.stroke(
@@ -686,45 +702,9 @@ impl canvas::Program<Message> for Scene {
}
}
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. },
),
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
position: Point::new(b.rect.x + 12., b.rect.y + 12.),
size: 13.into(),
color: if b.id == self.selected {
style::TEAL
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
}
}
+207 -386
View File
@@ -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);
}
}
+229 -5
View File
@@ -53,7 +53,7 @@ impl Opened {
if !app.project().contains(request.object)
|| request
.canvas
.is_some_and(|c| Some(c) != app.route_context())
.is_some_and(|c| Some(c) != current_canvas(app))
{
return None;
}
@@ -65,7 +65,7 @@ impl Opened {
epoch: app.canvas_epoch,
page: app.page,
layer: app.layer,
diagram: app.active_diagram,
diagram: current_diagram(app),
})
}
pub fn valid(&self, app: &App) -> bool {
@@ -74,8 +74,12 @@ impl Opened {
&& self.epoch == app.canvas_epoch
&& self.page == app.page
&& self.layer == app.layer
&& self.diagram == app.active_diagram
&& self.diagram == current_diagram(app)
&& app.project().contains(self.request.object)
&& self
.request
.canvas
.is_none_or(|c| Some(c) == current_canvas(app))
&& self
.request
.source_file
@@ -84,6 +88,22 @@ impl Opened {
}
}
fn current_canvas(app: &App) -> Option<route_edit::Context> {
if app.page == Page::Behavior {
app.state_context()
} else {
app.route_context()
}
}
fn current_diagram(app: &App) -> Option<Id> {
if app.page == Page::Behavior {
app.state_context().map(|context| context.diagram)
} else {
app.active_diagram
}
}
pub fn open(object: Id, anchor: Point) -> Message {
Message::Menu(Event::Open(Request::object(object, anchor)))
}
@@ -100,6 +120,17 @@ pub fn trigger(app: &App) -> iced::Element<'_, Message> {
.filter(|(id, _)| *id == app.selected)
.map(|(_, i)| i);
request.canvas = app.route_context();
} else if app.page == Page::Behavior {
request.canvas = app.state_context();
if let Some(context) = request.canvas
&& let Some(node) = app.project().diagrams[&context.diagram]
.nodes
.values()
.find(|node| node.element == app.selected || node.id == app.selected)
{
request.object = node.element;
request.placement = Some(node.id);
}
}
Message::Menu(Event::Open(request))
},
@@ -112,8 +143,9 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
let request = &opened.request;
let p = app.project();
let id = request.object;
let clean =
!app.busy && (request.source_file.is_some() || (!app.draft_dirty() && !app.source_dirty()));
let clean = !app.busy
&& (request.source_file.is_some()
|| (!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending()));
let item = |caption: &str, message: Message, enabled: bool| {
if clean && enabled {
Entry::action(
@@ -151,6 +183,11 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
on_close: Message::Menu(Event::Close),
});
}
if app.page == Page::Behavior
&& let Some(menu) = crate::behavior::state_menu::popup(app, opened, clean)
{
return Some(menu);
}
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
if !clean {
entries.push(Entry::title("Apply or revert pending edits first"));
@@ -453,6 +490,193 @@ mod tests {
})
.unwrap_or_else(|| panic!("Expected enabled menu action {caption}"))
}
#[test]
fn state_menu_folds_and_hides_only_its_captured_placement_and_rejects_stale_views() {
use crate::behavior::state_edit;
let mut project = Project::empty();
let machine = project
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = project
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
.unwrap();
let state = project
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
.unwrap();
let architecture = project.ensure_diagram(Layer::Oa);
project.place(architecture, state, Position { x: 80., y: 90. });
let first = state_edit::create_view(&project, machine).unwrap();
let first_id = first.diagram;
project = execute_batch(&project, &[first.command]).unwrap();
let second = state_edit::create_view(&project, machine).unwrap();
let second_id = second.diagram;
project = execute_batch(&project, &[second.command]).unwrap();
let placement = project.diagrams[&first_id]
.nodes
.values()
.find(|node| node.element == state)
.unwrap()
.id;
let mut app = App::with_project(project.clone());
send(&mut app, Message::Select(machine));
send(&mut app, Message::Page(Page::Behavior));
app.behavior.diagram.diagram = Some(first_id);
app.active_diagram = Some(architecture);
let open_state = |app: &mut App| {
let mut request = Request::object(state, Point::ORIGIN);
request.canvas = app.state_context();
request.placement = Some(placement);
send(app, Message::Menu(Event::Open(request)));
};
// A canvas menu from Architecture is stale on the state page even when
// the architecture diagram itself remains the active architecture view.
let mut wrong = Request::object(state, Point::ORIGIN);
wrong.canvas = app.route_context();
assert!(Opened::new(&app, wrong, 1).is_none());
open_state(&mut app);
let fold = choose(&app, "Fold contents");
send(&mut app, fold);
assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed);
assert_eq!(app.selected, state, "Folding retains semantic selection");
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, first_id);
assert_eq!(app.active_diagram, Some(architecture));
assert_eq!(
app.project().diagrams[&architecture],
project.diagrams[&architecture]
);
assert_eq!(
app.project().diagrams[&second_id],
project.diagrams[&second_id]
);
assert_eq!(app.project().elements, project.elements);
open_state(&mut app);
let unfold = choose(&app, "Unfold contents");
send(&mut app, unfold);
assert_eq!(app.selected, state, "Unfolding retains semantic selection");
assert_eq!(app.project(), &project);
assert_eq!(app.active_diagram, Some(architecture));
send(&mut app, Message::Undo);
assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &project);
open_state(&mut app);
let stale = choose(&app, "Fold contents");
app.behavior.diagram.diagram = Some(second_id);
send(&mut app, stale);
assert_eq!(app.project(), &project);
app.behavior.diagram.diagram = Some(first_id);
open_state(&mut app);
let hide = choose(&app, "Hide from this diagram");
send(&mut app, hide);
assert!(
!app.project().diagrams[&first_id]
.nodes
.contains_key(&placement)
);
assert_eq!(
app.project().diagrams[&architecture],
project.diagrams[&architecture]
);
assert_eq!(
app.project().diagrams[&second_id],
project.diagrams[&second_id]
);
assert_eq!(app.project().elements, project.elements);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &project);
// Placement properties deliberately select a placement record, but an
// accepted numeric edit must keep the Behavior view's own context.
let source_before = app.document.bundle().clone();
open_state(&mut app);
let properties = choose(&app, "Placement properties");
send(&mut app, properties);
assert_eq!(app.selected, placement);
assert_eq!(
app.draft.as_ref().unwrap().kind,
crate::forms::RecordKind::Node(first_id)
);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.active_diagram, Some(architecture));
let x = project.diagrams[&first_id].nodes[&placement].position.x + 37.25;
send(
&mut app,
Message::Field("/position/x".into(), x.to_string()),
);
assert!(app.draft_dirty());
send(&mut app, Message::Apply);
assert!(!app.draft_dirty());
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.behavior.active_owner, Some(machine));
assert_eq!(app.state_context().unwrap().diagram, first_id);
assert_eq!(
app.active_diagram,
Some(architecture),
"Placement Apply must not replace the Architecture view"
);
let mut expected = project.clone();
expected
.diagrams
.get_mut(&first_id)
.unwrap()
.nodes
.get_mut(&placement)
.unwrap()
.position
.x = x;
assert_eq!(app.project(), &expected);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &project);
assert_eq!(app.document.bundle(), &source_before);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, first_id);
assert_eq!(app.active_diagram, Some(architecture));
}
#[test]
fn state_menu_keeps_typed_creation_reachable_and_preserves_pending_properties() {
let mut project = Project::empty();
let machine = project
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = project
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
.unwrap();
let entry = project
.add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(region))
.unwrap();
let mut app = App::with_project(project);
send(&mut app, Message::Select(entry));
send(&mut app, Message::Page(Page::Behavior));
send(&mut app, open(entry, Point::ORIGIN));
let start = choose(&app, "New transition with details…");
send(&mut app, start);
assert_eq!(app.behavior.pending_owner(), Some(machine));
send(
&mut app,
Message::Behavior(crate::behavior::Event::States(
crate::behavior::states::Event::Cancel,
)),
);
send(
&mut app,
Message::Field("/name".into(), "Unapplied entry".into()),
);
let before = app.draft.as_ref().unwrap().materialize().unwrap();
send(&mut app, open(entry, Point::ORIGIN));
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
entry,
Entry::Action {
message: Some(_),
..
}
)));
assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), before);
}
#[test]
fn right_click_targets_actor_without_changing_selection_until_action() {
let mut app = App::new();