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();
+14
View File
@@ -44,3 +44,17 @@
- Each checkout uses a distinct Cargo target directory. A shared-target Linux run
reused a different worktree's test executable; only explicitly rebuilt main
results count as evidence.
## Persisted state workspace and input coordinates (2026-09-06)
State-machine views use the core projection/routing and ordinary Document commands,
with a retained behavior owner/view separate from Architecture. Palette gestures
create elements plus placements atomically; typed forms remain reachable for Local,
Internal and timed transitions. Context menus capture the state diagram so fold,
hide and placement properties cannot modify an unrelated Architecture view.
Gesture displacement is measured in ordered window coordinates, not coordinates
relative to a canvas whose origin may move when selection changes the toolbar.
Viewport notifications fit only on actual size changes. Native testing also showed
Iced WGPU places parent meshes after clipped child meshes; backgrounds must share
their content clip to avoid hiding vector geometry while leaving text visible.
+51 -10
View File
@@ -12,7 +12,7 @@ Authoritative objective: attachment
PLAN.md, TASK_LEDGER.md and release-semantics-audit.md retain full release scope.
The latest user-directed drawing/port/menu/source-tree milestone is accepted at
`fef2e0d` plus the pending tooltip-layer follow-up. It does not complete the release.
`fef2e0d` plus tooltip-layer follow-up `ad7feb5`. It does not complete the release.
Root now integrates direct state-machine editing in an isolated worktree. There
is meaningful implementation, independent review and real runtime progress; no
external blocker or disk blocker exists.
@@ -108,18 +108,59 @@ Target: `D:/CodexBuilds/Capella2-state-integration-target`. Main core is not yet
strict workspace all-target Clippy passed. This baseline precedes direct gestures;
there is no native state-gesture acceptance yet.
Ownership: root owns App/behavior toolbar/context integration. drawing_correctness
owns only new behavior/state_edit.rs planners; drawing_linux owns only new
behavior/state_canvas.rs rendering/input. They coordinate explicit Context/Event
APIs and use ordinary commands/shared geometry. drawing_requirements completed
representation work and is idle for independent review. Root exclusively owns
native input. Do not edit another worker's files or share Cargo targets.
Direct-state UI integration is now implemented (uncommitted): persisted canvas,
creation within explicit regions, movement, direct transitions and endpoint
reconnection, pan/zoom/Fit, typed detail forms, and contextual fold/unfold/hide.
The old computed static state scene is removed. Source/model commands remain atomic.
Independent review found removed typed-form access, unreachable folded contents,
selection relayout altering drag deltas, fallback owner resetting the first drag,
and same-size viewport events refitting the camera. Fixes preserve typed creation,
use state-specific menus, window-space ordered gesture deltas, retained owner and
actual-size resize checks. App tests also exposed Architecture placement leaking
into Behavior selection; the two view selections are now separate.
First direct-state Windows verification:231 desktop tests and strict desktop
all-target Clippy passed, logs `D:/CodexBuilds/Capella2-state-direct-{tests,clippy}.log`.
Native trial2A4B5C11 created a new SA StateMachine and dedicated view; it reproduced
missing box fills/borders while text remained. Iced WGPU appends parent meshes after
clipped children: the opaque parent background covered every node. The fix moves
the content background into the clip and restricts the outer fill to the guidance
strip. Native re-verification remains required. Saved reproduction is
`ui-state-direct-01/project.arcadia`,42objects/25links/2 expected incomplete-machine
checks. Trial window1442250 received normal close after verified Save; re-inventory.
Latest verification:551 Windows workspace tests (277core,236desktop,38server)
and strict workspace all-target Clippy passed. A single let-and-return lint in the
new label hit helper was fixed without changing behavior. Subsequent compact-toolbar
changes passed desktop Clippy and build. Exact logs use `state-direct-workspace-*`
and `state-compact-*` under D:/CodexBuilds.
Native82AE5A2C closed the missing-shapes bug; root directly created Region, Initial,
State, first-drag Initial→State transition, selected without motion, moved/undid the
state, created Internal Monitor communications with generated After30s Event,
Undo/Redo of both, and Fold/Unfold via right-click. Saved trial46objects/27links/1
remaining model check uses ui-state-direct-01/project.arcadia; its original empty
machine fixture is preserved in baseline-before-editing. Minimum1100x700 exposed
excessive toolbar height; latest build removes selected-action row and shortens
state heading. Native recheck is pending, along with reconnection, nested composite,
Linux state runtime and independent final runtime review.
Additional review/native fixes: Fold retains semantic selection; placement property
Apply retains Behavior view without overwriting Architecture view. Initial names
choose among12 deterministic route-aware positions, and label hit testing uses the
same cached rectangle. This closes the observed rightward-line/name collision in
code; native verification of this last refinement remains pending.
Root owns integration/native input; all workers have returned files. Every worktree
uses its own Cargo target. Main remains ad7feb5; state work is isolated and not yet
accepted into main. No release-completion claim.
## Exact next actions
1. Commit the verified main tooltip follow-up and evidence, retaining source/GUI/API
parity. Latest Windows trial is saved; no final-release claim.
2. Integrate state-edit planners and actual persisted canvas with explicit retained
1. Rebuild and verify the state rendering fix on the saved native reproduction;
complete real state creation, connections, folding, form edit, Save/reopen and
minimum-size trials. Commit only after reviewed behavior is demonstrated.
2. Finish state-edit acceptance with explicit retained
behavior view, creation in a region, movement, transition drawing/reconnection,
guarded forms, discoverable missing placements, pan/zoom and contextual actions.
Build/run/test native at default/minimum sizes; review/reproduce/fix findings.