Add explicit keyboard preview and apply for scenario reorder
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user