Add explicit scenario group reorder App interaction and guarded preview

This commit is contained in:
2026-09-24 17:00:22 -07:00
parent a16e014049
commit 61099ed474
11 changed files with 1065 additions and 33 deletions
+19 -1
View File
@@ -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;
+8 -1
View File
@@ -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
{
+2 -4
View File
@@ -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));
+4 -3
View File
@@ -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)",
));