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
+199 -6
View File
@@ -1243,6 +1243,7 @@ struct Scene {
camera_pan: iced::Vector,
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
spacing_stamp: Option<scenario::spacing::Stamp>,
selection: Option<(scenario::Context, crate::diagram_selection::Snapshot)>,
coordinate_input: bool,
pending_placement: Option<(scenario::Slot, Option<Id>)>,
lanes: BTreeMap<Id, f32>,
@@ -1262,6 +1263,8 @@ struct SequenceSlot {
}
#[derive(Default)]
struct SequenceInput {
modifiers: iced::keyboard::Modifiers,
group_press: Option<ScenarioGroupPress>,
context: Option<scenario::Context>,
tool: scenario::CanvasTool,
pointer: Option<Point>,
@@ -1269,6 +1272,13 @@ struct SequenceInput {
source: Option<(Id, scenario::Slot)>,
press: Option<SequencePress>,
}
struct ScenarioGroupPress {
context: scenario::Context,
snapshot: crate::diagram_selection::Snapshot,
target: crate::diagram_usage::Target,
start: Point,
moved: bool,
}
#[derive(Clone, Copy)]
enum SequencePress {
Spacing {
@@ -1309,11 +1319,48 @@ impl SequenceInput {
cancel
}
fn cancel(&mut self) {
self.group_press = None;
self.source = None;
self.press = None;
}
}
impl Scene {
fn selection_targets(&self) -> Vec<crate::diagram_usage::Target> {
let mut ids = std::collections::BTreeSet::new();
ids.extend(
self.boxes
.iter()
.filter(|b| !b.title.is_empty())
.map(|b| b.id),
);
ids.extend(self.lines.iter().map(|line| line.id));
ids.extend(self.activations.iter().map(|segment| segment.call));
ids.extend(self.render_lanes.keys().copied());
ids.extend(self.lifecycle.selection_markers().map(|(id, _)| id));
ids.into_iter()
.map(crate::diagram_usage::Target::ScenarioObject)
.collect()
}
fn is_selected(&self, id: Id) -> bool {
self.selection
.as_ref()
.map_or(id == self.selected, |(_, snapshot)| {
snapshot
.targets()
.contains(&crate::diagram_usage::Target::ScenarioObject(id))
})
}
fn is_group(&self) -> bool {
self.selection
.as_ref()
.is_some_and(|(_, snapshot)| snapshot.targets().len() > 1)
}
fn selection_action(&self, intent: scenario::selection::Intent) -> Option<Action<Message>> {
let (context, snapshot) = self.selection.as_ref()?;
Some(
Action::publish(scenario::selection::message(*context, snapshot, intent)).and_capture(),
)
}
fn removal_target(&self, owner: Id, id: Id) -> bool {
id != owner
&& (self.boxes.iter().any(|item| item.id == id)
@@ -1340,6 +1387,7 @@ impl Scene {
selected,
interaction: None,
spacing_stamp: None,
selection: None,
coordinate_input: true,
pending_placement: None,
lanes: BTreeMap::new(),
@@ -1982,7 +2030,24 @@ impl canvas::Program<Message> for Scene {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
use crate::{app::multiselect::Choice, diagram_usage::Target};
use scenario::selection::Intent;
use scenario::{CanvasEvent, CanvasTool};
if let canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
state.modifiers = *modifiers;
return None;
}
if matches!(event, canvas::Event::Window(iced::window::Event::Unfocused)) {
state.modifiers = iced::keyboard::Modifiers::default();
}
if state.group_press.as_ref().is_some_and(|press| {
self.selection.as_ref().is_none_or(|(context, snapshot)| {
*context != press.context || *snapshot != press.snapshot
})
}) {
state.cancel();
}
let (context, tool, editable) = self
.interaction
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
@@ -2006,7 +2071,11 @@ impl canvas::Program<Message> for Scene {
}
}
state.bounds = Some(bounds);
if !editable {
state.group_press = None;
}
if (!editable || !self.coordinate_input)
&& matches!(state.press, Some(SequencePress::Spacing { .. }))
&& let Some(cancelled) = state.cancel_spacing()
{
return Some(cancelled);
@@ -2037,6 +2106,9 @@ impl canvas::Program<Message> for Scene {
let cursor = match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
state.pointer = Some(*position);
if let Some(press) = &mut state.group_press {
press.moved |= position.distance(press.start) >= 3.;
}
if let Some(press) = &mut state.press {
match press {
SequencePress::Message { start, moved, .. }
@@ -2075,6 +2147,9 @@ impl canvas::Program<Message> for Scene {
&& state.source.is_none() =>
{
let context = context?;
if self.is_group() {
return self.selection_action(Intent::Refuse("Removing multiple Scenario objects is being implemented; select one occurrence to remove it".into()));
}
if self.removal_target(context.scenario, self.selected) {
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Remove(
@@ -2089,7 +2164,10 @@ impl canvas::Program<Message> for Scene {
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) if state.source.is_some() || state.press.is_some() => {
}) if state.source.is_some()
|| state.press.is_some()
|| state.group_press.is_some() =>
{
return state
.cancel_spacing()
.or_else(|| Some(Action::request_redraw().and_capture()));
@@ -2103,6 +2181,18 @@ impl canvas::Program<Message> for Scene {
)
});
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) if editable => {
let (context, _) = self.selection.as_ref()?;
let hit = self.hit_item(world?)?;
let mut request = crate::model_menu::Request::object(hit.id, cursor.position()?);
request.scenario = Some(Box::new(*context));
request.selection_target = Some(Box::new(Target::ScenarioObject(hit.id)));
state.cancel();
Some(
Action::publish(Message::Menu(crate::model_menu::Event::Open(request)))
.and_capture(),
)
}
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if !visible {
return None;
@@ -2112,6 +2202,73 @@ impl canvas::Program<Message> for Scene {
if !editable {
return None;
}
if self.selection.is_some() {
let hit = self.hit_item(point).or_else(|| {
(!self.is_group()
&& self.is_selected(self.selected)
&& matches!(
context.representation,
scenario_layout::Representation::Derived { .. }
))
.then(|| {
self.reorder_handle()
.filter(|handle| handle.contains(point))
})
.flatten()
.map(|_| SceneHit {
id: self.selected,
role: SceneHitRole::Box,
})
});
if tool != CanvasTool::Select && self.is_group() {
return self.selection_action(Intent::Refuse(
"Select one occurrence before using an authoring tool".into(),
));
}
if tool == CanvasTool::Select {
if let Some(hit) = hit {
let target = Target::ScenarioObject(hit.id);
if state.modifiers.shift() {
state.cancel();
return self
.selection_action(Intent::Choose(target, Choice::Toggle));
}
if self.is_group() && self.is_selected(hit.id) {
let (context, snapshot) =
self.selection.as_ref().expect("selection capture");
state.group_press = Some(ScenarioGroupPress {
context: *context,
snapshot: snapshot.clone(),
target,
start: cursor.position()?,
moved: false,
});
return Some(Action::request_redraw().and_capture());
}
// Preserve the existing first-press singleton
// spacing/reorder gesture. Replacing a displayed
// group is an explicit selection change first.
if (self.is_group() && !self.is_selected(hit.id))
|| !self.coordinate_input
{
return self
.selection_action(Intent::Choose(target, Choice::Replace));
}
} else {
state.cancel();
if state.modifiers.shift() {
return Some(Action::capture());
}
let (context, snapshot) = self.selection.as_ref()?;
// Background clearing shares this press with camera panning.
return Some(Action::publish(scenario::selection::message(
*context,
snapshot,
Intent::Clear,
)));
}
}
}
if !self.coordinate_input {
return self.hit(point).map(|id| {
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
@@ -2214,6 +2371,27 @@ impl canvas::Program<Message> for Scene {
.map(|id| Action::publish(Message::Select(id)).and_capture())
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
if let Some(press) = state.group_press.take() {
if world.is_none() {
return Some(Action::request_redraw());
}
let intent = if press.moved {
Intent::Refuse(
"Group movement is being implemented; select one occurrence to edit it"
.into(),
)
} else {
Intent::Collapse(press.target)
};
return Some(
Action::publish(scenario::selection::message(
press.context,
&press.snapshot,
intent,
))
.and_capture(),
);
}
let press = state.press.take()?;
let context = context?;
match press {
@@ -2365,7 +2543,7 @@ impl canvas::Program<Message> for Scene {
}
}),
Stroke::default()
.with_color(if guide.id == self.selected {
.with_color(if self.is_selected(guide.id) {
style::TEAL
} else {
style::MUTED
@@ -2391,7 +2569,7 @@ impl canvas::Program<Message> for Scene {
f.stroke(
&path,
Stroke::default()
.with_color(if activation.call == self.selected {
.with_color(if self.is_selected(activation.call) {
style::TEAL
} else {
style::BOUNDARY
@@ -2408,7 +2586,7 @@ impl canvas::Program<Message> for Scene {
b.marker,
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
);
let color = if b.id == self.selected {
let color = if self.is_selected(b.id) {
style::TEAL
} else {
style::BOUNDARY
@@ -2430,7 +2608,7 @@ impl canvas::Program<Message> for Scene {
}
}
for l in self.lines.iter().filter(|line| !line.label.is_empty()) {
let color = if l.id == self.selected {
let color = if self.is_selected(l.id) {
style::TEAL
} else {
style::MUTED
@@ -2486,7 +2664,7 @@ impl canvas::Program<Message> for Scene {
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 {
color: if self.is_selected(b.id) {
style::TEAL
} else {
style::INK
@@ -2516,6 +2694,19 @@ impl canvas::Program<Message> for Scene {
}
self.lifecycle
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
if self.selection.is_some() {
// Lifecycle glyphs use their exact painted hit identity too.
for (id, point) in self.lifecycle.selection_markers() {
if self.is_selected(id) {
f.stroke(
&Path::circle(point, 12.),
Stroke::default()
.with_color(style::TEAL)
.with_width(if id == self.selected { 3. } else { 1.5 }),
);
}
}
}
if let Some((context, tool, editable)) = self.interaction {
let active = editable
&& self.coordinate_input
@@ -2542,6 +2733,8 @@ impl canvas::Program<Message> for Scene {
context.representation,
scenario_layout::Representation::Derived { .. }
)
&& !self.is_group()
&& self.is_selected(self.selected)
&& let Some(handle) = self.reorder_handle()
{
f.fill_rectangle(handle.position(), handle.size(), style::TEAL);