From 61099ed4742eaa51c3bd81129bc072d8321961b7 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Thu, 24 Sep 2026 17:00:22 -0700 Subject: [PATCH] Add explicit scenario group reorder App interaction and guarded preview --- crates/oa-desktop/src/app.rs | 20 +- crates/oa-desktop/src/app/escape.rs | 9 +- .../src/app/scenario_multiselect.rs | 1 + crates/oa-desktop/src/app/scenario_reorder.rs | 254 +++++++++ .../src/app/scenario_reorder/tests.rs | 480 ++++++++++++++++++ crates/oa-desktop/src/app/scenario_spacing.rs | 3 + crates/oa-desktop/src/behavior/scenario.rs | 6 +- .../src/behavior/scenario/reorder_session.rs | 74 ++- .../src/behavior/scenario/reorder_view.rs | 213 ++++++++ .../src/behavior/scenario/workspace.rs | 31 +- crates/oa-desktop/src/model_menu.rs | 7 +- 11 files changed, 1065 insertions(+), 33 deletions(-) create mode 100644 crates/oa-desktop/src/app/scenario_reorder.rs create mode 100644 crates/oa-desktop/src/app/scenario_reorder/tests.rs create mode 100644 crates/oa-desktop/src/behavior/scenario/reorder_view.rs diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index af7cedc6..bf15e854 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -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), 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 { + // 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 { @@ -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; diff --git a/crates/oa-desktop/src/app/escape.rs b/crates/oa-desktop/src/app/escape.rs index 8683f30a..9f000dc1 100644 --- a/crates/oa-desktop/src/app/escape.rs +++ b/crates/oa-desktop/src/app/escape.rs @@ -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, + ) + }) + }), ], }) } diff --git a/crates/oa-desktop/src/app/scenario_multiselect.rs b/crates/oa-desktop/src/app/scenario_multiselect.rs index fd5e4828..a6f3b99e 100644 --- a/crates/oa-desktop/src/app/scenario_multiselect.rs +++ b/crates/oa-desktop/src/app/scenario_multiselect.rs @@ -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)), diff --git a/crates/oa-desktop/src/app/scenario_reorder.rs b/crates/oa-desktop/src/app/scenario_reorder.rs new file mode 100644 index 00000000..851dac7d --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_reorder.rs @@ -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, + 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 { + // 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; diff --git a/crates/oa-desktop/src/app/scenario_reorder/tests.rs b/crates/oa-desktop/src/app/scenario_reorder/tests.rs new file mode 100644 index 00000000..d3252e56 --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_reorder/tests.rs @@ -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()); + } +} diff --git a/crates/oa-desktop/src/app/scenario_spacing.rs b/crates/oa-desktop/src/app/scenario_spacing.rs index cff1dda9..37a7bd33 100644 --- a/crates/oa-desktop/src/app/scenario_spacing.rs +++ b/crates/oa-desktop/src/app/scenario_spacing.rs @@ -40,6 +40,9 @@ impl App { } pub(super) fn scenario_spacing(&mut self, event: Event) -> Task { + 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 { diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 5adcdb59..21c1dbf4 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -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::{ diff --git a/crates/oa-desktop/src/behavior/scenario/reorder_session.rs b/crates/oa-desktop/src/behavior/scenario/reorder_session.rs index defa1f7a..a856a2f4 100644 --- a/crates/oa-desktop/src/behavior/scenario/reorder_session.rs +++ b/crates/oa-desktop/src/behavior/scenario/reorder_session.rs @@ -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, + pub(crate) selection_visible: bool, command: Option>, } @@ -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 { - 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 diff --git a/crates/oa-desktop/src/behavior/scenario/reorder_view.rs b/crates/oa-desktop/src/behavior/scenario/reorder_view.rs new file mode 100644 index 00000000..ce4dd193 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/reorder_view.rs @@ -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 { + (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) -> Option> { + 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::>(); + 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(), + ) +} diff --git a/crates/oa-desktop/src/behavior/scenario/workspace.rs b/crates/oa-desktop/src/behavior/scenario/workspace.rs index 09cda758..a24c57f9 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -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)); diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index 60f598b9..a5a08302 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -366,9 +366,10 @@ pub fn popup(app: &App) -> Option> { "{} 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)", ));