Isolate reorder chooser input and retain exact sequence feedback

This commit is contained in:
2026-09-24 17:05:41 -07:00
parent 61099ed474
commit f3ed6e97dd
5 changed files with 255 additions and 24 deletions
+66 -3
View File
@@ -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::<Vec<_>>()
.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,
@@ -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());
}
@@ -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<Size>) -> Option<Element<'_, Mes
.copied()
.map(Some)
.chain(std::iter::once(None))
.map(|before| Slot {
parent: session.parent(),
before,
.enumerate()
.map(|(index, before)| {
let slot = Slot {
parent: session.parent(),
before,
};
let title = before.map_or_else(
|| "End of sequence".into(),
|id| format!("Before {}. {}", index + 1, label(p, id)),
);
(slot, title)
})
.filter(|(slot, title)| {
title.to_lowercase().contains(&query)
|| slot
.before
.and_then(|id| p.elements.get(&id))
.is_some_and(|e| {
e.name.to_lowercase().contains(&query)
|| e.alias.to_lowercase().contains(&query)
})
})
.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);
@@ -125,6 +141,7 @@ pub(super) fn panel(app: &App, viewport: Option<Size>) -> Option<Element<'_, Mes
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)
.id("scenario-reorder-query")
.on_input(move |query| message(session, Intent::Query(query)))
.size(13),
choices,
@@ -140,8 +157,9 @@ pub(super) fn panel(app: &App, viewport: Option<Size>) -> Option<Element<'_, Mes
]
.spacing(8);
let mut apply = None;
let mut footer = column![].spacing(6);
if let Some(preview) = session.current_preview() {
body = body.push(
footer = footer.push(
text(format!(
"Destination: {}",
destination(p, session.before(), preview.slot)
@@ -184,13 +202,14 @@ pub(super) fn panel(app: &App, viewport: Option<Size>) -> Option<Element<'_, Mes
}
},
};
body = body.push(text(feedback).size(12));
footer = footer.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),
footer,
row![
button("Cancel (Esc)").on_press(message(session, Intent::Cancel)),
button("Apply order").on_press_maybe(apply)
@@ -125,6 +125,7 @@ pub fn view<'a>(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),
+3 -4
View File
@@ -366,10 +366,9 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
"{} 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)",
));