diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 1e14766a..abbddfb1 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -949,6 +949,15 @@ impl App { ]) } pub fn update(&mut self, message: Message) -> Task { + // Scrollable geometry can change while async work or an overlay blocks + // commands. Keep the observed translation current; its passive handler + // still validates document, scenario and revision before accepting it. + if matches!( + &message, + Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Scrolled(..)) + ) { + return self.handle(message); + } // Global subscriptions also receive keys consumed by an overlay. if self.model_menu.is_some() && matches!(message, Message::Escape) { self.model_menu = None; diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 623f5746..c3e97637 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -1813,6 +1813,109 @@ pub(crate) mod direct_tests { ); } #[test] + fn direct_busy_save_keeps_observed_scroll_current_for_the_next_pointer_gesture() { + let f = fixture(); + let mut app = App::with_project(f.project.clone()); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + let context = app.scenario_context().unwrap(); + let original = app.document.snapshot(); + app.busy = true; + for (area, offset) in [ + (ScrollArea::Canvas, iced::Vector::new(25., 100.)), + (ScrollArea::Page, iced::Vector::new(0., 40.)), + ] { + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( + context, area, offset, + ))); + } + assert!(app.busy); + assert_eq!( + app.behavior.scenario.scroll_offset(), + iced::Vector::new(25., 140.) + ); + assert_ne!( + app.scenario_context(), + Some(context), + "scroll invalidates the pre-save pointer context even while busy" + ); + let mut stale = context; + stale.document = Id::new_v4(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( + stale, + ScrollArea::Canvas, + iced::Vector::new(0., 900.), + ))); + assert_eq!( + app.behavior.scenario.scroll_offset(), + iced::Vector::new(25., 140.) + ); + let root = std::path::PathBuf::from("scenario-scroll-save-fixture"); + let receipt = crate::app::FileSnapshot { + document: context.document, + revision: context.revision, + snapshot: original.clone(), + path: root.join(&original.bundle.entry), + disk: Some(oa_core::source_files::DiskProject { + root, + bundle: original.bundle.clone(), + expectations: BTreeMap::new(), + reachable: original.bundle.files.keys().cloned().collect(), + diagnostics: vec![], + }), + }; + let _ = app.update(Message::Saved(Ok(Some(receipt)))); + assert!(!app.busy); + assert_eq!(app.project(), &original.decoded.project); + assert!(!app.document.can_undo()); + let context = app.scenario_context().unwrap(); + let mut scene = Scene::sequence(app.project(), f.owner, f.call); + scene.interaction = Some((context, CanvasTool::Message(MessageStyle::CallReply), true)); + scene.scroll_offset = app.behavior.scenario.scroll_offset(); + let slot = scene.slots.last().unwrap().slot; + let bounds = Rectangle::new(Point::new(20., 250.), scene.size); + let y = bounds.y + scene.slots.last().unwrap().y; + let a = Point::new(bounds.x + scene.lanes[&f.sender], y) - scene.scroll_offset; + let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) - scene.scroll_offset; + let mut input = super::super::SequenceInput::default(); + let mut published = vec![]; + for event in [ + mouse::Event::CursorMoved { position: a }, + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::CursorMoved { position: b }, + mouse::Event::ButtonReleased(mouse::Button::Left), + ] { + if let Some(message) = canvas::Program::update( + &scene, + &mut input, + &canvas::Event::Mouse(event), + bounds, + mouse::Cursor::Available(b + scene.scroll_offset), + ) + .and_then(|action| action.into_inner().0) + { + published.push(message); + } + } + assert_eq!(published.len(), 1); + assert!( + matches!(&published[0], Message::ScenarioCanvas(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if *sender == f.sender && *receiver == f.receiver && *actual == slot) + ); + let _ = app.update(published.pop().unwrap()); + assert_eq!( + app.behavior.scenario.draft.as_ref().unwrap().before, + slot.before + ); + assert_eq!( + app.behavior.scenario.draft.as_ref().unwrap().sender, + Some(f.sender) + ); + assert_eq!( + app.behavior.scenario.draft.as_ref().unwrap().receiver, + Some(f.receiver) + ); + } + #[test] fn direct_reply_choices_cache_keeps_typing_fast_and_revalidates_changed_contexts() { let f = fixture(); let mut p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap();