From ef1c1be704d807c28ad4a34f9378e0194212a992 Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Sun, 13 Sep 2026 03:42:37 -0700 Subject: [PATCH] Restore Scenario inspector from focused Details with stamped admission --- crates/oa-desktop/src/app.rs | 22 ++ crates/oa-desktop/src/behavior/scenario.rs | 8 + .../src/behavior/scenario/workspace.rs | 11 +- .../scenario/workspace/details_tests.rs | 310 ++++++++++++++++++ 4 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 crates/oa-desktop/src/behavior/scenario/workspace/details_tests.rs diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index d65ad51a..32eebbd7 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -1302,6 +1302,21 @@ impl App { { return Task::none(); } + // A retained Details button may belong to an earlier selection or + // canvas epoch. Refuse it before retiring any newer reveal or draft. + if let behavior::scenario::CanvasEvent::Properties(_, id) = event + && (self.scenario_context() != Some(captured) + || self.selected != *id + || !self.project().contains(*id) + || behavior::selected_behavior(self.project(), *id) != Some(captured.scenario) + || self.behavior.has_pending() + || self.draft_dirty() + || self.source_dirty() + || self.source_rename.open() + || !behavior::canvas_workspace::keyboard_enabled(self)) + { + return Task::none(); + } } if let Message::BehaviorControl(intent) = &message && behavior::canvas_workspace::Scope::current(self).is_none_or(|scope| { @@ -2053,6 +2068,13 @@ impl App { self.behavior.scenario.toggle_shelf(shelf); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } + CanvasEvent::Properties(_, _) => { + // The checked action opens the current record only: no + // select()/Apply side effect or representation change. + self.behavior.scenario.show_properties(); + self.inspector_tab = crate::navigation::InspectorTab::Properties; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } CanvasEvent::Zoom(_, scale) => { let mut state = std::mem::take(&mut self.behavior.scenario); if state.camera_zoom(self.project(), context.scenario, scale) { diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 4125fc06..cea3a738 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -116,6 +116,7 @@ pub enum CanvasEvent { Camera(Context, u64, super::canvas_workspace::Event), Focus(Context), Shelf(Context, Shelf), + Properties(Context, Id), OpenUse(Context, Id), Back(Context), Choose(Context, CanvasTool), @@ -157,6 +158,7 @@ impl CanvasEvent { | Self::Camera(context, ..) | Self::Focus(context) | Self::Shelf(context, _) + | Self::Properties(context, _) | Self::OpenUse(context, _) | Self::Back(context) | Self::Choose(context, _) @@ -501,6 +503,12 @@ impl State { pub(crate) fn toggle_shelf(&mut self, shelf: Shelf) { self.presentation.shelf = (self.presentation.shelf != Some(shelf)).then_some(shelf); } + pub(crate) fn show_properties(&mut self) { + // Properties belong to the inspector. Restore that panel and dismiss + // the competing outline without replacing this view or its camera. + self.presentation.focused = false; + self.presentation.shelf = None; + } pub(crate) fn open_use( &mut self, p: &Project, diff --git a/crates/oa-desktop/src/behavior/scenario/workspace.rs b/crates/oa-desktop/src/behavior/scenario/workspace.rs index 227b26e6..f3db8696 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -371,8 +371,12 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a ); let mut details = column![ text(format!("Selected: {}", p.named(app.selected))).size(12), - button(text("Properties").size(12)) - .on_press_maybe(clean.then_some(Message::EditProperties(app.selected))) + container(button(text("Properties").size(12)).on_press_maybe( + (clean && super::super::selected_behavior(p, app.selected) == Some(owner)).then_some( + Message::ScenarioCanvas(CanvasEvent::Properties(context, app.selected)), + ), + )) + .id("scenario-properties") ] .spacing(6); if referenced_scenario(p, owner, app.selected).is_some() { @@ -922,6 +926,9 @@ mod tests; #[cfg(all(test, debug_assertions))] mod spacing_tests; +#[cfg(all(test, debug_assertions))] +mod details_tests; + fn sequence_rows<'a>( p: &'a Project, context: Context, diff --git a/crates/oa-desktop/src/behavior/scenario/workspace/details_tests.rs b/crates/oa-desktop/src/behavior/scenario/workspace/details_tests.rs new file mode 100644 index 00000000..1d289e59 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/workspace/details_tests.rs @@ -0,0 +1,310 @@ +//! Mounted Scenario Details → inspector regression and retained-intent guards. +use super::*; +use crate::behavior::canvas_workspace::tests::Mounted; +use crate::behavior::scenario_layout::resolve; +use iced::advanced::widget; +use iced::{Vector, keyboard}; +use std::sync::Arc; + +const FIXTURE: &str = include_str!("../../../app/diagram_usages/tests/saved_scenario.arcadia"); + +fn id(app: &App, alias: &str) -> Id { + app.project() + .identities() + .into_iter() + .find(|(_, name)| *name == alias) + .unwrap() + .0 +} +fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) { + let context = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(event(context))); +} +fn setup() -> (App, Id, Id) { + let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap()); + let owner = id(&app, "incident_response"); + let diagram = id(&app, "incident_readable"); + let message = id(&app, "initial_report"); + let _ = app.update(Message::Select(owner)); + let _ = app.update(Message::Diagram(crate::app::Choice { + id: diagram, + label: "A".into(), + })); + send(&mut app, |c| CanvasEvent::Select(c, message)); + app.window_size = Size::new(1440., 1000.); + (app, diagram, message) +} +fn open_focused_details(app: &mut App) { + if !app.behavior.scenario.focused() { + send(app, CanvasEvent::Focus); + } + if app.behavior.scenario.presentation.shelf.is_none() { + send(app, |c| CanvasEvent::Shelf(c, Shelf::Selection)); + } +} +fn stored_camera(app: &App) -> (Vector, Option, bool) { + let p = &app.behavior.scenario.presentation; + (p.pan, p.zoom, p.fit) +} + +#[tokio::test] +async fn focused_saved_details_opens_real_editable_inspector_and_returns_to_same_view() { + let (mut app, diagram, message) = setup(); + let _ = app.update(Message::InspectorTab( + crate::navigation::InspectorTab::Connections, + )); + let mut mounted = Mounted::new(&mut app).await; + open_focused_details(&mut app); + mounted.settle(&mut app).await; + let before = mounted.geometry(&app); + assert!(!before.scrolls.contains_key(&widget::Id::new("inspector"))); + let (details, offset) = before.scrolls[&widget::Id::new("scenario-details")]; + let button = before.bounds("scenario-properties").center() - offset; + assert!( + details.contains(button), + "Properties must be actually visible" + ); + let context = app.scenario_context().unwrap(); + let camera = stored_camera(&app); + let original = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + let messages = mounted.click(&mut app, button).await; + assert!(messages.iter().any(|event| matches!(event, + Message::ScenarioCanvas(CanvasEvent::Properties(c, selected)) + if *c == context && *selected == message))); + mounted.settle(&mut app).await; + assert!(!app.behavior.scenario.focused()); + assert!(app.behavior.scenario.presentation.shelf.is_none()); + assert_eq!( + app.inspector_tab, + crate::navigation::InspectorTab::Properties + ); + assert_eq!(app.selected, message); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!(app.page, crate::app::Page::Behavior); + let current = app.scenario_context().unwrap(); + assert_eq!(current.representation, context.representation); + assert_eq!(current.visit, context.visit); + assert_eq!( + stored_camera(&app), + camera, + "restoring panels retains camera intent" + ); + assert!(Arc::ptr_eq(&original, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); + + let shown = mounted.geometry(&app); + assert!( + !shown + .scrolls + .contains_key(&widget::Id::new("scenario-details")) + ); + let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")]; + assert!(inspector.width > 250. && inspector.height > 500.); + // Production Properties places Name before the other scalar text inputs. + // Account for the retained inspector scroll, rather than clicking content coordinates. + let input = shown + .text_inputs + .iter() + .map(|bounds| bounds.center() - offset) + .filter(|point| inspector.contains(*point)) + .min_by(|a, b| a.y.total_cmp(&b.y)) + .expect("visible editable property"); + mounted.click(&mut app, input).await; + let typed = mounted + .batch( + &mut app, + &[iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character("x".into()), + modified_key: keyboard::Key::Character("x".into()), + physical_key: keyboard::key::Physical::Unidentified( + keyboard::key::NativeCode::Unidentified, + ), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: Some("x".into()), + repeat: false, + })], + mouse::Cursor::Available(input), + ) + .await; + assert!( + typed + .iter() + .any(|message| matches!(message, Message::Field(path, _) if path == "/name")), + "real Name editor must publish its edit: {typed:?}" + ); + assert!(app.draft_dirty()); + assert!( + Arc::ptr_eq(&original, &app.document.snapshot()), + "typing is only a draft" + ); + let _ = app.update(Message::RevertProperties); + assert!(!app.draft_dirty()); + send(&mut app, CanvasEvent::Focus); + mounted.settle(&mut app).await; + let returned = mounted.geometry(&app); + assert!(!returned.scrolls.contains_key(&widget::Id::new("inspector"))); + let viewport = returned.bounds("scenario-viewport"); + assert_eq!(app.selected, message); + assert_eq!(app.scenario_context().unwrap().visit, context.visit); + assert_eq!( + app.scenario_context().unwrap().representation, + context.representation + ); + assert_eq!(stored_camera(&app), camera); + let geometry = resolve(app.project(), context.representation, message).unwrap(); + let line = geometry + .scene + .lines + .iter() + .find(|line| line.id == message) + .unwrap(); + let effective = app.behavior.scenario.presentation.camera(geometry.bounds); + let point = viewport.position() + + effective.pan + + Vector::new(line.points[0].x, line.points[0].y) * effective.scale; + assert!( + viewport.contains(point), + "the same selected message remains in fitted view" + ); + assert!(Arc::ptr_eq(&original, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn details_properties_refuses_pending_property_source_and_behavior_drafts_without_apply() { + for kind in 0..3 { + let (mut app, _, message) = setup(); + open_focused_details(&mut app); + match kind { + 0 => { + let _ = app.update(Message::Field( + "/name".into(), + "Retained property draft".into(), + )); + assert!(app.draft_dirty()); + } + 1 => { + let _ = app.update(Message::SourceEdit( + iced::widget::text_editor::Action::Edit( + iced::widget::text_editor::Edit::Insert('x'), + ), + )); + assert!(app.source_dirty()); + } + _ => { + let owner = app.scenario_context().unwrap().scenario; + let _ = app.update(Message::Behavior(super::super::super::Event::Scenario( + Event::Start(owner, Tool::Lifeline), + ))); + assert!(app.behavior.has_pending()); + } + } + let snapshot = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + let property = app.draft.as_ref().unwrap().materialize(); + let source = app.source_editor.text(); + let behavior = app.behavior.recovery_snapshot(); + let camera = app.behavior.scenario.camera_boundary(); + let context = app.scenario_context().unwrap(); + let epochs = (app.canvas_epoch, app.camera_epoch); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties( + context, message, + ))); + assert!(app.behavior.scenario.camera_boundary() == camera); + assert_eq!(app.scenario_context(), Some(context)); + assert_eq!((app.canvas_epoch, app.camera_epoch), epochs); + assert_eq!(app.draft.as_ref().unwrap().materialize(), property); + assert_eq!(app.source_editor.text(), source); + assert_eq!(app.behavior.recovery_snapshot(), behavior); + assert_eq!(app.selected, message); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} + +#[test] +fn details_properties_rejects_retired_view_selection_and_epoch_without_changing_panels() { + for kind in 0..3 { + let (mut app, diagram, message) = setup(); + open_focused_details(&mut app); + let captured = app.scenario_context().unwrap(); + match kind { + 0 => { + let other = id(&app, "incident_wide"); + for view in [other, diagram] { + let _ = app.update(Message::Diagram(crate::app::Choice { + id: view, + label: "view".into(), + })); + } + open_focused_details(&mut app); + assert_ne!(app.scenario_context().unwrap().visit, captured.visit); + } + 1 => { + let other = id(&app, "updated_report"); + send(&mut app, |c| CanvasEvent::Select(c, other)); + } + _ => { + send(&mut app, |c| CanvasEvent::Zoom(c, Some(0.5))); + } + } + let camera = app.behavior.scenario.camera_boundary(); + let context = app.scenario_context(); + let selected = app.selected; + let tab = app.inspector_tab; + let epochs = (app.canvas_epoch, app.camera_epoch); + let snapshot = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties( + captured, message, + ))); + assert!(app.behavior.scenario.camera_boundary() == camera); + assert_eq!(app.scenario_context(), context); + assert_eq!(app.selected, selected); + assert_eq!(app.inspector_tab, tab); + assert_eq!((app.canvas_epoch, app.camera_epoch), epochs); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + } +} + +#[test] +fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish() { + use super::super::spacing::{Event as Spacing, Target, controller::tests::packet}; + let (mut app, _, message) = setup(); + send(&mut app, |c| { + CanvasEvent::Viewport(c, Size::new(900., 600.)) + }); + open_focused_details(&mut app); + let begin = packet( + app.project(), + &app.behavior.scenario, + app.scenario_context().unwrap(), + app.camera_epoch, + Target::Message(message), + ); + let Spacing::Begin { press_screen, .. } = begin else { + panic!() + }; + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(begin))); + assert_eq!(app.behavior.scenario.spacing_token(), Some(begin.token())); + let snapshot = app.document.snapshot(); + let camera = stored_camera(&app); + send(&mut app, |c| CanvasEvent::Properties(c, message)); + assert!(app.behavior.scenario.spacing_token().is_none()); + assert_eq!(stored_camera(&app), camera); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing( + Spacing::Finish { + stamp: begin.stamp(), + token: begin.token(), + screen: press_screen + Vector::new(0., 40.), + }, + ))); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); +}