Add persisted state diagram authoring and context-bound gestures
This commit is contained in:
@@ -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.,
|
||||
))),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user