Restore Scenario inspector from focused Details with stamped admission

This commit is contained in:
2026-09-13 03:42:37 -07:00
parent 0409a481c7
commit ef1c1be704
4 changed files with 349 additions and 2 deletions
+22
View File
@@ -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());
}