From 652ffb4d1b21f414a69ebac549459f4bb9e3c16a Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Thu, 24 Sep 2026 17:16:03 -0700 Subject: [PATCH] Add explicit keyboard preview and apply for scenario reorder --- crates/oa-desktop/src/app/escape.rs | 19 ++ crates/oa-desktop/src/app/scenario_reorder.rs | 3 + .../src/app/scenario_reorder/tests.rs | 215 +++++++++++++++++- .../src/behavior/scenario/reorder_view.rs | 30 ++- .../src/behavior/scenario/workspace.rs | 12 +- 5 files changed, 269 insertions(+), 10 deletions(-) diff --git a/crates/oa-desktop/src/app/escape.rs b/crates/oa-desktop/src/app/escape.rs index 9f000dc1..823c7539 100644 --- a/crates/oa-desktop/src/app/escape.rs +++ b/crates/oa-desktop/src/app/escape.rs @@ -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; 2], + reorder_active: bool, + reorder_apply: Option, } impl Widget for Bridge<'_, R> { @@ -94,6 +98,21 @@ impl Widget 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 { diff --git a/crates/oa-desktop/src/app/scenario_reorder.rs b/crates/oa-desktop/src/app/scenario_reorder.rs index b4cbaa99..bf93fd22 100644 --- a/crates/oa-desktop/src/app/scenario_reorder.rs +++ b/crates/oa-desktop/src/app/scenario_reorder.rs @@ -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, } diff --git a/crates/oa-desktop/src/app/scenario_reorder/tests.rs b/crates/oa-desktop/src/app/scenario_reorder/tests.rs index a5bc5308..eff0b9e5 100644 --- a/crates/oa-desktop/src/app/scenario_reorder/tests.rs +++ b/crates/oa-desktop/src/app/scenario_reorder/tests.rs @@ -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()); +} diff --git a/crates/oa-desktop/src/behavior/scenario/reorder_view.rs b/crates/oa-desktop/src/behavior/scenario/reorder_view.rs index bce2d2ff..75c7341b 100644 --- a/crates/oa-desktop/src/behavior/scenario/reorder_view.rs +++ b/crates/oa-desktop/src/behavior/scenario/reorder_view.rs @@ -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 { + 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) -> Option) -> Option) -> Option(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(