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_diagram;
|
||||
mod scenario_multiselect;
|
||||
pub(crate) mod scenario_reorder;
|
||||
mod scenario_spacing;
|
||||
#[cfg(test)]
|
||||
mod source_indentation_tests;
|
||||
@@ -441,6 +442,7 @@ pub enum Message {
|
||||
BehaviorDiagram(Choice),
|
||||
StateDiagram(crate::behavior::state_edit::Event),
|
||||
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
|
||||
ScenarioReorder(Box<scenario_reorder::Event>),
|
||||
TopologyCanvas(crate::behavior::topology::CanvasEvent),
|
||||
PlaceSelected,
|
||||
HideSelected,
|
||||
@@ -527,6 +529,7 @@ pub struct App {
|
||||
pub guide: crate::guide::State,
|
||||
pub traceability: crate::views::traceability::State,
|
||||
pub behavior: crate::behavior::State,
|
||||
pub(crate) scenario_reorder: scenario_reorder::State,
|
||||
pub data_state: crate::data_view::State,
|
||||
pub inspector_tab: crate::navigation::InspectorTab,
|
||||
pub layer: Layer,
|
||||
@@ -649,6 +652,7 @@ impl App {
|
||||
guide: crate::guide::State::default(),
|
||||
traceability: Default::default(),
|
||||
behavior: Default::default(),
|
||||
scenario_reorder: Default::default(),
|
||||
data_state: Default::default(),
|
||||
inspector_tab: Default::default(),
|
||||
layer,
|
||||
@@ -1352,6 +1356,11 @@ impl App {
|
||||
])
|
||||
}
|
||||
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
|
||||
{
|
||||
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() {
|
||||
self.refresh_draft();
|
||||
}
|
||||
self.scenario_reorder_reconcile();
|
||||
task
|
||||
}
|
||||
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::Hosted(event) => return self.hosted_event(event),
|
||||
Message::ScenarioReorder(event) => return self.scenario_reorder_event(*event),
|
||||
Message::ScenarioCanvas(event) => {
|
||||
use crate::behavior::scenario::{self, CanvasEvent};
|
||||
let context = event.context();
|
||||
@@ -2758,7 +2769,10 @@ impl App {
|
||||
|| (menu.request.process.is_some()
|
||||
&& matches!(*action, Message::TopologyCanvas(_)))
|
||||
|| (menu.request.scenario.is_some()
|
||||
&& matches!(*action, Message::ScenarioCanvas(_)))
|
||||
&& matches!(
|
||||
*action,
|
||||
Message::ScenarioCanvas(_) | Message::ScenarioReorder(_)
|
||||
))
|
||||
{
|
||||
return self.update(*action);
|
||||
}
|
||||
@@ -2801,6 +2815,10 @@ impl App {
|
||||
return self.update(Message::Add(child));
|
||||
}
|
||||
Message::Escape => {
|
||||
if self.scenario_reorder.session.is_some() {
|
||||
self.scenario_reorder_cancel();
|
||||
return Task::none();
|
||||
}
|
||||
let restore_source_focus = self.source_navigation_available()
|
||||
&& (self.source_navigation.open || self.text_workspace.drawer().is_some());
|
||||
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,
|
||||
messages: [
|
||||
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 {
|
||||
self.scenario_reorder_cancel();
|
||||
self.source_interaction();
|
||||
self.reconcile_pending_diagram_reveal(&Message::ScenarioCanvas(
|
||||
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> {
|
||||
if self.scenario_reorder.session.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
let stamp = event.stamp();
|
||||
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;
|
||||
pub(crate) mod focus;
|
||||
pub(crate) mod group_reorder;
|
||||
// Candidate interaction foundation: production input is enabled only after the
|
||||
// destination experiment and the App lifetime adapter have been verified.
|
||||
#[cfg(test)]
|
||||
mod reorder_session;
|
||||
pub(crate) mod reorder_session;
|
||||
#[cfg(test)]
|
||||
mod reorder_slot_experiments;
|
||||
pub(crate) mod reorder_view;
|
||||
pub(crate) mod selection;
|
||||
pub(crate) mod spacing;
|
||||
use super::scenario_layout::{
|
||||
|
||||
@@ -23,6 +23,7 @@ pub(crate) struct Preview {
|
||||
/// Geometry status is independent of semantic validity; requested Saved
|
||||
/// representation is retained even when presentation reports fallback.
|
||||
pub(crate) presentation: Option<presentation::ScenarioPresentation>,
|
||||
pub(crate) selection_visible: bool,
|
||||
command: Option<Box<Command>>,
|
||||
}
|
||||
|
||||
@@ -117,6 +118,28 @@ impl Session {
|
||||
&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 {
|
||||
if self
|
||||
.preview
|
||||
@@ -137,31 +160,44 @@ impl Session {
|
||||
}
|
||||
});
|
||||
let preview = match result {
|
||||
Ok((command, candidate)) => 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(presentation::present(
|
||||
Ok((command, candidate)) => {
|
||||
let shown = presentation::present(
|
||||
&candidate,
|
||||
self.context.representation,
|
||||
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 {
|
||||
slot,
|
||||
before: self.before.clone(),
|
||||
after: self.before.clone(),
|
||||
outcome: Outcome::Refused(reason),
|
||||
presentation: None,
|
||||
selection_visible: false,
|
||||
command: None,
|
||||
},
|
||||
};
|
||||
@@ -178,11 +214,7 @@ impl Session {
|
||||
snapshot: &Snapshot,
|
||||
token: Id,
|
||||
) -> Result<Completion, String> {
|
||||
if current != self.project.as_ref()
|
||||
|| context != self.context
|
||||
|| snapshot != &self.snapshot
|
||||
|| token != self.token
|
||||
{
|
||||
if !self.matches(current, context, snapshot, token) {
|
||||
return Err("The reorder input changed; choose the operation again.".into());
|
||||
}
|
||||
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,
|
||||
);
|
||||
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 {
|
||||
display_notice_parts(
|
||||
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
|
||||
// on Begin would discard its pointer capture before Move/Finish.
|
||||
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 {
|
||||
let feedback = spacing_feedback(p, paint.refusal, paint.diagnostics);
|
||||
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)
|
||||
]
|
||||
.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) {
|
||||
navigation =
|
||||
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();
|
||||
}
|
||||
}
|
||||
let editor = if pending {
|
||||
let editor = if app.scenario_reorder.session.is_some() {
|
||||
None
|
||||
} else if pending {
|
||||
Some(form_panel)
|
||||
} else {
|
||||
presentation.shelf.map(|_| details)
|
||||
@@ -780,7 +805,7 @@ fn display_notice_parts<'a>(
|
||||
if let Some(issue) = requested_issues.first() {
|
||||
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 { .. } => {
|
||||
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",
|
||||
snapshot.targets().len()
|
||||
)));
|
||||
entries.push(Entry::disabled(
|
||||
"Reorder selected occurrences (not yet available)",
|
||||
));
|
||||
entries.push(match crate::behavior::scenario::reorder_view::begin(app) {
|
||||
Some(message) => item("Reorder selected occurrences…", message, true),
|
||||
None => Entry::disabled("Reorder selected occurrences"),
|
||||
});
|
||||
entries.push(Entry::disabled(
|
||||
"Remove selected occurrences (not yet available)",
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user