Add persisted state diagram authoring and context-bound gestures

This commit is contained in:
2026-09-06 03:02:38 +03:00
parent af31c8a11d
commit e0b22108a8
14 changed files with 4316 additions and 753 deletions
+229 -5
View File
@@ -53,7 +53,7 @@ impl Opened {
if !app.project().contains(request.object)
|| request
.canvas
.is_some_and(|c| Some(c) != app.route_context())
.is_some_and(|c| Some(c) != current_canvas(app))
{
return None;
}
@@ -65,7 +65,7 @@ impl Opened {
epoch: app.canvas_epoch,
page: app.page,
layer: app.layer,
diagram: app.active_diagram,
diagram: current_diagram(app),
})
}
pub fn valid(&self, app: &App) -> bool {
@@ -74,8 +74,12 @@ impl Opened {
&& self.epoch == app.canvas_epoch
&& self.page == app.page
&& self.layer == app.layer
&& self.diagram == app.active_diagram
&& self.diagram == current_diagram(app)
&& app.project().contains(self.request.object)
&& self
.request
.canvas
.is_none_or(|c| Some(c) == current_canvas(app))
&& self
.request
.source_file
@@ -84,6 +88,22 @@ impl Opened {
}
}
fn current_canvas(app: &App) -> Option<route_edit::Context> {
if app.page == Page::Behavior {
app.state_context()
} else {
app.route_context()
}
}
fn current_diagram(app: &App) -> Option<Id> {
if app.page == Page::Behavior {
app.state_context().map(|context| context.diagram)
} else {
app.active_diagram
}
}
pub fn open(object: Id, anchor: Point) -> Message {
Message::Menu(Event::Open(Request::object(object, anchor)))
}
@@ -100,6 +120,17 @@ pub fn trigger(app: &App) -> iced::Element<'_, Message> {
.filter(|(id, _)| *id == app.selected)
.map(|(_, i)| i);
request.canvas = app.route_context();
} else if app.page == Page::Behavior {
request.canvas = app.state_context();
if let Some(context) = request.canvas
&& let Some(node) = app.project().diagrams[&context.diagram]
.nodes
.values()
.find(|node| node.element == app.selected || node.id == app.selected)
{
request.object = node.element;
request.placement = Some(node.id);
}
}
Message::Menu(Event::Open(request))
},
@@ -112,8 +143,9 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
let request = &opened.request;
let p = app.project();
let id = request.object;
let clean =
!app.busy && (request.source_file.is_some() || (!app.draft_dirty() && !app.source_dirty()));
let clean = !app.busy
&& (request.source_file.is_some()
|| (!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending()));
let item = |caption: &str, message: Message, enabled: bool| {
if clean && enabled {
Entry::action(
@@ -151,6 +183,11 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
on_close: Message::Menu(Event::Close),
});
}
if app.page == Page::Behavior
&& let Some(menu) = crate::behavior::state_menu::popup(app, opened, clean)
{
return Some(menu);
}
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
if !clean {
entries.push(Entry::title("Apply or revert pending edits first"));
@@ -453,6 +490,193 @@ mod tests {
})
.unwrap_or_else(|| panic!("Expected enabled menu action {caption}"))
}
#[test]
fn state_menu_folds_and_hides_only_its_captured_placement_and_rejects_stale_views() {
use crate::behavior::state_edit;
let mut project = Project::empty();
let machine = project
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = project
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
.unwrap();
let state = project
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
.unwrap();
let architecture = project.ensure_diagram(Layer::Oa);
project.place(architecture, state, Position { x: 80., y: 90. });
let first = state_edit::create_view(&project, machine).unwrap();
let first_id = first.diagram;
project = execute_batch(&project, &[first.command]).unwrap();
let second = state_edit::create_view(&project, machine).unwrap();
let second_id = second.diagram;
project = execute_batch(&project, &[second.command]).unwrap();
let placement = project.diagrams[&first_id]
.nodes
.values()
.find(|node| node.element == state)
.unwrap()
.id;
let mut app = App::with_project(project.clone());
send(&mut app, Message::Select(machine));
send(&mut app, Message::Page(Page::Behavior));
app.behavior.diagram.diagram = Some(first_id);
app.active_diagram = Some(architecture);
let open_state = |app: &mut App| {
let mut request = Request::object(state, Point::ORIGIN);
request.canvas = app.state_context();
request.placement = Some(placement);
send(app, Message::Menu(Event::Open(request)));
};
// A canvas menu from Architecture is stale on the state page even when
// the architecture diagram itself remains the active architecture view.
let mut wrong = Request::object(state, Point::ORIGIN);
wrong.canvas = app.route_context();
assert!(Opened::new(&app, wrong, 1).is_none());
open_state(&mut app);
let fold = choose(&app, "Fold contents");
send(&mut app, fold);
assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed);
assert_eq!(app.selected, state, "Folding retains semantic selection");
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, first_id);
assert_eq!(app.active_diagram, Some(architecture));
assert_eq!(
app.project().diagrams[&architecture],
project.diagrams[&architecture]
);
assert_eq!(
app.project().diagrams[&second_id],
project.diagrams[&second_id]
);
assert_eq!(app.project().elements, project.elements);
open_state(&mut app);
let unfold = choose(&app, "Unfold contents");
send(&mut app, unfold);
assert_eq!(app.selected, state, "Unfolding retains semantic selection");
assert_eq!(app.project(), &project);
assert_eq!(app.active_diagram, Some(architecture));
send(&mut app, Message::Undo);
assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &project);
open_state(&mut app);
let stale = choose(&app, "Fold contents");
app.behavior.diagram.diagram = Some(second_id);
send(&mut app, stale);
assert_eq!(app.project(), &project);
app.behavior.diagram.diagram = Some(first_id);
open_state(&mut app);
let hide = choose(&app, "Hide from this diagram");
send(&mut app, hide);
assert!(
!app.project().diagrams[&first_id]
.nodes
.contains_key(&placement)
);
assert_eq!(
app.project().diagrams[&architecture],
project.diagrams[&architecture]
);
assert_eq!(
app.project().diagrams[&second_id],
project.diagrams[&second_id]
);
assert_eq!(app.project().elements, project.elements);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &project);
// Placement properties deliberately select a placement record, but an
// accepted numeric edit must keep the Behavior view's own context.
let source_before = app.document.bundle().clone();
open_state(&mut app);
let properties = choose(&app, "Placement properties");
send(&mut app, properties);
assert_eq!(app.selected, placement);
assert_eq!(
app.draft.as_ref().unwrap().kind,
crate::forms::RecordKind::Node(first_id)
);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.active_diagram, Some(architecture));
let x = project.diagrams[&first_id].nodes[&placement].position.x + 37.25;
send(
&mut app,
Message::Field("/position/x".into(), x.to_string()),
);
assert!(app.draft_dirty());
send(&mut app, Message::Apply);
assert!(!app.draft_dirty());
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.behavior.active_owner, Some(machine));
assert_eq!(app.state_context().unwrap().diagram, first_id);
assert_eq!(
app.active_diagram,
Some(architecture),
"Placement Apply must not replace the Architecture view"
);
let mut expected = project.clone();
expected
.diagrams
.get_mut(&first_id)
.unwrap()
.nodes
.get_mut(&placement)
.unwrap()
.position
.x = x;
assert_eq!(app.project(), &expected);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &project);
assert_eq!(app.document.bundle(), &source_before);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, first_id);
assert_eq!(app.active_diagram, Some(architecture));
}
#[test]
fn state_menu_keeps_typed_creation_reachable_and_preserves_pending_properties() {
let mut project = Project::empty();
let machine = project
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = project
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
.unwrap();
let entry = project
.add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(region))
.unwrap();
let mut app = App::with_project(project);
send(&mut app, Message::Select(entry));
send(&mut app, Message::Page(Page::Behavior));
send(&mut app, open(entry, Point::ORIGIN));
let start = choose(&app, "New transition with details…");
send(&mut app, start);
assert_eq!(app.behavior.pending_owner(), Some(machine));
send(
&mut app,
Message::Behavior(crate::behavior::Event::States(
crate::behavior::states::Event::Cancel,
)),
);
send(
&mut app,
Message::Field("/name".into(), "Unapplied entry".into()),
);
let before = app.draft.as_ref().unwrap().materialize().unwrap();
send(&mut app, open(entry, Point::ORIGIN));
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
entry,
Entry::Action {
message: Some(_),
..
}
)));
assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), before);
}
#[test]
fn right_click_targets_actor_without_changing_selection_until_action() {
let mut app = App::new();