Restore Scenario inspector from focused Details with stamped admission
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<f32>, 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());
|
||||
}
|
||||
Reference in New Issue
Block a user