Isolate reorder chooser input and retain exact sequence feedback
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)",
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user