Add explicit keyboard preview and apply for scenario reorder

This commit is contained in:
2026-09-24 17:16:03 -07:00
parent c9f6a3da40
commit 652ffb4d1b
5 changed files with 269 additions and 10 deletions
+19
View File
@@ -24,6 +24,8 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
&& app.architecture.is_none()));
Element::new(Bridge {
content,
reorder_active: app.scenario_reorder_enabled() && app.scenario_reorder.session.is_some(),
reorder_apply: crate::behavior::scenario::reorder_view::apply_message(app),
messages: [
architecture_authoring::captured_escape_message(app),
modal.then_some(Message::Escape).or_else(|| {
@@ -41,6 +43,8 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
struct Bridge<'a, R> {
content: Element<'a, Message, Theme, R>,
messages: [Option<Message>; 2],
reorder_active: bool,
reorder_apply: Option<Message>,
}
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
@@ -94,6 +98,21 @@ impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if self.reorder_active
&& matches!(event,
Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Enter), modifiers, .. })
if modifiers.control() && !modifiers.alt() && !modifiers.shift() && !modifiers.logo())
{
if matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed { repeat: false, .. })
) && let Some(message) = &self.reorder_apply
{
shell.publish(message.clone());
}
shell.capture_event();
return;
}
let escape = matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed {
@@ -163,6 +163,9 @@ impl App {
Ok(session) => {
self.scenario_reorder.session = Some(session);
self.status = "Choose a semantic destination, then apply reorder. Saved positions are preserved.".into();
return iced::widget::text_input::focus(scenario::reorder_view::query_id(
event.token,
));
}
Err(reason) => self.status = reason,
}
@@ -537,13 +537,15 @@ fn synthetic_candidate_unavailable_and_hidden_selection_cannot_commit() {
#[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};
use iced::{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);
let start = packet(&app, Intent::Begin);
let task = app.update(Message::ScenarioReorder(Box::new(start)));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
let token = app
.scenario_reorder
@@ -551,7 +553,7 @@ async fn mounted_query_owns_space_arrows_wheel_and_root_escape_cancels() {
.as_ref()
.expect("overlay remains open after layout")
.token();
let query_id = widget::Id::new("scenario-reorder-query");
let query_id = scenario::reorder_view::query_id(token);
let geometry = mounted.geometry(&app);
let point = geometry
.focused
@@ -560,7 +562,6 @@ async fn mounted_query_owns_space_arrows_wheel_and_root_escape_cancels() {
.expect("identified query input")
.1
.center();
mounted.click(&mut app, point).await;
assert!(
mounted
.geometry(&app)
@@ -642,3 +643,209 @@ async fn mounted_query_owns_space_arrows_wheel_and_root_escape_cancels() {
assert!(camera == app.behavior.scenario.camera_boundary());
assert!(!app.document.can_undo());
}
fn enter(control: bool, repeat: bool) -> iced::Event {
use iced::keyboard::{self, key::Named};
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(Named::Enter),
modified_key: keyboard::Key::Named(Named::Enter),
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: if control {
keyboard::Modifiers::CTRL
} else {
keyboard::Modifiers::empty()
},
text: None,
repeat,
})
}
#[tokio::test]
async fn mounted_keyboard_previews_then_applies_once_and_never_commits_enter_repeat() {
use crate::behavior::canvas_workspace::tests::Mounted;
use iced::{keyboard, mouse};
let (mut app, owner, [a, b, x, y]) = setup(true);
app.window_size = iced::Size::new(1440., 1000.);
let mut mounted = Mounted::new(&mut app).await;
let baseline = app.document.snapshot();
let event = packet(&app, Intent::Begin);
let task = app.update(Message::ScenarioReorder(Box::new(event)));
mounted.perform(&mut app, task).await;
let token = app.scenario_reorder.session.as_ref().unwrap().token();
let id = scenario::reorder_view::query_id(token);
assert!(
mounted
.geometry(&app)
.focused
.iter()
.any(|(actual, _, focused)| actual.as_ref() == Some(&id) && *focused)
);
for c in "End of sequence".chars() {
let value = c.to_string();
let key = keyboard::Key::Character(value.clone().into());
mounted
.batch(
&mut app,
&[iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: key.clone(),
modified_key: key,
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: Some(value.into()),
repeat: false,
})],
mouse::Cursor::Unavailable,
)
.await;
}
assert_eq!(app.scenario_reorder.query, "End of sequence");
assert!(
app.scenario_reorder
.session
.as_ref()
.unwrap()
.current_preview()
.is_none()
);
// Ctrl+Enter with one search match but no explicitly previewed proposal must
// be captured at the root, not fall through to the text input's submit.
let messages = mounted
.batch(&mut app, &[enter(true, false)], mouse::Cursor::Unavailable)
.await;
assert!(!messages.iter().any(|message| matches!(message, Message::ScenarioReorder(event) if matches!(event.intent, Intent::Choose(_) | Intent::Finish(_)))));
assert!(
app.scenario_reorder
.session
.as_ref()
.unwrap()
.current_preview()
.is_none()
);
let slot = Slot {
parent: owner,
before: None,
};
let messages = mounted
.batch(&mut app, &[enter(false, false)], mouse::Cursor::Unavailable)
.await;
assert!(messages.iter().any(|message| matches!(message, Message::ScenarioReorder(event) if matches!(event.intent, Intent::Choose(actual) if actual == slot))));
assert_eq!(
app.scenario_reorder
.session
.as_ref()
.unwrap()
.current_preview()
.unwrap()
.slot,
slot
);
for event in [enter(false, true), enter(false, false), enter(true, true)] {
mounted
.batch(&mut app, &[event], mouse::Cursor::Unavailable)
.await;
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
}
let messages = mounted
.batch(&mut app, &[enter(true, false)], mouse::Cursor::Unavailable)
.await;
assert_eq!(messages.iter().filter(|message| matches!(message, Message::ScenarioReorder(event) if matches!(event.intent, Intent::Finish(actual) if actual == slot))).count(), 1);
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);
assert!(app.scenario_reorder.session.is_none());
let committed = app.document.snapshot();
mounted
.batch(&mut app, &[enter(true, true)], mouse::Cursor::Unavailable)
.await;
assert_eq!(app.document.bundle(), &committed.bundle);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
}
#[tokio::test]
async fn mounted_refused_preview_blocks_ctrl_enter_and_old_focus_task_cannot_focus_new_token() {
use crate::behavior::canvas_workspace::tests::Mounted;
use iced::mouse;
let (mut app, owner, [call, reply, _, _]) = setup(true);
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,
))));
app.window_size = iced::Size::new(1440., 1000.);
let mut mounted = Mounted::new(&mut app).await;
let start = packet(&app, Intent::Begin);
let old_focus = app.update(Message::ScenarioReorder(Box::new(start)));
let cancel = packet(&app, Intent::Cancel);
send(&mut app, cancel);
let start = packet(&app, Intent::Begin);
let new_focus = app.update(Message::ScenarioReorder(Box::new(start)));
let token = app.scenario_reorder.session.as_ref().unwrap().token();
let query_id = scenario::reorder_view::query_id(token);
mounted.perform(&mut app, old_focus).await;
assert!(
!mounted
.geometry(&app)
.focused
.iter()
.any(|(id, _, focused)| id.as_ref() == Some(&query_id) && *focused)
);
mounted.perform(&mut app, new_focus).await;
assert!(
mounted
.geometry(&app)
.focused
.iter()
.any(|(id, _, focused)| id.as_ref() == Some(&query_id) && *focused)
);
choose(
&mut app,
Slot {
parent: owner,
before: Some(call),
},
);
assert!(matches!(
app.scenario_reorder
.session
.as_ref()
.unwrap()
.current_preview()
.unwrap()
.outcome,
Outcome::Refused(_)
));
let epoch = app.canvas_epoch;
let baseline = app.document.snapshot();
let messages = mounted
.batch(
&mut app,
&[enter(true, false), enter(true, true)],
mouse::Cursor::Unavailable,
)
.await;
assert!(!messages.iter().any(|message| matches!(message, Message::ScenarioReorder(event) if matches!(event.intent, Intent::Choose(_) | Intent::Finish(_)))));
assert_eq!(
app.scenario_reorder.session.as_ref().unwrap().token(),
token
);
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
}
@@ -6,6 +6,25 @@ use iced::widget::{column, opaque, scrollable};
const PAGE_SIZE: usize = 5;
pub(crate) fn query_id(token: Id) -> iced::widget::Id {
iced::widget::Id::from(format!("scenario-reorder-query:{token}"))
}
pub(crate) fn apply_message(app: &App) -> Option<Message> {
if !app.scenario_reorder_enabled() {
return None;
}
let session = app.scenario_reorder.session.as_ref()?;
let preview = session.current_preview()?;
(preview.selection_visible
&& !matches!(preview.outcome, reorder_session::Outcome::Refused(_))
&& matches!(
preview.presentation.as_ref()?.status(),
PresentationStatus::Ready | PresentationStatus::Fallback { .. }
))
.then(|| message(session, Intent::Finish(preview.slot)))
}
pub(crate) fn message(session: &reorder_session::Session, intent: Intent) -> Message {
Message::ScenarioReorder(Box::new(Event {
context: session.context(),
@@ -141,8 +160,11 @@ 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")
.id(query_id(session.token()))
.on_input(move |query| message(session, Intent::Query(query)))
.on_submit_maybe(
(slots.len() == 1).then(|| message(session, Intent::Choose(slots[0].0)))
)
.size(13),
choices,
row![
@@ -157,7 +179,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);
let mut footer =
column![text("Enter previews a unique match. Ctrl+Enter applies; Esc cancels.").size(12)]
.spacing(6);
if let Some(preview) = session.current_preview() {
footer = footer.push(
text(format!(
@@ -212,7 +236,7 @@ pub(super) fn panel(app: &App, viewport: Option<Size>) -> Option<Element<'_, Mes
footer,
row![
button("Cancel (Esc)").on_press(message(session, Intent::Cancel)),
button("Apply order").on_press_maybe(apply)
button("Apply order (Ctrl+Enter)").on_press_maybe(apply)
]
.spacing(8)
]
@@ -237,11 +237,17 @@ 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(
let reorder = reorder_view::begin(app);
navigation = navigation.push(super::super::canvas_workspace::Control::new(
button(text("Reorder…").size(12))
.padding(6)
.on_press_maybe(reorder_view::begin(app)),
);
.on_press_maybe(reorder.clone()),
scope,
"Reorder",
keyboard,
reorder,
camera,
));
if state.can_back(p, context) {
navigation =
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(