diff --git a/crates/oa-desktop/src/app/scenario_reorder.rs b/crates/oa-desktop/src/app/scenario_reorder.rs index 851dac7d..b4cbaa99 100644 --- a/crates/oa-desktop/src/app/scenario_reorder.rs +++ b/crates/oa-desktop/src/app/scenario_reorder.rs @@ -35,6 +35,58 @@ pub(crate) enum Intent { Cancel, } +fn short_label(value: &str) -> String { + let mut chars = value.chars(); + let mut label: String = chars.by_ref().take(24).collect(); + if chars.next().is_some() { + label.push('…'); + } + label +} + +/// Actual committed block order, independent of capture order or saved Y. +fn result_neighborhood(project: &oa_core::Project, parent: Id, snapshot: &Snapshot) -> String { + let Some(order) = project + .elements + .get(&parent) + .and_then(|element| element.steps()) + else { + return String::new(); + }; + let selected: Vec<_> = order + .iter() + .enumerate() + .filter(|(_, id)| { + snapshot + .targets() + .contains(&crate::diagram_usage::Target::ScenarioObject(**id)) + }) + .collect(); + let Some((first, _)) = selected.first() else { + return String::new(); + }; + let last = selected.last().expect("nonempty block").0; + let label = |index: usize| format!("#{} @{}", index + 1, project.elements[&order[index]].alias); + let left = first + .checked_sub(1) + .map_or_else(|| "Start".to_owned(), label); + let right = if last + 1 < order.len() { + label(last + 1) + } else { + "End".to_owned() + }; + let mut members = selected + .iter() + .take(3) + .map(|(index, _)| label(*index)) + .collect::>() + .join(", "); + if selected.len() > 3 { + members.push_str(&format!(", +{} more", selected.len() - 3)); + } + format!("{left} → [{members}] → {right}") +} + impl App { pub(crate) fn scenario_reorder_enabled(&self) -> bool { self.spacing_enabled() @@ -164,7 +216,13 @@ impl App { let label = |id| { self.project().elements.get(&id).map_or_else( || id.to_string(), - |element| format!("{} (@{})", element.name, element.alias), + |element| { + format!( + "{} (@{})", + short_label(&element.name), + short_label(&element.alias) + ) + }, ) }; let parent_name = label(slot.parent); @@ -211,10 +269,15 @@ impl App { self.reconcile_multiselect(); self.inspect_scenario_multiselect_primary(); let mut summary = format!( - "Moved {} selected occurrences {} in {}. Saved positions are preserved.", + "Moved {} selected occurrences {} in {}. {}. Saved positions are preserved.", completion.snapshot.targets().len(), destination, - parent_name + parent_name, + result_neighborhood( + self.project(), + slot.parent, + &completion.snapshot + ) ); if matches!( completion.presentation_status, diff --git a/crates/oa-desktop/src/app/scenario_reorder/tests.rs b/crates/oa-desktop/src/app/scenario_reorder/tests.rs index d3252e56..67fe3ed7 100644 --- a/crates/oa-desktop/src/app/scenario_reorder/tests.rs +++ b/crates/oa-desktop/src/app/scenario_reorder/tests.rs @@ -1,13 +1,30 @@ use super::*; use crate::{ - behavior::{ - scenario::{CanvasEvent, Slot, direct_tests}, - topology::occurrence, - }, + behavior::scenario::{CanvasEvent, Slot, direct_tests}, diagram_usage::Target, }; use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout}; +fn occurrence( + project: &oa_core::Project, + owner: Id, + name: &str, + body: OccurrenceBody, +) -> oa_core::Element { + let mut element = project + .make_element( + Kind::Occurrence, + project.elements[&owner].layer(), + name, + Some(owner), + ) + .unwrap(); + if let Some(oa_core::SharedBody::Occurrence { body: value, .. }) = element.shared_mut() { + *value = body; + } + element +} + fn setup(saved: bool) -> (App, Id, [Id; 4]) { let f = direct_tests::fixture(); let mut project = f.project; @@ -140,7 +157,25 @@ fn app_commit_preserves_full_selection_view_and_exact_source_history() { .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(&format!( + "#2 @{} → [#3 @{}, #4 @{}] → End", + app.project().elements[&y].alias, + app.project().elements[&a].alias, + app.project().elements[&b].alias + )), + "{}", + summary.1 + ); assert!(summary.1.contains(app.project().named(owner))); + let mut repeated = app.project().clone(); + for id in [a, b, x, y] { + repeated.elements.get_mut(&id).unwrap().name = "Repeated display name".into(); + } + assert_eq!( + result_neighborhood(&repeated, owner, &selected), + result_neighborhood(app.project(), owner, &selected) + ); app.scenario_reorder_reconcile(); assert_eq!(app.scenario_reorder.summary.as_ref(), Some(&summary)); assert_eq!(app.selected, primary); @@ -348,7 +383,7 @@ fn source_write_refusal_is_atomic_and_dirty_properties_block_begin() { 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())); + 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; @@ -478,3 +513,112 @@ fn synthetic_candidate_unavailable_and_hidden_selection_cannot_commit() { assert!(!app.document.can_undo()); } } + +#[tokio::test] +async fn mounted_query_owns_space_arrows_wheel_and_root_escape_cancels() { + use crate::behavior::canvas_workspace::tests::Mounted; + use iced::{advanced::widget, keyboard, mouse}; + let (mut app, _, _) = setup(true); + app.window_size = iced::Size::new(1440., 1000.); + let mut mounted = Mounted::new(&mut app).await; + let baseline = app.document.snapshot(); + let selected = app.multiselect_snapshot().unwrap(); + begin(&mut app); + mounted.settle(&mut app).await; + let token = app + .scenario_reorder + .session + .as_ref() + .expect("overlay remains open after layout") + .token(); + let query_id = widget::Id::new("scenario-reorder-query"); + let geometry = mounted.geometry(&app); + let point = geometry + .focused + .iter() + .find(|(id, _, _)| id.as_ref() == Some(&query_id)) + .expect("identified query input") + .1 + .center(); + mounted.click(&mut app, point).await; + assert!( + mounted + .geometry(&app) + .focused + .iter() + .any(|(id, _, focused)| id.as_ref() == Some(&query_id) && *focused) + ); + let camera = app.behavior.scenario.camera_boundary(); + let key = |named, text| { + iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(named), + modified_key: keyboard::Key::Named(named), + physical_key: keyboard::key::Physical::Unidentified( + keyboard::key::NativeCode::Unidentified, + ), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text, + repeat: false, + }) + }; + let messages = mounted + .batch( + &mut app, + &[key(keyboard::key::Named::Space, Some(" ".into()))], + mouse::Cursor::Available(point), + ) + .await; + assert!(messages.iter().any(|message| matches!(message, Message::ScenarioReorder(event) if matches!(&event.intent, Intent::Query(query) if query == " "))), "Space must reach the actual text input: {messages:?}"); + assert_eq!(app.scenario_reorder.query, " "); + for named in [ + keyboard::key::Named::ArrowLeft, + keyboard::key::Named::ArrowRight, + keyboard::key::Named::ArrowUp, + keyboard::key::Named::ArrowDown, + ] { + mounted + .batch( + &mut app, + &[key(named, None)], + mouse::Cursor::Available(point), + ) + .await; + assert!(camera == app.behavior.scenario.camera_boundary()); + } + mounted + .batch( + &mut app, + &[iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x: 0., y: -3. }, + })], + mouse::Cursor::Available(point), + ) + .await; + assert!(camera == app.behavior.scenario.camera_boundary()); + assert_eq!( + app.scenario_reorder.session.as_ref().unwrap().token(), + token + ); + let messages = mounted + .batch( + &mut app, + &[key(keyboard::key::Named::Escape, None)], + mouse::Cursor::Available(point), + ) + .await; + assert!(messages.iter().any(|message| matches!(message, Message::ScenarioReorder(event) if matches!(event.intent, Intent::Cancel))), "Escape must use the root's captured reorder cancellation: {messages:?}"); + assert!(app.scenario_reorder.session.is_none()); + 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.multiselect_snapshot().unwrap().primary(), + selected.primary() + ); + assert!(camera == app.behavior.scenario.camera_boundary()); + assert!(!app.document.can_undo()); +} diff --git a/crates/oa-desktop/src/behavior/scenario/reorder_view.rs b/crates/oa-desktop/src/behavior/scenario/reorder_view.rs index ce4dd193..bce2d2ff 100644 --- a/crates/oa-desktop/src/behavior/scenario/reorder_view.rs +++ b/crates/oa-desktop/src/behavior/scenario/reorder_view.rs @@ -2,7 +2,7 @@ use super::*; use crate::app::scenario_reorder::{Event, Intent}; use crate::behavior::scenario_layout::presentation::PresentationStatus; -use iced::widget::{opaque, scrollable}; +use iced::widget::{column, opaque, scrollable}; const PAGE_SIZE: usize = 5; @@ -95,12 +95,28 @@ pub(super) fn panel(app: &App, viewport: Option) -> Option>(); let pages = slots.len().div_ceil(PAGE_SIZE).max(1); let page = state.page.min(pages - 1); @@ -125,6 +141,7 @@ pub(super) fn panel(app: &App, viewport: Option) -> Option) -> Option) -> Option(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a scenario_view: Some((context.representation, context.visit)), }; let keyboard = clean + && app.scenario_reorder.session.is_none() && capabilities.geometry_navigation && super::super::canvas_workspace::keyboard_enabled(app); let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e)); @@ -153,7 +154,8 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a }) }) .flatten(); - scene.coordinate_input = capabilities.coordinate_input; + scene.coordinate_input = + capabilities.coordinate_input && app.scenario_reorder.session.is_none(); scene.pending_placement = coordinate_input .then(|| state.pending_placement(owner)) .flatten(); @@ -179,9 +181,6 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a .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 { @@ -225,6 +224,13 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view.")) .id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into() }; + // The chooser must sit outside Viewport: that camera wrapper deliberately + // consumes Space, arrows and wheel before its child receives input. + let mut canvas_layers = iced::widget::stack![canvas]; + if let Some(panel) = reorder_view::panel(app, presentation.viewport) { + canvas_layers = canvas_layers.push(panel); + } + let canvas = canvas_layers.into(); let selected_count = snapshot.as_ref().map_or(0, |s| s.targets().len()); let mut navigation = row![ out.width(Fill), diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index a5a08302..1832064f 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -366,10 +366,9 @@ pub fn popup(app: &App) -> Option> { "{} selected", snapshot.targets().len() ))); - 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::title( + "Use Reorder above the canvas to change sequence order", + )); entries.push(Entry::disabled( "Remove selected occurrences (not yet available)", ));