Wire exact Scenario group selection and contextual admission

This commit is contained in:
2026-09-24 00:24:08 -07:00
parent 47917dd0ef
commit 879eb1d5c8
13 changed files with 1505 additions and 52 deletions
+165 -39
View File
@@ -19,6 +19,7 @@ pub struct Request {
pub creation_point: Option<Position>,
pub canvas: Option<route_edit::Context>,
pub process: Option<Box<crate::behavior::topology::Context>>,
pub scenario: Option<Box<crate::behavior::scenario::Context>>,
pub source_file: Option<String>,
}
impl Request {
@@ -34,6 +35,7 @@ impl Request {
creation_point: None,
canvas: None,
process: None,
scenario: None,
source_file: None,
}
}
@@ -55,6 +57,7 @@ pub struct Opened {
page: Page,
layer: Layer,
diagram: Option<Id>,
scenario_selection: Option<crate::diagram_selection::Snapshot>,
}
impl Opened {
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
@@ -67,9 +70,18 @@ impl Opened {
.process
.as_deref()
.is_some_and(|c| Some(*c) != app.topology_context())
|| request
.scenario
.as_deref()
.is_some_and(|c| Some(*c) != app.scenario_context())
{
return None;
}
let scenario_selection = if request.scenario.is_some() {
Some(app.multiselect_snapshot()?)
} else {
None
};
Some(Self {
token,
request,
@@ -79,6 +91,7 @@ impl Opened {
page: app.page,
layer: app.layer,
diagram: current_diagram(app),
scenario_selection,
})
}
pub fn valid(&self, app: &App) -> bool {
@@ -99,6 +112,15 @@ impl Opened {
.process
.as_deref()
.is_none_or(|c| Some(*c) == app.topology_context())
&& self
.request
.scenario
.as_deref()
.is_none_or(|c| Some(*c) == app.scenario_context())
&& self
.scenario_selection
.as_ref()
.is_none_or(|captured| app.multiselect_snapshot().as_ref() == Some(captured))
&& self
.request
.source_file
@@ -109,9 +131,19 @@ impl Opened {
fn target_matches(app: &App, request: &Request) -> bool {
use crate::diagram_usage::Target;
if request.scenario.is_some()
&& (request.canvas.is_some() || request.process.is_some() || request.placement.is_some())
{
return false;
}
let Some(target) = request.selection_target.as_deref() else {
return true;
};
if let Some(context) = request.scenario.as_deref() {
return Some(*context) == app.scenario_context()
&& matches!(target, Target::ScenarioObject(id) if *id == request.object)
&& app.multiselect_target_visible(target);
}
if (request.canvas.is_none() && request.process.is_none())
|| !app.multiselect_target_visible(target)
{
@@ -159,6 +191,17 @@ fn current_diagram(app: &App) -> Option<Id> {
if app.page == Page::Behavior {
app.topology_context()
.map(|context| context.owner)
.or_else(|| {
app.scenario_context()
.map(|context| match context.representation {
crate::behavior::scenario_layout::Representation::Derived { owner } => {
owner
}
crate::behavior::scenario_layout::Representation::Saved { diagram } => {
diagram
}
})
})
.or_else(|| app.state_context().map(|context| context.diagram))
} else {
app.active_diagram
@@ -172,49 +215,56 @@ pub fn open(object: Id, anchor: Point) -> Message {
pub fn trigger(app: &App) -> iced::Element<'_, Message> {
crate::context_menu::Area::left(
crate::views::action("Actions ▾", Message::Menu(Event::Close)),
move |anchor| {
let mut request = Request::object(app.selected, anchor);
if app.page == Page::Diagram {
request.placement = app.selected_node;
request.bend = app
.selected_bend
.filter(|(id, _)| *id == app.selected)
.map(|(_, i)| i);
request.canvas = app.route_context();
} else if app.page == Page::Behavior {
request.canvas = app.state_context();
request.process = app.topology_context().map(Box::new);
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
request.selection_target = Some(Box::new(target.clone()));
request.placement = match target {
crate::diagram_usage::Target::Placement(id) => {
if let Some(node) = request
.canvas
.and_then(|c| app.project().diagrams.get(&c.diagram))
.and_then(|d| d.nodes.get(id))
{
request.object = node.element;
}
Some(*id)
}
_ => None,
};
} else 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))
},
move |anchor| Message::Menu(Event::Open(actions_request(app, anchor))),
)
.into()
}
fn actions_request(app: &App, anchor: Point) -> Request {
let mut request = Request::object(app.selected, anchor);
if app.page == Page::Diagram {
request.placement = app.selected_node;
request.bend = app
.selected_bend
.filter(|(id, _)| *id == app.selected)
.map(|(_, i)| i);
request.canvas = app.route_context();
} else if app.page == Page::Behavior {
request.canvas = app.state_context();
request.process = app.topology_context().map(Box::new);
request.scenario = app.scenario_context().map(Box::new);
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
request.selection_target = Some(Box::new(target.clone()));
request.placement = match target {
crate::diagram_usage::Target::Placement(id) => {
if let Some(node) = request
.canvas
.and_then(|c| app.project().diagrams.get(&c.diagram))
.and_then(|d| d.nodes.get(id))
{
request.object = node.element;
}
Some(*id)
}
crate::diagram_usage::Target::ScenarioObject(id) => {
request.object = *id;
None
}
_ => None,
};
} else 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);
}
}
request
}
pub fn popup(app: &App) -> Option<Menu<Message>> {
let opened = app.model_menu.as_ref().filter(|menu| menu.valid(app))?;
let request = &opened.request;
@@ -261,6 +311,78 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
});
}
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
if let Some(context) = request.scenario.as_deref().copied() {
use crate::app::multiselect::Choice;
use crate::behavior::scenario::{CanvasEvent, selection};
let snapshot = opened.scenario_selection.as_ref()?;
let selection_message = |intent| {
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(selection::Event {
context,
snapshot: snapshot.clone(),
intent,
})))
};
if let Some(target) = request.selection_target.as_deref() {
let selected = snapshot.targets().contains(target);
entries.push(item(
if selected {
"Remove from selection"
} else {
"Add to selection"
},
selection_message(selection::Intent::Choose(
target.clone(),
if selected {
Choice::Remove
} else {
Choice::Add
},
)),
true,
));
if snapshot.targets().len() > 1 {
if selected {
entries.push(item(
"Inspect this element",
selection_message(selection::Intent::Choose(
target.clone(),
Choice::Inspect,
)),
true,
));
}
entries.push(item(
"Select only this element",
selection_message(selection::Intent::Choose(target.clone(), Choice::Replace)),
true,
));
}
entries.push(Entry::separator());
}
// A menu on an unselected subject must not expose singleton model actions
// while the canvas still depicts a group. Selection changes are explicit.
if snapshot.targets().len() > 1 {
entries.push(Entry::title(format!(
"{} selected",
snapshot.targets().len()
)));
entries.push(Entry::disabled(
"Move selected occurrences (not yet available)",
));
entries.push(Entry::disabled(
"Remove selected occurrences (not yet available)",
));
entries.push(Entry::title(
"Select one occurrence for its individual actions",
));
return Some(Menu {
token: opened.token,
anchor: request.anchor,
entries,
on_close: Message::Menu(Event::Close),
});
}
}
if (request.canvas.is_some() || request.process.is_some())
&& let Some(context) = app.multiselect_context()
{
@@ -798,6 +920,10 @@ fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)]
}
}
#[cfg(test)]
#[path = "model_menu_scenario_tests.rs"]
mod scenario_tests;
#[cfg(test)]
mod tests {
use super::*;