Add explicit scenario group reorder App interaction and guarded preview
This commit is contained in:
@@ -26,6 +26,7 @@ mod review_navigation_tests;
|
|||||||
mod scenario_details_tests;
|
mod scenario_details_tests;
|
||||||
mod scenario_diagram;
|
mod scenario_diagram;
|
||||||
mod scenario_multiselect;
|
mod scenario_multiselect;
|
||||||
|
pub(crate) mod scenario_reorder;
|
||||||
mod scenario_spacing;
|
mod scenario_spacing;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod source_indentation_tests;
|
mod source_indentation_tests;
|
||||||
@@ -441,6 +442,7 @@ pub enum Message {
|
|||||||
BehaviorDiagram(Choice),
|
BehaviorDiagram(Choice),
|
||||||
StateDiagram(crate::behavior::state_edit::Event),
|
StateDiagram(crate::behavior::state_edit::Event),
|
||||||
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
|
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
|
||||||
|
ScenarioReorder(Box<scenario_reorder::Event>),
|
||||||
TopologyCanvas(crate::behavior::topology::CanvasEvent),
|
TopologyCanvas(crate::behavior::topology::CanvasEvent),
|
||||||
PlaceSelected,
|
PlaceSelected,
|
||||||
HideSelected,
|
HideSelected,
|
||||||
@@ -527,6 +529,7 @@ pub struct App {
|
|||||||
pub guide: crate::guide::State,
|
pub guide: crate::guide::State,
|
||||||
pub traceability: crate::views::traceability::State,
|
pub traceability: crate::views::traceability::State,
|
||||||
pub behavior: crate::behavior::State,
|
pub behavior: crate::behavior::State,
|
||||||
|
pub(crate) scenario_reorder: scenario_reorder::State,
|
||||||
pub data_state: crate::data_view::State,
|
pub data_state: crate::data_view::State,
|
||||||
pub inspector_tab: crate::navigation::InspectorTab,
|
pub inspector_tab: crate::navigation::InspectorTab,
|
||||||
pub layer: Layer,
|
pub layer: Layer,
|
||||||
@@ -649,6 +652,7 @@ impl App {
|
|||||||
guide: crate::guide::State::default(),
|
guide: crate::guide::State::default(),
|
||||||
traceability: Default::default(),
|
traceability: Default::default(),
|
||||||
behavior: Default::default(),
|
behavior: Default::default(),
|
||||||
|
scenario_reorder: Default::default(),
|
||||||
data_state: Default::default(),
|
data_state: Default::default(),
|
||||||
inspector_tab: Default::default(),
|
inspector_tab: Default::default(),
|
||||||
layer,
|
layer,
|
||||||
@@ -1352,6 +1356,11 @@ impl App {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
|
// Captured reorder packets must be admitted before shared navigation,
|
||||||
|
// camera or source boundaries can disturb a newer operation.
|
||||||
|
if let Message::ScenarioReorder(event) = message {
|
||||||
|
return self.scenario_reorder_event(*event);
|
||||||
|
}
|
||||||
if let Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Selection(event)) = message
|
if let Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Selection(event)) = message
|
||||||
{
|
{
|
||||||
return self.scenario_selection_event(*event);
|
return self.scenario_selection_event(*event);
|
||||||
@@ -1664,6 +1673,7 @@ impl App {
|
|||||||
if preview_read_only && self.process_preview_primary().is_none() && self.draft.is_none() {
|
if preview_read_only && self.process_preview_primary().is_none() && self.draft.is_none() {
|
||||||
self.refresh_draft();
|
self.refresh_draft();
|
||||||
}
|
}
|
||||||
|
self.scenario_reorder_reconcile();
|
||||||
task
|
task
|
||||||
}
|
}
|
||||||
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
|
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
|
||||||
@@ -2214,6 +2224,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
Message::CheckpointLoaded(result) => return self.checkpoint_loaded(result),
|
Message::CheckpointLoaded(result) => return self.checkpoint_loaded(result),
|
||||||
Message::Hosted(event) => return self.hosted_event(event),
|
Message::Hosted(event) => return self.hosted_event(event),
|
||||||
|
Message::ScenarioReorder(event) => return self.scenario_reorder_event(*event),
|
||||||
Message::ScenarioCanvas(event) => {
|
Message::ScenarioCanvas(event) => {
|
||||||
use crate::behavior::scenario::{self, CanvasEvent};
|
use crate::behavior::scenario::{self, CanvasEvent};
|
||||||
let context = event.context();
|
let context = event.context();
|
||||||
@@ -2758,7 +2769,10 @@ impl App {
|
|||||||
|| (menu.request.process.is_some()
|
|| (menu.request.process.is_some()
|
||||||
&& matches!(*action, Message::TopologyCanvas(_)))
|
&& matches!(*action, Message::TopologyCanvas(_)))
|
||||||
|| (menu.request.scenario.is_some()
|
|| (menu.request.scenario.is_some()
|
||||||
&& matches!(*action, Message::ScenarioCanvas(_)))
|
&& matches!(
|
||||||
|
*action,
|
||||||
|
Message::ScenarioCanvas(_) | Message::ScenarioReorder(_)
|
||||||
|
))
|
||||||
{
|
{
|
||||||
return self.update(*action);
|
return self.update(*action);
|
||||||
}
|
}
|
||||||
@@ -2801,6 +2815,10 @@ impl App {
|
|||||||
return self.update(Message::Add(child));
|
return self.update(Message::Add(child));
|
||||||
}
|
}
|
||||||
Message::Escape => {
|
Message::Escape => {
|
||||||
|
if self.scenario_reorder.session.is_some() {
|
||||||
|
self.scenario_reorder_cancel();
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
let restore_source_focus = self.source_navigation_available()
|
let restore_source_focus = self.source_navigation_available()
|
||||||
&& (self.source_navigation.open || self.text_workspace.drawer().is_some());
|
&& (self.source_navigation.open || self.text_workspace.drawer().is_some());
|
||||||
let dismiss_find_only = restore_source_focus && self.source_navigation.open;
|
let dismiss_find_only = restore_source_focus && self.source_navigation.open;
|
||||||
|
|||||||
@@ -26,7 +26,14 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
|
|||||||
content,
|
content,
|
||||||
messages: [
|
messages: [
|
||||||
architecture_authoring::captured_escape_message(app),
|
architecture_authoring::captured_escape_message(app),
|
||||||
modal.then_some(Message::Escape),
|
modal.then_some(Message::Escape).or_else(|| {
|
||||||
|
app.scenario_reorder.session.as_ref().map(|session| {
|
||||||
|
crate::behavior::scenario::reorder_view::message(
|
||||||
|
session,
|
||||||
|
super::scenario_reorder::Intent::Cancel,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if changed {
|
if changed {
|
||||||
|
self.scenario_reorder_cancel();
|
||||||
self.source_interaction();
|
self.source_interaction();
|
||||||
self.reconcile_pending_diagram_reveal(&Message::ScenarioCanvas(
|
self.reconcile_pending_diagram_reveal(&Message::ScenarioCanvas(
|
||||||
scenario::CanvasEvent::Selection(Box::new(event)),
|
scenario::CanvasEvent::Selection(Box::new(event)),
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! Exact-input adapter for an explicit semantic reorder operation.
|
||||||
|
use super::*;
|
||||||
|
use crate::behavior::scenario_layout::presentation::PresentationStatus;
|
||||||
|
use crate::{
|
||||||
|
behavior::scenario::{
|
||||||
|
self,
|
||||||
|
reorder_session::{Outcome, Session},
|
||||||
|
selection,
|
||||||
|
},
|
||||||
|
diagram_selection::Snapshot,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct State {
|
||||||
|
pub(crate) session: Option<Session>,
|
||||||
|
pub(crate) query: String,
|
||||||
|
pub(crate) page: usize,
|
||||||
|
pub(crate) summary: Option<(scenario::Context, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct Event {
|
||||||
|
pub(crate) context: scenario::Context,
|
||||||
|
pub(crate) snapshot: Snapshot,
|
||||||
|
pub(crate) token: Id,
|
||||||
|
pub(crate) intent: Intent,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) enum Intent {
|
||||||
|
Begin,
|
||||||
|
Choose(scenario::Slot),
|
||||||
|
Query(String),
|
||||||
|
Page(usize),
|
||||||
|
Finish(scenario::Slot),
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub(crate) fn scenario_reorder_enabled(&self) -> bool {
|
||||||
|
self.spacing_enabled()
|
||||||
|
&& diagram_removal::enabled(self)
|
||||||
|
&& self.behavior.scenario.spacing_token().is_none()
|
||||||
|
&& self.scenario_context().is_some_and(|context| {
|
||||||
|
self.behavior.scenario.canvas_tool(context.scenario) == scenario::CanvasTool::Select
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn scenario_reorder_cancel(&mut self) {
|
||||||
|
if self.scenario_reorder.session.take().is_some() {
|
||||||
|
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||||
|
}
|
||||||
|
self.scenario_reorder.query.clear();
|
||||||
|
self.scenario_reorder.page = 0;
|
||||||
|
self.scenario_reorder.summary = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn scenario_reorder_reconcile(&mut self) {
|
||||||
|
if self
|
||||||
|
.scenario_reorder
|
||||||
|
.summary
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|(context, _)| self.scenario_context() != Some(*context))
|
||||||
|
{
|
||||||
|
self.scenario_reorder.summary = None;
|
||||||
|
}
|
||||||
|
let Some(session) = self.scenario_reorder.session.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let valid = self.scenario_reorder_enabled()
|
||||||
|
&& self
|
||||||
|
.scenario_context()
|
||||||
|
.zip(self.multiselect_snapshot())
|
||||||
|
.is_some_and(|(context, snapshot)| {
|
||||||
|
session.matches(self.project(), context, &snapshot, session.token())
|
||||||
|
});
|
||||||
|
if !valid {
|
||||||
|
self.scenario_reorder_cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn scenario_reorder_event(&mut self, event: Event) -> Task<Message> {
|
||||||
|
// Reject stale envelopes before touching any newer operation or UI state.
|
||||||
|
if event.token.is_nil()
|
||||||
|
|| self.scenario_context() != Some(event.context)
|
||||||
|
|| !self
|
||||||
|
.diagram_selection
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|selection_state| {
|
||||||
|
selection_state.matches(&event.snapshot, selection::context(event.context))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
if matches!(&event.intent, Intent::Begin) {
|
||||||
|
if self.scenario_reorder.session.is_some() || !self.scenario_reorder_enabled() {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
// Retire the captured Begin before construction, including failed starts.
|
||||||
|
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||||
|
let context = self.scenario_context().expect("admitted scenario");
|
||||||
|
let snapshot = self.multiselect_snapshot().expect("admitted selection");
|
||||||
|
self.scenario_reorder.query.clear();
|
||||||
|
self.scenario_reorder.page = 0;
|
||||||
|
self.scenario_reorder.summary = None;
|
||||||
|
match Session::new(
|
||||||
|
self.document.snapshot().decoded.project.clone().into(),
|
||||||
|
context,
|
||||||
|
snapshot,
|
||||||
|
event.token,
|
||||||
|
) {
|
||||||
|
Ok(session) => {
|
||||||
|
self.scenario_reorder.session = Some(session);
|
||||||
|
self.status = "Choose a semantic destination, then apply reorder. Saved positions are preserved.".into();
|
||||||
|
}
|
||||||
|
Err(reason) => self.status = reason,
|
||||||
|
}
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
let Some(session) = self.scenario_reorder.session.as_ref() else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
if !session.matches(self.project(), event.context, &event.snapshot, event.token) {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
if let Intent::Finish(slot) = &event.intent
|
||||||
|
&& session
|
||||||
|
.current_preview()
|
||||||
|
.is_none_or(|preview| preview.slot != *slot)
|
||||||
|
{
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
if matches!(&event.intent, Intent::Cancel) {
|
||||||
|
self.scenario_reorder_cancel();
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
if !self.scenario_reorder_enabled() {
|
||||||
|
self.scenario_reorder_cancel();
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
match event.intent {
|
||||||
|
Intent::Choose(slot) => {
|
||||||
|
let preview = self
|
||||||
|
.scenario_reorder
|
||||||
|
.session
|
||||||
|
.as_mut()
|
||||||
|
.expect("admitted session")
|
||||||
|
.preview(slot);
|
||||||
|
self.status = match &preview.outcome {
|
||||||
|
Outcome::Refused(reason) => reason.clone(),
|
||||||
|
Outcome::Noop => "This destination leaves the semantic order unchanged.".into(),
|
||||||
|
Outcome::Change => match preview.presentation.as_ref().map(|shown| shown.status()) {
|
||||||
|
Some(PresentationStatus::Ready) => "Review the semantic order, then apply reorder. Saved positions are preserved.".into(),
|
||||||
|
Some(PresentationStatus::Fallback { .. }) => "The new semantic order requires automatic display fallback. Apply keeps the requested saved view and its authored positions.".into(),
|
||||||
|
_ => "The candidate cannot be displayed; reorder cannot be applied.".into(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Intent::Query(query) => {
|
||||||
|
self.scenario_reorder.query = query;
|
||||||
|
self.scenario_reorder.page = 0;
|
||||||
|
}
|
||||||
|
Intent::Page(page) => self.scenario_reorder.page = page,
|
||||||
|
Intent::Finish(slot) => {
|
||||||
|
let label = |id| {
|
||||||
|
self.project().elements.get(&id).map_or_else(
|
||||||
|
|| id.to_string(),
|
||||||
|
|element| format!("{} (@{})", element.name, element.alias),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let parent_name = label(slot.parent);
|
||||||
|
let destination = slot.before.map_or_else(
|
||||||
|
|| "at the end".to_owned(),
|
||||||
|
|anchor| format!("before {}", label(anchor)),
|
||||||
|
);
|
||||||
|
let session = self
|
||||||
|
.scenario_reorder
|
||||||
|
.session
|
||||||
|
.take()
|
||||||
|
.expect("admitted session");
|
||||||
|
let selection_visible = session
|
||||||
|
.current_preview()
|
||||||
|
.is_some_and(|preview| preview.selection_visible);
|
||||||
|
let result =
|
||||||
|
session.finish(self.project(), event.context, &event.snapshot, event.token);
|
||||||
|
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||||
|
self.scenario_reorder.query.clear();
|
||||||
|
self.scenario_reorder.page = 0;
|
||||||
|
match result {
|
||||||
|
Err(reason) => self.status = reason,
|
||||||
|
Ok(completion) => {
|
||||||
|
debug_assert_eq!(completion.representation, event.context.representation);
|
||||||
|
if !selection_visible {
|
||||||
|
self.status = "The candidate would hide a selected occurrence; reorder was not applied.".into();
|
||||||
|
} else if matches!(
|
||||||
|
completion.presentation_status,
|
||||||
|
PresentationStatus::Unavailable { .. }
|
||||||
|
) {
|
||||||
|
self.status =
|
||||||
|
"The candidate cannot be displayed; reorder was not applied."
|
||||||
|
.into();
|
||||||
|
} else if let Some(command) = completion.command {
|
||||||
|
match self.document.execute(*command) {
|
||||||
|
Ok(_) => {
|
||||||
|
if let Some(crate::diagram_usage::Target::ScenarioObject(
|
||||||
|
primary,
|
||||||
|
)) = completion.snapshot.primary()
|
||||||
|
{
|
||||||
|
self.selected = *primary;
|
||||||
|
}
|
||||||
|
self.sync();
|
||||||
|
self.reconcile_multiselect();
|
||||||
|
self.inspect_scenario_multiselect_primary();
|
||||||
|
let mut summary = format!(
|
||||||
|
"Moved {} selected occurrences {} in {}. Saved positions are preserved.",
|
||||||
|
completion.snapshot.targets().len(),
|
||||||
|
destination,
|
||||||
|
parent_name
|
||||||
|
);
|
||||||
|
if matches!(
|
||||||
|
completion.presentation_status,
|
||||||
|
PresentationStatus::Fallback { .. }
|
||||||
|
) {
|
||||||
|
summary.push_str(" Automatic display fallback is active.");
|
||||||
|
}
|
||||||
|
self.scenario_reorder.summary =
|
||||||
|
self.scenario_context().map(|context| (context, summary));
|
||||||
|
self.status = if matches!(
|
||||||
|
completion.presentation_status,
|
||||||
|
PresentationStatus::Fallback { .. }
|
||||||
|
) {
|
||||||
|
"Semantic order updated. Automatic display fallback is active; the requested saved view and authored positions are preserved.".into()
|
||||||
|
} else {
|
||||||
|
"Semantic order updated. Saved positions are preserved."
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.status = format!("Reorder was not applied: {error}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.status = "Semantic order unchanged.".into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Intent::Begin | Intent::Cancel => unreachable!(),
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
behavior::{
|
||||||
|
scenario::{CanvasEvent, Slot, direct_tests},
|
||||||
|
topology::occurrence,
|
||||||
|
},
|
||||||
|
diagram_usage::Target,
|
||||||
|
};
|
||||||
|
use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout};
|
||||||
|
|
||||||
|
fn setup(saved: bool) -> (App, Id, [Id; 4]) {
|
||||||
|
let f = direct_tests::fixture();
|
||||||
|
let mut project = f.project;
|
||||||
|
let extra = ["X", "Y"].map(|name| {
|
||||||
|
let element = occurrence(
|
||||||
|
&project,
|
||||||
|
f.owner,
|
||||||
|
name,
|
||||||
|
OccurrenceBody::Action {
|
||||||
|
action: None,
|
||||||
|
lifeline: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let id = element.id;
|
||||||
|
project.elements.insert(id, element);
|
||||||
|
project
|
||||||
|
.elements
|
||||||
|
.get_mut(&f.owner)
|
||||||
|
.unwrap()
|
||||||
|
.steps_mut()
|
||||||
|
.unwrap()
|
||||||
|
.push(id);
|
||||||
|
id
|
||||||
|
});
|
||||||
|
let diagram = Id::new_v4();
|
||||||
|
project.diagrams.insert(
|
||||||
|
diagram,
|
||||||
|
Diagram {
|
||||||
|
id: diagram,
|
||||||
|
alias: "saved_reorder".into(),
|
||||||
|
name: "Saved reorder".into(),
|
||||||
|
description: "Preserve this view".into(),
|
||||||
|
layer: Layer::Oa,
|
||||||
|
projection: ProjectionKind::Scenario { scenario: f.owner },
|
||||||
|
nodes: Default::default(),
|
||||||
|
routes: vec![],
|
||||||
|
view_options: Default::default(),
|
||||||
|
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||||
|
lanes: vec![oa_core::ScenarioLanePosition {
|
||||||
|
lifeline: f.sender,
|
||||||
|
x: 180.,
|
||||||
|
}],
|
||||||
|
messages: vec![],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut app = App::with_project(project);
|
||||||
|
let _ = app.update(Message::Select(f.owner));
|
||||||
|
if saved {
|
||||||
|
let _ = app.update(Message::Diagram(Choice {
|
||||||
|
id: diagram,
|
||||||
|
label: "Saved".into(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for (id, choice) in [
|
||||||
|
(f.reply, multiselect::Choice::Replace),
|
||||||
|
(f.call, multiselect::Choice::Add),
|
||||||
|
] {
|
||||||
|
let event = selection::Event {
|
||||||
|
context: app.scenario_context().unwrap(),
|
||||||
|
snapshot: app.multiselect_snapshot().unwrap(),
|
||||||
|
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
|
||||||
|
};
|
||||||
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||||
|
event,
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
assert!(app.scenario_reorder_enabled());
|
||||||
|
(app, f.owner, [f.call, f.reply, extra[0], extra[1]])
|
||||||
|
}
|
||||||
|
fn packet(app: &App, intent: Intent) -> Event {
|
||||||
|
Event {
|
||||||
|
context: app.scenario_context().unwrap(),
|
||||||
|
snapshot: app.multiselect_snapshot().unwrap(),
|
||||||
|
token: app
|
||||||
|
.scenario_reorder
|
||||||
|
.session
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(Id::new_v4, Session::token),
|
||||||
|
intent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn send(app: &mut App, event: Event) {
|
||||||
|
let _ = app.update(Message::ScenarioReorder(Box::new(event)));
|
||||||
|
}
|
||||||
|
fn begin(app: &mut App) -> Event {
|
||||||
|
let event = packet(app, Intent::Begin);
|
||||||
|
send(app, event.clone());
|
||||||
|
assert!(app.scenario_reorder.session.is_some(), "{}", app.status);
|
||||||
|
event
|
||||||
|
}
|
||||||
|
fn choose(app: &mut App, slot: Slot) {
|
||||||
|
let event = packet(app, Intent::Choose(slot));
|
||||||
|
send(app, event);
|
||||||
|
}
|
||||||
|
fn finish(app: &mut App, slot: Slot) {
|
||||||
|
let event = packet(app, Intent::Finish(slot));
|
||||||
|
send(app, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_commit_preserves_full_selection_view_and_exact_source_history() {
|
||||||
|
for saved in [false, true] {
|
||||||
|
let (mut app, owner, [a, b, x, y]) = setup(saved);
|
||||||
|
let baseline = app.document.snapshot();
|
||||||
|
let selected = app.multiselect_snapshot().unwrap();
|
||||||
|
let primary = app.selected;
|
||||||
|
let representation = app.scenario_context().unwrap().representation;
|
||||||
|
let camera = app.behavior.scenario.camera_boundary();
|
||||||
|
let old_begin = begin(&mut app);
|
||||||
|
assert_ne!(old_begin.context, app.scenario_context().unwrap());
|
||||||
|
let slot = Slot {
|
||||||
|
parent: owner,
|
||||||
|
before: None,
|
||||||
|
};
|
||||||
|
choose(&mut app, slot);
|
||||||
|
finish(&mut app, slot);
|
||||||
|
let mut expected = baseline.decoded.project.clone();
|
||||||
|
*expected
|
||||||
|
.elements
|
||||||
|
.get_mut(&owner)
|
||||||
|
.unwrap()
|
||||||
|
.steps_mut()
|
||||||
|
.unwrap() = vec![x, y, a, b];
|
||||||
|
assert_eq!(app.project(), &expected);
|
||||||
|
let summary = app
|
||||||
|
.scenario_reorder
|
||||||
|
.summary
|
||||||
|
.clone()
|
||||||
|
.expect("semantic result summary");
|
||||||
|
assert_eq!(Some(summary.0), app.scenario_context());
|
||||||
|
assert!(summary.1.contains("2 selected occurrences at the end"));
|
||||||
|
assert!(summary.1.contains(app.project().named(owner)));
|
||||||
|
app.scenario_reorder_reconcile();
|
||||||
|
assert_eq!(app.scenario_reorder.summary.as_ref(), Some(&summary));
|
||||||
|
assert_eq!(app.selected, primary);
|
||||||
|
assert_eq!(
|
||||||
|
app.multiselect_snapshot().unwrap().targets(),
|
||||||
|
selected.targets()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.scenario_context().unwrap().representation,
|
||||||
|
representation
|
||||||
|
);
|
||||||
|
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||||
|
let changed = app.document.bundle().clone();
|
||||||
|
assert_ne!(changed, baseline.bundle);
|
||||||
|
let _ = app.update(Message::Undo);
|
||||||
|
assert!(app.scenario_reorder.summary.is_none());
|
||||||
|
assert_eq!(app.project(), &baseline.decoded.project);
|
||||||
|
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||||
|
let _ = app.update(Message::Redo);
|
||||||
|
assert_eq!(app.project(), &expected);
|
||||||
|
assert_eq!(app.document.bundle(), &changed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canceled_packets_and_queued_other_destination_apply_cannot_touch_new_session() {
|
||||||
|
let (mut app, owner, [a, _, _, _]) = setup(false);
|
||||||
|
let original_begin = begin(&mut app);
|
||||||
|
let first = Slot {
|
||||||
|
parent: owner,
|
||||||
|
before: None,
|
||||||
|
};
|
||||||
|
choose(&mut app, first);
|
||||||
|
let old_packets = [
|
||||||
|
original_begin,
|
||||||
|
packet(&app, Intent::Choose(first)),
|
||||||
|
packet(&app, Intent::Finish(first)),
|
||||||
|
packet(&app, Intent::Cancel),
|
||||||
|
];
|
||||||
|
let cancel = packet(&app, Intent::Cancel);
|
||||||
|
send(&mut app, cancel);
|
||||||
|
begin(&mut app);
|
||||||
|
choose(&mut app, first);
|
||||||
|
let queued_apply = packet(&app, Intent::Finish(first));
|
||||||
|
let second = Slot {
|
||||||
|
parent: owner,
|
||||||
|
before: Some(a),
|
||||||
|
};
|
||||||
|
choose(&mut app, second);
|
||||||
|
let token = app.scenario_reorder.session.as_ref().unwrap().token();
|
||||||
|
let epoch = app.canvas_epoch;
|
||||||
|
let status = app.status.clone();
|
||||||
|
let baseline = app.document.snapshot();
|
||||||
|
for event in old_packets.into_iter().chain([queued_apply]) {
|
||||||
|
send(&mut app, event);
|
||||||
|
assert_eq!(app.canvas_epoch, epoch);
|
||||||
|
assert_eq!(app.status, status);
|
||||||
|
assert_eq!(
|
||||||
|
app.scenario_reorder.session.as_ref().unwrap().token(),
|
||||||
|
token
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.scenario_reorder
|
||||||
|
.session
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.current_preview()
|
||||||
|
.unwrap()
|
||||||
|
.slot,
|
||||||
|
second
|
||||||
|
);
|
||||||
|
assert_eq!(app.project(), &baseline.decoded.project);
|
||||||
|
}
|
||||||
|
finish(&mut app, second);
|
||||||
|
assert!(app.scenario_reorder.session.is_none());
|
||||||
|
assert!(!app.document.can_undo());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noop_keeps_redo_and_query_page_do_not_touch_model_or_camera() {
|
||||||
|
let (mut app, owner, [a, _, _, _]) = setup(false);
|
||||||
|
let end = Slot {
|
||||||
|
parent: owner,
|
||||||
|
before: None,
|
||||||
|
};
|
||||||
|
begin(&mut app);
|
||||||
|
choose(&mut app, end);
|
||||||
|
finish(&mut app, end);
|
||||||
|
let _ = app.update(Message::Undo);
|
||||||
|
assert!(app.document.can_redo());
|
||||||
|
let baseline = app.document.snapshot();
|
||||||
|
let camera = app.behavior.scenario.camera_boundary();
|
||||||
|
begin(&mut app);
|
||||||
|
for intent in [Intent::Query("missing label".into()), Intent::Page(3)] {
|
||||||
|
let event = packet(&app, intent);
|
||||||
|
send(&mut app, event);
|
||||||
|
}
|
||||||
|
assert_eq!(app.scenario_reorder.query, "missing label");
|
||||||
|
assert_eq!(app.scenario_reorder.page, 3);
|
||||||
|
let noop = Slot {
|
||||||
|
parent: owner,
|
||||||
|
before: Some(a),
|
||||||
|
};
|
||||||
|
choose(&mut app, noop);
|
||||||
|
finish(&mut app, noop);
|
||||||
|
assert!(app.document.can_redo());
|
||||||
|
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||||
|
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_generation_aba_and_busy_boundary_retire_only_current_operation() {
|
||||||
|
let (mut app, _, [a, b, _, _]) = setup(false);
|
||||||
|
begin(&mut app);
|
||||||
|
let old = packet(&app, Intent::Cancel);
|
||||||
|
let selection = app.diagram_selection.as_mut().unwrap();
|
||||||
|
selection.set_primary(&Target::ScenarioObject(b));
|
||||||
|
selection.set_primary(&Target::ScenarioObject(a));
|
||||||
|
app.scenario_reorder_reconcile();
|
||||||
|
assert!(app.scenario_reorder.session.is_none());
|
||||||
|
begin(&mut app);
|
||||||
|
let epoch = app.canvas_epoch;
|
||||||
|
send(&mut app, old);
|
||||||
|
assert_eq!(app.canvas_epoch, epoch);
|
||||||
|
assert!(app.scenario_reorder.session.is_some());
|
||||||
|
app.busy = true;
|
||||||
|
app.scenario_reorder_reconcile();
|
||||||
|
assert!(app.scenario_reorder.session.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn temporal_refusal_is_terminal_without_model_or_history_change() {
|
||||||
|
let (mut app, owner, [call, reply, _, _]) = setup(false);
|
||||||
|
let event = selection::Event {
|
||||||
|
context: app.scenario_context().unwrap(),
|
||||||
|
snapshot: app.multiselect_snapshot().unwrap(),
|
||||||
|
intent: selection::Intent::Collapse(Target::ScenarioObject(reply)),
|
||||||
|
};
|
||||||
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||||
|
event,
|
||||||
|
))));
|
||||||
|
let baseline = app.document.snapshot();
|
||||||
|
begin(&mut app);
|
||||||
|
let slot = Slot {
|
||||||
|
parent: owner,
|
||||||
|
before: Some(call),
|
||||||
|
};
|
||||||
|
choose(&mut app, slot);
|
||||||
|
assert!(app.status.contains("reply_order"));
|
||||||
|
let epoch = app.canvas_epoch;
|
||||||
|
finish(&mut app, slot);
|
||||||
|
assert!(app.scenario_reorder.session.is_none());
|
||||||
|
assert_ne!(app.canvas_epoch, epoch);
|
||||||
|
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||||
|
assert_eq!(app.project(), &baseline.decoded.project);
|
||||||
|
assert!(!app.document.can_undo());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_write_refusal_is_atomic_and_dirty_properties_block_begin() {
|
||||||
|
let (mut app, owner, _) = setup(true);
|
||||||
|
let mut bundle = app.document.bundle().clone();
|
||||||
|
// A legal compact authored list is smaller than the source writer's new
|
||||||
|
// canonical list. The exact source budget forces a real write failure.
|
||||||
|
for text in bundle.files.values_mut() {
|
||||||
|
*text = text
|
||||||
|
.split_inclusive('\n')
|
||||||
|
.map(|line| {
|
||||||
|
if line.trim_start().starts_with("steps [") {
|
||||||
|
line.replace(", ", ",")
|
||||||
|
} else {
|
||||||
|
line.to_owned()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
let mut limits = oa_core::source_project::ProjectLimits::default();
|
||||||
|
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum();
|
||||||
|
app.document = Document::from_bundle(bundle, limits).unwrap();
|
||||||
|
app.sync();
|
||||||
|
let baseline = app.document.snapshot();
|
||||||
|
let selection = app.multiselect_snapshot().unwrap();
|
||||||
|
let primary = app.selected;
|
||||||
|
let camera = app.behavior.scenario.camera_boundary();
|
||||||
|
begin(&mut app);
|
||||||
|
let slot = Slot {
|
||||||
|
parent: owner,
|
||||||
|
before: None,
|
||||||
|
};
|
||||||
|
choose(&mut app, slot);
|
||||||
|
let epoch = app.canvas_epoch;
|
||||||
|
finish(&mut app, slot);
|
||||||
|
assert!(
|
||||||
|
app.status.starts_with("Reorder was not applied:"),
|
||||||
|
"{}",
|
||||||
|
app.status
|
||||||
|
);
|
||||||
|
assert_ne!(app.canvas_epoch, epoch);
|
||||||
|
assert_eq!(app.project(), &baseline.decoded.project);
|
||||||
|
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||||
|
assert_eq!(
|
||||||
|
app.multiselect_snapshot().unwrap().targets(),
|
||||||
|
selection.targets()
|
||||||
|
);
|
||||||
|
assert_eq!(app.selected, primary);
|
||||||
|
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||||
|
assert!(!app.document.can_undo());
|
||||||
|
let _ = app.update(Message::Field("name".into(), "Unapplied name".into()));
|
||||||
|
assert!(app.draft_dirty());
|
||||||
|
let event = packet(&app, Intent::Begin);
|
||||||
|
let epoch = app.canvas_epoch;
|
||||||
|
send(&mut app, event);
|
||||||
|
assert!(app.scenario_reorder.session.is_none());
|
||||||
|
assert_eq!(app.canvas_epoch, epoch);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_baseline_fallback_allows_explicit_semantic_reorder_and_retains_saved_view() {
|
||||||
|
use crate::behavior::scenario_layout::{Representation, presentation};
|
||||||
|
let (mut app, owner, [call, reply, x, y]) = setup(true);
|
||||||
|
let representation = app.scenario_context().unwrap().representation;
|
||||||
|
let Representation::Saved { diagram } = representation else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let mut project = app.project().clone();
|
||||||
|
project.diagrams.get_mut(&diagram).unwrap().layout =
|
||||||
|
Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||||
|
lanes: vec![],
|
||||||
|
messages: [(call, 600.), (reply, 200.)]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(occurrence, send_y)| oa_core::ScenarioMessagePosition {
|
||||||
|
address: oa_core::OccurrenceEndpoint {
|
||||||
|
references: vec![],
|
||||||
|
occurrence,
|
||||||
|
},
|
||||||
|
send_y,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}));
|
||||||
|
app.document = Document::from_project(project.clone(), Default::default()).unwrap();
|
||||||
|
app.sync();
|
||||||
|
assert!(matches!(
|
||||||
|
presentation::present(app.project(), representation, call).status(),
|
||||||
|
PresentationStatus::Fallback { .. }
|
||||||
|
));
|
||||||
|
let selected = app.multiselect_snapshot().unwrap();
|
||||||
|
begin(&mut app);
|
||||||
|
let slot = Slot {
|
||||||
|
parent: owner,
|
||||||
|
before: None,
|
||||||
|
};
|
||||||
|
choose(&mut app, slot);
|
||||||
|
assert!(app.status.contains("fallback"));
|
||||||
|
finish(&mut app, slot);
|
||||||
|
*project
|
||||||
|
.elements
|
||||||
|
.get_mut(&owner)
|
||||||
|
.unwrap()
|
||||||
|
.steps_mut()
|
||||||
|
.unwrap() = vec![x, y, call, reply];
|
||||||
|
assert_eq!(app.project(), &project);
|
||||||
|
assert_eq!(
|
||||||
|
app.scenario_context().unwrap().representation,
|
||||||
|
representation
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.multiselect_snapshot().unwrap().targets(),
|
||||||
|
selected.targets()
|
||||||
|
);
|
||||||
|
assert!(app.status.contains("fallback"));
|
||||||
|
assert!(
|
||||||
|
app.scenario_reorder
|
||||||
|
.summary
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.1
|
||||||
|
.contains("fallback")
|
||||||
|
);
|
||||||
|
assert!(app.document.can_undo());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synthetic_candidate_unavailable_and_hidden_selection_cannot_commit() {
|
||||||
|
use crate::behavior::scenario_layout::{Representation, presentation};
|
||||||
|
for unavailable in [true, false] {
|
||||||
|
let (mut app, owner, _) = setup(true);
|
||||||
|
let baseline = app.document.snapshot();
|
||||||
|
let selected = app.multiselect_snapshot().unwrap();
|
||||||
|
let primary = app.selected;
|
||||||
|
let camera = app.behavior.scenario.camera_boundary();
|
||||||
|
begin(&mut app);
|
||||||
|
let slot = Slot {
|
||||||
|
parent: owner,
|
||||||
|
before: None,
|
||||||
|
};
|
||||||
|
choose(&mut app, slot);
|
||||||
|
// Deliberate fault injection tests the App commit guards, not a claim
|
||||||
|
// that this structurally valid fixture naturally produces either state.
|
||||||
|
let missing = presentation::present(
|
||||||
|
app.project(),
|
||||||
|
Representation::Saved { diagram: Id::nil() },
|
||||||
|
primary,
|
||||||
|
);
|
||||||
|
let preview = app
|
||||||
|
.scenario_reorder
|
||||||
|
.session
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.current_preview_mut()
|
||||||
|
.unwrap();
|
||||||
|
if unavailable {
|
||||||
|
preview.presentation = Some(missing);
|
||||||
|
preview.selection_visible = true;
|
||||||
|
} else {
|
||||||
|
preview.selection_visible = false;
|
||||||
|
}
|
||||||
|
let epoch = app.canvas_epoch;
|
||||||
|
finish(&mut app, slot);
|
||||||
|
assert!(app.scenario_reorder.session.is_none());
|
||||||
|
assert!(app.scenario_reorder.summary.is_none());
|
||||||
|
assert_ne!(app.canvas_epoch, epoch);
|
||||||
|
assert!(app.status.contains(if unavailable {
|
||||||
|
"cannot be displayed"
|
||||||
|
} else {
|
||||||
|
"hide a selected occurrence"
|
||||||
|
}));
|
||||||
|
assert_eq!(app.project(), &baseline.decoded.project);
|
||||||
|
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||||
|
assert_eq!(
|
||||||
|
app.multiselect_snapshot().unwrap().targets(),
|
||||||
|
selected.targets()
|
||||||
|
);
|
||||||
|
assert_eq!(app.selected, primary);
|
||||||
|
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||||
|
assert!(!app.document.can_undo());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn scenario_spacing(&mut self, event: Event) -> Task<Message> {
|
pub(super) fn scenario_spacing(&mut self, event: Event) -> Task<Message> {
|
||||||
|
if self.scenario_reorder.session.is_some() {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
let stamp = event.stamp();
|
let stamp = event.stamp();
|
||||||
if self.scenario_context() != Some(stamp.context) || self.camera_epoch != stamp.camera_epoch
|
if self.scenario_context() != Some(stamp.context) || self.camera_epoch != stamp.camera_epoch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,12 +7,10 @@ use std::{cell::RefCell, collections::BTreeSet};
|
|||||||
mod camera;
|
mod camera;
|
||||||
pub(crate) mod focus;
|
pub(crate) mod focus;
|
||||||
pub(crate) mod group_reorder;
|
pub(crate) mod group_reorder;
|
||||||
// Candidate interaction foundation: production input is enabled only after the
|
pub(crate) mod reorder_session;
|
||||||
// destination experiment and the App lifetime adapter have been verified.
|
|
||||||
#[cfg(test)]
|
|
||||||
mod reorder_session;
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod reorder_slot_experiments;
|
mod reorder_slot_experiments;
|
||||||
|
pub(crate) mod reorder_view;
|
||||||
pub(crate) mod selection;
|
pub(crate) mod selection;
|
||||||
pub(crate) mod spacing;
|
pub(crate) mod spacing;
|
||||||
use super::scenario_layout::{
|
use super::scenario_layout::{
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub(crate) struct Preview {
|
|||||||
/// Geometry status is independent of semantic validity; requested Saved
|
/// Geometry status is independent of semantic validity; requested Saved
|
||||||
/// representation is retained even when presentation reports fallback.
|
/// representation is retained even when presentation reports fallback.
|
||||||
pub(crate) presentation: Option<presentation::ScenarioPresentation>,
|
pub(crate) presentation: Option<presentation::ScenarioPresentation>,
|
||||||
|
pub(crate) selection_visible: bool,
|
||||||
command: Option<Box<Command>>,
|
command: Option<Box<Command>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +118,28 @@ impl Session {
|
|||||||
&self.before
|
&self.before
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn current_preview(&self) -> Option<&Preview> {
|
||||||
|
self.preview.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn current_preview_mut(&mut self) -> Option<&mut Preview> {
|
||||||
|
self.preview.as_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn matches(
|
||||||
|
&self,
|
||||||
|
current: &Project,
|
||||||
|
context: Context,
|
||||||
|
snapshot: &Snapshot,
|
||||||
|
token: Id,
|
||||||
|
) -> bool {
|
||||||
|
current == self.project.as_ref()
|
||||||
|
&& context == self.context
|
||||||
|
&& snapshot == &self.snapshot
|
||||||
|
&& token == self.token
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn preview(&mut self, slot: Slot) -> &Preview {
|
pub(crate) fn preview(&mut self, slot: Slot) -> &Preview {
|
||||||
if self
|
if self
|
||||||
.preview
|
.preview
|
||||||
@@ -137,31 +160,44 @@ impl Session {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
let preview = match result {
|
let preview = match result {
|
||||||
Ok((command, candidate)) => Preview {
|
Ok((command, candidate)) => {
|
||||||
slot,
|
let shown = presentation::present(
|
||||||
before: self.before.clone(),
|
|
||||||
after: candidate.elements[&self.parent]
|
|
||||||
.steps()
|
|
||||||
.expect("validated sequence")
|
|
||||||
.clone(),
|
|
||||||
outcome: if command.is_some() {
|
|
||||||
Outcome::Change
|
|
||||||
} else {
|
|
||||||
Outcome::Noop
|
|
||||||
},
|
|
||||||
presentation: Some(presentation::present(
|
|
||||||
&candidate,
|
&candidate,
|
||||||
self.context.representation,
|
self.context.representation,
|
||||||
self.primary,
|
self.primary,
|
||||||
)),
|
);
|
||||||
command,
|
let visible = shown
|
||||||
},
|
.scene()
|
||||||
|
.map_or_else(Vec::new, |scene| scene.selection_targets());
|
||||||
|
let selection_visible = self
|
||||||
|
.snapshot
|
||||||
|
.targets()
|
||||||
|
.iter()
|
||||||
|
.all(|target| visible.contains(target));
|
||||||
|
Preview {
|
||||||
|
slot,
|
||||||
|
before: self.before.clone(),
|
||||||
|
after: candidate.elements[&self.parent]
|
||||||
|
.steps()
|
||||||
|
.expect("validated sequence")
|
||||||
|
.clone(),
|
||||||
|
outcome: if command.is_some() {
|
||||||
|
Outcome::Change
|
||||||
|
} else {
|
||||||
|
Outcome::Noop
|
||||||
|
},
|
||||||
|
presentation: Some(shown),
|
||||||
|
selection_visible,
|
||||||
|
command,
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(reason) => Preview {
|
Err(reason) => Preview {
|
||||||
slot,
|
slot,
|
||||||
before: self.before.clone(),
|
before: self.before.clone(),
|
||||||
after: self.before.clone(),
|
after: self.before.clone(),
|
||||||
outcome: Outcome::Refused(reason),
|
outcome: Outcome::Refused(reason),
|
||||||
presentation: None,
|
presentation: None,
|
||||||
|
selection_visible: false,
|
||||||
command: None,
|
command: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -178,11 +214,7 @@ impl Session {
|
|||||||
snapshot: &Snapshot,
|
snapshot: &Snapshot,
|
||||||
token: Id,
|
token: Id,
|
||||||
) -> Result<Completion, String> {
|
) -> Result<Completion, String> {
|
||||||
if current != self.project.as_ref()
|
if !self.matches(current, context, snapshot, token) {
|
||||||
|| context != self.context
|
|
||||||
|| snapshot != &self.snapshot
|
|
||||||
|| token != self.token
|
|
||||||
{
|
|
||||||
return Err("The reorder input changed; choose the operation again.".into());
|
return Err("The reorder input changed; choose the operation again.".into());
|
||||||
}
|
}
|
||||||
let preview = self
|
let preview = self
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
//! Identity-based destination choice, independent of saved screen coordinates.
|
||||||
|
use super::*;
|
||||||
|
use crate::app::scenario_reorder::{Event, Intent};
|
||||||
|
use crate::behavior::scenario_layout::presentation::PresentationStatus;
|
||||||
|
use iced::widget::{opaque, scrollable};
|
||||||
|
|
||||||
|
const PAGE_SIZE: usize = 5;
|
||||||
|
|
||||||
|
pub(crate) fn message(session: &reorder_session::Session, intent: Intent) -> Message {
|
||||||
|
Message::ScenarioReorder(Box::new(Event {
|
||||||
|
context: session.context(),
|
||||||
|
snapshot: session.snapshot().clone(),
|
||||||
|
token: session.token(),
|
||||||
|
intent,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn begin(app: &App) -> Option<Message> {
|
||||||
|
(app.scenario_reorder_enabled() && app.scenario_reorder.session.is_none()).then_some(())?;
|
||||||
|
let snapshot = app.multiselect_snapshot()?;
|
||||||
|
if snapshot.targets().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Message::ScenarioReorder(Box::new(Event {
|
||||||
|
context: app.scenario_context()?,
|
||||||
|
snapshot,
|
||||||
|
token: Id::new_v4(),
|
||||||
|
intent: Intent::Begin,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(p: &Project, id: Id) -> String {
|
||||||
|
p.elements.get(&id).map_or_else(
|
||||||
|
|| id.to_string(),
|
||||||
|
|e| format!("{} (@{})", short(&e.name, 38), e.alias),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn destination(p: &Project, before: &[Id], slot: Slot) -> String {
|
||||||
|
slot.before.map_or_else(
|
||||||
|
|| "End of sequence".into(),
|
||||||
|
|id| {
|
||||||
|
let position = before
|
||||||
|
.iter()
|
||||||
|
.position(|item| *item == id)
|
||||||
|
.map_or(0, |i| i + 1);
|
||||||
|
format!("Before {position}. {}", label(p, id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn order(p: &Project, ids: &[Id], snapshot: &crate::diagram_selection::Snapshot) -> String {
|
||||||
|
// A bounded neighborhood keeps the moved block and its immediate neighbors
|
||||||
|
// visible. Ellipses explicitly mark omitted, unchanged sequence members.
|
||||||
|
let selected = |id: &Id| {
|
||||||
|
snapshot
|
||||||
|
.targets()
|
||||||
|
.contains(&crate::diagram_usage::Target::ScenarioObject(*id))
|
||||||
|
};
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut omitted = false;
|
||||||
|
for (index, id) in ids.iter().enumerate() {
|
||||||
|
let near = selected(id)
|
||||||
|
|| index.checked_sub(1).is_some_and(|i| selected(&ids[i]))
|
||||||
|
|| ids.get(index + 1).is_some_and(selected);
|
||||||
|
if !near {
|
||||||
|
omitted = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if omitted {
|
||||||
|
parts.push("…".into());
|
||||||
|
omitted = false;
|
||||||
|
}
|
||||||
|
parts.push(format!(
|
||||||
|
"{}{}. {}",
|
||||||
|
if selected(id) { "• " } else { "" },
|
||||||
|
index + 1,
|
||||||
|
label(p, *id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if omitted {
|
||||||
|
parts.push("…".into());
|
||||||
|
}
|
||||||
|
parts.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn panel(app: &App, viewport: Option<Size>) -> Option<Element<'_, Message>> {
|
||||||
|
let session = app.scenario_reorder.session.as_ref()?;
|
||||||
|
let p = app.project();
|
||||||
|
let state = &app.scenario_reorder;
|
||||||
|
let query = state.query.to_lowercase();
|
||||||
|
let slots = session
|
||||||
|
.before()
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(Some)
|
||||||
|
.chain(std::iter::once(None))
|
||||||
|
.map(|before| Slot {
|
||||||
|
parent: session.parent(),
|
||||||
|
before,
|
||||||
|
})
|
||||||
|
.map(|slot| (slot, destination(p, session.before(), slot)))
|
||||||
|
.filter(|(_, title)| title.to_lowercase().contains(&query))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let pages = slots.len().div_ceil(PAGE_SIZE).max(1);
|
||||||
|
let page = state.page.min(pages - 1);
|
||||||
|
let mut choices = column![].spacing(4);
|
||||||
|
for (slot, title) in slots.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||||||
|
choices = choices.push(
|
||||||
|
button(text(title.clone()).size(12))
|
||||||
|
.width(Fill)
|
||||||
|
.on_press(message(session, Intent::Choose(*slot))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if slots.is_empty() {
|
||||||
|
choices = choices
|
||||||
|
.push(text("No matching destination. Try its name, alias or position.").size(12));
|
||||||
|
}
|
||||||
|
let mut body = column![
|
||||||
|
text(format!(
|
||||||
|
"Reorder {} selected",
|
||||||
|
session.snapshot().targets().len()
|
||||||
|
))
|
||||||
|
.size(17),
|
||||||
|
text(format!("Sequence: {}", label(p, session.parent()))).size(12),
|
||||||
|
text("Changes execution order; saved positions stay as authored.").size(12),
|
||||||
|
text_input("Find destination by name, alias or position", &state.query)
|
||||||
|
.on_input(move |query| message(session, Intent::Query(query)))
|
||||||
|
.size(13),
|
||||||
|
choices,
|
||||||
|
row![
|
||||||
|
button("Previous")
|
||||||
|
.on_press_maybe((page > 0).then(|| message(session, Intent::Page(page - 1)))),
|
||||||
|
text(format!("{} / {pages}", page + 1)).size(12),
|
||||||
|
button("Next").on_press_maybe(
|
||||||
|
(page + 1 < pages).then(|| message(session, Intent::Page(page + 1)))
|
||||||
|
)
|
||||||
|
]
|
||||||
|
.spacing(8),
|
||||||
|
]
|
||||||
|
.spacing(8);
|
||||||
|
let mut apply = None;
|
||||||
|
if let Some(preview) = session.current_preview() {
|
||||||
|
body = body.push(
|
||||||
|
text(format!(
|
||||||
|
"Destination: {}",
|
||||||
|
destination(p, session.before(), preview.slot)
|
||||||
|
))
|
||||||
|
.size(13),
|
||||||
|
);
|
||||||
|
let comparison = row![
|
||||||
|
column![
|
||||||
|
text("Before").size(13),
|
||||||
|
text(order(p, &preview.before, session.snapshot())).size(12)
|
||||||
|
]
|
||||||
|
.width(Fill),
|
||||||
|
column![
|
||||||
|
text("After • selected").size(13),
|
||||||
|
text(order(p, &preview.after, session.snapshot())).size(12)
|
||||||
|
]
|
||||||
|
.width(Fill)
|
||||||
|
]
|
||||||
|
.spacing(10);
|
||||||
|
body = body.push(comparison);
|
||||||
|
let feedback = match &preview.outcome {
|
||||||
|
reorder_session::Outcome::Refused(reason) => reason.clone(),
|
||||||
|
_ if !preview.selection_visible => {
|
||||||
|
"This destination would hide a selected occurrence; choose another destination."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
_ => match preview.presentation.as_ref().map(|shown| shown.status()) {
|
||||||
|
Some(PresentationStatus::Unavailable { .. }) | None => {
|
||||||
|
"This order cannot be displayed; choose another destination.".into()
|
||||||
|
}
|
||||||
|
Some(status) => {
|
||||||
|
apply = Some(message(session, Intent::Finish(preview.slot)));
|
||||||
|
if matches!(status, PresentationStatus::Fallback { .. }) {
|
||||||
|
"Apply will display an automatic arrangement. Your requested saved view and authored positions are retained; they are not repaired.".into()
|
||||||
|
} else if matches!(preview.outcome, reorder_session::Outcome::Noop) {
|
||||||
|
"This destination keeps the current order; Apply will close without an edit.".into()
|
||||||
|
} else {
|
||||||
|
"The selected occurrences move together in their existing order. One Undo restores the sequence.".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
body = body.push(text(feedback).size(12));
|
||||||
|
} else {
|
||||||
|
body = body.push(text("Choose a destination to preview the new sequence.").size(12));
|
||||||
|
}
|
||||||
|
let height = viewport.map_or(500., |size| (size.height - 32.).max(100.));
|
||||||
|
let card = column![
|
||||||
|
scrollable(body).height(Fill),
|
||||||
|
row![
|
||||||
|
button("Cancel (Esc)").on_press(message(session, Intent::Cancel)),
|
||||||
|
button("Apply order").on_press_maybe(apply)
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
]
|
||||||
|
.spacing(10);
|
||||||
|
Some(
|
||||||
|
container(opaque(
|
||||||
|
container(card)
|
||||||
|
.padding(12)
|
||||||
|
.width(480)
|
||||||
|
.height(height.min(630.))
|
||||||
|
.style(style::card),
|
||||||
|
))
|
||||||
|
.align_right(Fill)
|
||||||
|
.padding(8)
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -91,7 +91,8 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
|||||||
},
|
},
|
||||||
ScenarioPresentation::capabilities,
|
ScenarioPresentation::capabilities,
|
||||||
);
|
);
|
||||||
let coordinate_input = clean && capabilities.coordinate_input;
|
let coordinate_input =
|
||||||
|
clean && capabilities.coordinate_input && app.scenario_reorder.session.is_none();
|
||||||
let notice = if let Some(paint) = &preview {
|
let notice = if let Some(paint) = &preview {
|
||||||
display_notice_parts(
|
display_notice_parts(
|
||||||
p,
|
p,
|
||||||
@@ -164,6 +165,23 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
|||||||
// Keep this Stack in both branches: replacing the Canvas with a wrapper
|
// Keep this Stack in both branches: replacing the Canvas with a wrapper
|
||||||
// on Begin would discard its pointer capture before Move/Finish.
|
// on Begin would discard its pointer capture before Move/Finish.
|
||||||
let mut layers = iced::widget::stack![raw_canvas];
|
let mut layers = iced::widget::stack![raw_canvas];
|
||||||
|
if let Some((_, summary)) = &app.scenario_reorder.summary {
|
||||||
|
layers = layers.push(
|
||||||
|
container(iced::widget::opaque(
|
||||||
|
container(
|
||||||
|
text(format!("{summary} Use Undo to restore the previous order.")).size(12),
|
||||||
|
)
|
||||||
|
.padding(8)
|
||||||
|
.style(style::card),
|
||||||
|
))
|
||||||
|
.align_bottom(Fill)
|
||||||
|
.width(Fill)
|
||||||
|
.padding(8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(panel) = reorder_view::panel(app, presentation.viewport) {
|
||||||
|
layers = layers.push(panel);
|
||||||
|
}
|
||||||
if let Some(paint) = &preview {
|
if let Some(paint) = &preview {
|
||||||
let feedback = spacing_feedback(p, paint.refusal, paint.diagnostics);
|
let feedback = spacing_feedback(p, paint.refusal, paint.diagnostics);
|
||||||
let feedback = if paint.selected_count > 1 {
|
let feedback = if paint.selected_count > 1 {
|
||||||
@@ -213,6 +231,11 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
|||||||
text(format!("{selected_count} selected")).size(12)
|
text(format!("{selected_count} selected")).size(12)
|
||||||
]
|
]
|
||||||
.spacing(6);
|
.spacing(6);
|
||||||
|
navigation = navigation.push(
|
||||||
|
button(text("Reorder…").size(12))
|
||||||
|
.padding(6)
|
||||||
|
.on_press_maybe(reorder_view::begin(app)),
|
||||||
|
);
|
||||||
if state.can_back(p, context) {
|
if state.can_back(p, context) {
|
||||||
navigation =
|
navigation =
|
||||||
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(
|
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(
|
||||||
@@ -714,7 +737,9 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
|||||||
.into();
|
.into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let editor = if pending {
|
let editor = if app.scenario_reorder.session.is_some() {
|
||||||
|
None
|
||||||
|
} else if pending {
|
||||||
Some(form_panel)
|
Some(form_panel)
|
||||||
} else {
|
} else {
|
||||||
presentation.shelf.map(|_| details)
|
presentation.shelf.map(|_| details)
|
||||||
@@ -780,7 +805,7 @@ fn display_notice_parts<'a>(
|
|||||||
if let Some(issue) = requested_issues.first() {
|
if let Some(issue) = requested_issues.first() {
|
||||||
notice = notice.push(text(geometry_problem(p, issue)).size(12));
|
notice = notice.push(text(geometry_problem(p, issue)).size(12));
|
||||||
}
|
}
|
||||||
notice = notice.push(text("Selection, navigation and Fit remain available. Canvas insertion and reordering are unavailable until the saved positions are repaired.").size(12));
|
notice = notice.push(text("Selection, navigation and Fit remain available. Coordinate editing requires repaired saved positions. Reorder uses explicit sequence destinations and previews its display consequences.").size(12));
|
||||||
}
|
}
|
||||||
PresentationStatus::Unavailable { .. } => {
|
PresentationStatus::Unavailable { .. } => {
|
||||||
notice = notice.push(text("This view is unavailable. Its identity or automatic geometry cannot be resolved; the model and source remain available for inspection and repair.").size(12));
|
notice = notice.push(text("This view is unavailable. Its identity or automatic geometry cannot be resolved; the model and source remain available for inspection and repair.").size(12));
|
||||||
|
|||||||
@@ -366,9 +366,10 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
|||||||
"{} selected",
|
"{} selected",
|
||||||
snapshot.targets().len()
|
snapshot.targets().len()
|
||||||
)));
|
)));
|
||||||
entries.push(Entry::disabled(
|
entries.push(match crate::behavior::scenario::reorder_view::begin(app) {
|
||||||
"Reorder selected occurrences (not yet available)",
|
Some(message) => item("Reorder selected occurrences…", message, true),
|
||||||
));
|
None => Entry::disabled("Reorder selected occurrences"),
|
||||||
|
});
|
||||||
entries.push(Entry::disabled(
|
entries.push(Entry::disabled(
|
||||||
"Remove selected occurrences (not yet available)",
|
"Remove selected occurrences (not yet available)",
|
||||||
));
|
));
|
||||||
|
|||||||
Reference in New Issue
Block a user