Improve behavior authoring and typed trace history (untested checkpoint)
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s

This commit is contained in:
Codex
2026-09-10 11:07:07 -07:00
parent 1797516f46
commit 01bb52a76d
25 changed files with 4078 additions and 171 deletions
+390 -9
View File
@@ -32,6 +32,7 @@ mod scenario_scroll {
pub(super) fn observe<'a>(
context: scenario::Context,
area: scenario::ScrollArea,
restore: Option<scenario::ScrollRestore>,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
Element::new(Observer {
@@ -39,6 +40,7 @@ mod scenario_scroll {
context,
area,
process: false,
restore,
})
}
pub(super) fn observe_topology<'a>(
@@ -56,6 +58,7 @@ mod scenario_scroll {
},
area,
process: true,
restore: None,
})
}
struct Observer<'a, R = Renderer> {
@@ -63,11 +66,13 @@ mod scenario_scroll {
context: scenario::Context,
area: scenario::ScrollArea,
process: bool,
restore: Option<scenario::ScrollRestore>,
}
type Report = Option<(bool, Id, Id, Vector)>;
struct ObservedScroll {
identity: (Id, Id),
report: Report,
restored: Option<Id>,
}
#[derive(Default)]
struct Translation(Option<Vector>);
@@ -84,6 +89,28 @@ mod scenario_scroll {
self.0 = Some(translation);
}
}
struct RestoreTranslation {
offset: Vector,
applied: bool,
}
impl widget::Operation for RestoreTranslation {
// Restore only this wrapper's root Scrollable, never its nested canvas.
fn traverse(&mut self, _: &mut dyn FnMut(&mut dyn widget::Operation)) {}
fn scrollable(
&mut self,
_: Option<&widget::Id>,
_: Rectangle,
_: Rectangle,
_: Vector,
state: &mut dyn widget::operation::Scrollable,
) {
state.scroll_to(widget::operation::scrollable::AbsoluteOffset {
x: Some(self.offset.x),
y: Some(self.offset.y),
});
self.applied = true;
}
}
impl<R: renderer::Renderer> Observer<'_, R> {
fn report(
&mut self,
@@ -92,6 +119,25 @@ mod scenario_scroll {
renderer: &R,
shell: &mut Shell<'_, Message>,
) {
let restore = self.restore.filter(|request| {
!self.process
&& (request.document, request.owner)
== (self.context.document, self.context.scenario)
&& tree.state.downcast_ref::<ObservedScroll>().restored != Some(request.token)
});
let receipt = restore.and_then(|request| {
let mut operation = RestoreTranslation {
offset: request.offset,
applied: false,
};
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
&mut operation,
);
operation.applied.then_some(request.token)
});
let mut offset = Translation::default();
self.content.as_widget_mut().operate(
&mut tree.children[0],
@@ -106,7 +152,19 @@ mod scenario_scroll {
self.context.scenario,
offset,
));
let previous = &mut tree.state.downcast_mut::<ObservedScroll>().report;
let state = tree.state.downcast_mut::<ObservedScroll>();
if let Some(token) = receipt {
state.restored = Some(token);
state.report = next;
shell.publish(Message::ScenarioCanvas(scenario::CanvasEvent::Restored(
self.context,
token,
self.area,
offset,
)));
return;
}
let previous = &mut state.report;
if *previous != next {
*previous = next;
shell.publish(if self.process {
@@ -139,6 +197,7 @@ mod scenario_scroll {
widget::tree::State::new(ObservedScroll {
identity: (self.context.document, self.context.scenario),
report: None,
restored: None,
})
}
fn children(&self) -> Vec<widget::Tree> {
@@ -274,6 +333,7 @@ mod scenario_scroll {
context,
area: scenario::ScrollArea::Canvas,
process: false,
restore: None,
content: scrollable(iced::widget::Space::new().width(200.).height(height))
.id("observer-test")
.width(200.)
@@ -320,6 +380,154 @@ mod scenario_scroll {
})
.collect()
}
fn actual_offset(view: &mut Observer<'_, ()>, tree: &mut widget::Tree) -> Vector {
let node = view.layout(
tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(800., 700.)),
);
let mut operation = Translation::default();
view.operate(tree, Layout::new(&node), &(), &mut operation);
operation.0.unwrap()
}
#[test]
fn direct_scenario_caller_restores_mounted_scroll_once_with_clamping_and_app_receipt() {
use scenario::{
CanvasEvent, ScrollArea,
direct_tests::{navigation_event, navigation_fixture},
};
let redraw = || {
iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))
};
for area in [ScrollArea::Canvas, ScrollArea::Page] {
for returned_height in [600., 250.] {
let (project, [a, _, _], [use_id, _, _]) = navigation_fixture();
let mut app = App::with_project(project.clone());
let _ = app.update(Message::Select(a));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let source = app.document.bundle().clone();
let mut view = observer(600., app.scenario_context().unwrap());
view.area = area;
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
for message in update(&mut view, &mut tree, redraw()).0 {
let _ = app.update(message);
}
for message in update(
&mut view,
&mut tree,
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. },
}),
)
.0
{
let _ = app.update(message);
}
assert_eq!(actual_offset(&mut view, &mut tree), Vector::new(0., 180.));
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, use_id));
view = observer(600., app.scenario_context().unwrap());
view.area = area;
view.diff(&mut tree);
for message in update(&mut view, &mut tree, redraw()).0 {
let _ = app.update(message);
}
assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO);
navigation_event(&mut app, CanvasEvent::Back);
assert_eq!(app.selected, use_id);
let context = app.scenario_context().unwrap();
let request = app.behavior.scenario.scroll_restore(context, area).unwrap();
view = observer(returned_height, context);
view.area = area;
view.restore = Some(request);
view.diff(&mut tree);
let expected = Vector::new(0., (returned_height - 200.).min(180.));
let messages = update(&mut view, &mut tree, redraw()).0;
assert!(
matches!(messages.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Restored(_, _, _, actual))] if *actual == expected)
);
assert_eq!(actual_offset(&mut view, &mut tree), expected);
for message in messages {
let _ = app.update(message);
}
assert!(
app.behavior
.scenario
.scroll_restore(app.scenario_context().unwrap(), area)
.is_none()
);
// A delayed receipt leaves the same request in the view; later input must not snap back.
for message in update(
&mut view,
&mut tree,
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0., y: 20. },
}),
)
.0
{
let _ = app.update(message);
}
let moved = expected - Vector::new(0., 20.);
assert_eq!(actual_offset(&mut view, &mut tree), moved);
view.context = app.scenario_context().unwrap();
view.diff(&mut tree);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
assert_eq!(actual_offset(&mut view, &mut tree), moved);
navigation_event(&mut app, |ctx| {
CanvasEvent::Restored(ctx, request.token, area, Vector::new(0., 999.))
});
let late_context = app.scenario_context().unwrap();
let _ = app.update(Message::Page(crate::app::Page::Diagram));
let epoch = app.canvas_epoch;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Restored(
late_context,
request.token,
area,
Vector::ZERO,
)));
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.project(), &project);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
}
}
#[test]
fn direct_scenario_restore_cannot_scroll_another_document_owner_or_process_view() {
for mismatch in 0..3 {
let context = scenario::direct_tests::context(Id::new_v4());
let mut request = scenario::ScrollRestore {
document: context.document,
owner: context.scenario,
token: Id::new_v4(),
offset: Vector::new(0., 180.),
};
match mismatch {
0 => request.document = Id::new_v4(),
1 => request.owner = Id::new_v4(),
_ => {}
}
let mut view = observer(600., context);
view.process = mismatch == 2;
view.restore = Some(request);
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
let messages = update(
&mut view,
&mut tree,
iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
)),
)
.0;
assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO);
assert!(!messages.iter().any(|m| matches!(
m,
Message::ScenarioCanvas(scenario::CanvasEvent::Restored(..))
)));
}
}
#[test]
fn independent_process_owner_change_resets_actual_scroll_offsets() {
let context = scenario::direct_tests::context(Id::new_v4());
@@ -711,8 +919,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
return out.push(label("Start with a Process, Scenario or State machine using Create element. Add occurrences or regions inside it to describe behavior.")).padding(24).into();
};
let e = &p.elements[&selected.id];
let compact_process = matches!(e.kind(), Kind::Process | Kind::FunctionalChain)
&& (uses_explicit_topology(p, e.id) || e.steps().is_none_or(Vec::is_empty));
let compact_process = matches!(e.kind(), Kind::Process | Kind::FunctionalChain);
if e.kind() == Kind::StateMachine {
out = column![text("States & modes").size(20)].spacing(8);
} else if compact_process {
@@ -841,6 +1048,7 @@ struct Scene {
scale: f32,
scroll_offset: iced::Vector,
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
pending_placement: Option<(scenario::Slot, Option<Id>)>,
lanes: BTreeMap<Id, f32>,
slots: Vec<SequenceSlot>,
rows: BTreeMap<Id, (Id, f32)>,
@@ -863,6 +1071,11 @@ struct SequenceInput {
}
#[derive(Clone, Copy)]
enum SequencePress {
Occurrence {
tool: scenario::Tool,
slot: scenario::Slot,
lifeline: Option<Id>,
},
Message {
source: Id,
slot: scenario::Slot,
@@ -893,6 +1106,7 @@ impl Scene {
size: Size::new(700., 200.),
selected,
interaction: None,
pending_placement: None,
lanes: BTreeMap::new(),
slots: vec![],
rows: BTreeMap::new(),
@@ -1518,6 +1732,7 @@ impl canvas::Program<Message> for Scene {
| SequencePress::Reorder { start, moved, .. } => {
*moved |= position.distance(*start) >= 3.
}
SequencePress::Occurrence { .. } => {}
}
}
mouse::Cursor::Available(*position + self.scroll_offset)
@@ -1576,6 +1791,17 @@ impl canvas::Program<Message> for Scene {
});
return Some(Action::request_redraw().and_capture());
}
if let CanvasTool::Add(tool) = tool {
if !tool.canvas_occurrence() {
return None;
}
state.press = Some(SequencePress::Occurrence {
tool,
slot: self.slot_at(point, None)?.slot,
lifeline: tool.uses_lifeline().then(|| self.lane_at(point)).flatten(),
});
return Some(Action::request_redraw().and_capture());
}
if self.reorder_handle().is_some_and(|r| r.contains(point)) {
state.press = Some(SequencePress::Reorder {
occurrence: self.selected,
@@ -1597,6 +1823,23 @@ impl canvas::Program<Message> for Scene {
let press = state.press.take()?;
let context = context?;
match press {
SequencePress::Occurrence {
tool: captured,
slot,
lifeline,
} if tool == CanvasTool::Add(captured) && world.is_some() => {
return Some(
Action::publish(Message::ScenarioCanvas(
CanvasEvent::BeginOccurrence {
context,
tool: captured,
slot,
lifeline,
},
))
.and_capture(),
);
}
SequencePress::Message { source, slot, .. }
if tool == CanvasTool::Message(scenario::MessageStyle::Timer)
&& world.is_some() =>
@@ -1783,8 +2026,8 @@ impl canvas::Program<Message> for Scene {
..Default::default()
});
}
if let Some((context, tool, true)) = self.interaction {
let active = state.context == Some(context) && state.tool == tool;
if let Some((context, tool, editable)) = self.interaction {
let active = editable && state.context == Some(context) && state.tool == tool;
let point = cursor
.position_in(bounds)
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
@@ -1796,7 +2039,8 @@ impl canvas::Program<Message> for Scene {
None
}
});
if tool == scenario::CanvasTool::Select
if editable
&& tool == scenario::CanvasTool::Select
&& let Some(handle) = self.reorder_handle()
{
f.fill_rectangle(handle.position(), handle.size(), style::TEAL);
@@ -1808,9 +2052,25 @@ impl canvas::Program<Message> for Scene {
..Default::default()
});
}
let slot = if let Some((_, slot)) = source {
let placement = self.pending_placement.or_else(|| {
active.then_some(state.press).flatten().and_then(|press| {
if let SequencePress::Occurrence { slot, lifeline, .. } = press {
Some((slot, lifeline))
} else {
None
}
})
});
let slot = if let Some((slot, _)) = placement {
self.slots.iter().find(|s| s.slot == slot)
} else if matches!(tool, scenario::CanvasTool::Message(_)) || reorder.is_some() {
} else if let Some((_, slot)) = source {
self.slots.iter().find(|s| s.slot == slot)
} else if editable
&& (matches!(
tool,
scenario::CanvasTool::Message(_) | scenario::CanvasTool::Add(_)
) || reorder.is_some())
{
point.and_then(|p| self.slot_at(p, reorder))
} else {
None
@@ -1829,12 +2089,29 @@ impl canvas::Program<Message> for Scene {
Color::WHITE,
);
f.fill_text(Text {
content: short(&format!("Insert: {}", slot.label), 88),
content: short(
&format!(
"{}: {}",
if self.pending_placement.is_some() {
"Pending"
} else {
"Insert"
},
slot.label
),
88,
),
position: Point::new(38., slot.y - 19.),
size: 11.into(),
color: style::TEAL,
..Default::default()
});
if let Some(x) = placement
.and_then(|(_, lifeline)| lifeline)
.and_then(|id| self.lanes.get(&id))
{
f.fill(&Path::circle(Point::new(*x, slot.y), 5.), style::TEAL);
}
if let Some((source, _)) = source
&& let Some(x) = self.lanes.get(&source)
&& let Some(point) = point
@@ -2246,6 +2523,110 @@ mod direct_canvas_tests {
}
}
#[test]
fn direct_clicked_occurrence_capture_cancels_on_view_and_context_changes() {
for tool in [
scenario::Tool::Action,
scenario::Tool::Observation,
scenario::Tool::Reuse,
scenario::Tool::Fragment,
] {
for cancellation in 0..9 {
let (f, mut scene) = scene();
scene.interaction.as_mut().unwrap().1 = CanvasTool::Add(tool);
let mut state = SequenceInput::default();
let mut bounds = Rectangle::new(Point::ORIGIN, scene.size);
let point = Point::new(scene.lanes[&f.sender], scene.slots[0].y);
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: point },
point,
bounds,
);
assert!(
send(
&scene,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
point,
bounds
)
.is_none()
);
assert!(matches!(
state.press,
Some(SequencePress::Occurrence { .. })
));
let cancellation_event = match cancellation {
0 => {
scene.interaction.as_mut().unwrap().0.epoch += 1;
None
}
1 => {
scene.interaction.as_mut().unwrap().1 = CanvasTool::Select;
None
}
2 => {
scene.interaction.as_mut().unwrap().2 = false;
None
}
3 => Some(canvas::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: -1. },
})),
4 => Some(canvas::Event::Window(iced::window::Event::Resized(
Size::new(1100., 700.),
))),
5 => {
bounds.y -= 30.;
None
}
6 => Some(canvas::Event::Window(iced::window::Event::Unfocused)),
7 => Some(canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
modified_key: iced::keyboard::Key::Named(
iced::keyboard::key::Named::Escape,
),
physical_key: iced::keyboard::key::Physical::Code(
iced::keyboard::key::Code::Escape,
),
location: iced::keyboard::Location::Standard,
modifiers: iced::keyboard::Modifiers::empty(),
text: None,
repeat: false,
})),
_ => None,
};
if let Some(event) = cancellation_event {
let action = canvas::Program::update(
&scene,
&mut state,
&event,
bounds,
mouse::Cursor::Available(point),
);
assert!(action.and_then(|action| action.into_inner().0).is_none());
}
let cursor = if cancellation == 8 {
mouse::Cursor::Unavailable
} else {
mouse::Cursor::Available(point)
};
let released = canvas::Program::update(
&scene,
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
bounds,
cursor,
);
assert!(
released.and_then(|action| action.into_inner().0).is_none(),
"{tool:?} cancellation {cancellation}"
);
assert!(state.press.is_none());
}
}
}
#[test]
fn direct_selected_handle_reorders_and_hits_keep_semantic_identity() {
let (f, mut scene) = scene();
scene.interaction.as_mut().unwrap().1 = CanvasTool::Select;