Integrate persistent canvas controls with scenario lifetime rendering

This commit is contained in:
2026-09-11 12:44:06 -07:00
parent 32334c6631
commit a57c334b30
42 changed files with 11751 additions and 2163 deletions
+265 -230
View File
@@ -14,9 +14,11 @@ use iced::{
};
use oa_core::*;
use std::collections::BTreeMap;
pub(crate) mod canvas_workspace;
#[cfg(test)]
mod contextual_source_tests;
pub(crate) mod creation;
mod form_session;
pub(crate) mod scenario;
mod scenario_lifecycle;
pub(crate) mod source_draft;
@@ -37,6 +39,7 @@ mod scenario_scroll {
pub(super) fn observe<'a, R: renderer::Renderer + 'a>(
context: scenario::Context,
area: scenario::ScrollArea,
process: bool,
restore: Option<scenario::ScrollRestore>,
content: impl Into<Element<'a, Message, Theme, R>>,
) -> Element<'a, Message, Theme, R> {
@@ -44,28 +47,10 @@ mod scenario_scroll {
content: content.into(),
context,
area,
process: false,
process,
restore,
})
}
pub(super) fn observe_topology<'a>(
context: topology::Context,
area: scenario::ScrollArea,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
Element::new(Observer {
content: content.into(),
context: scenario::Context {
document: context.document,
scenario: context.owner,
revision: context.revision,
epoch: context.epoch,
},
area,
process: true,
restore: None,
})
}
struct Observer<'a, R = Renderer> {
content: Element<'a, Message, Theme, R>,
context: scenario::Context,
@@ -336,7 +321,7 @@ mod scenario_scroll {
fn observer(height: f32, context: scenario::Context) -> Observer<'static, ()> {
Observer {
context,
area: scenario::ScrollArea::Canvas,
area: scenario::ScrollArea::Page,
process: false,
restore: None,
content: scrollable(iced::widget::Space::new().width(200.).height(height))
@@ -406,97 +391,96 @@ mod scenario_scroll {
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());
let area = 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]
@@ -578,9 +562,9 @@ mod scenario_scroll {
other.scenario = Id::new_v4();
let mut state = topology::State::default();
state.enter(context.document, context.scenario);
state.scrolled(scenario::ScrollArea::Canvas, Vector::new(0., 180.));
state.scrolled(scenario::ScrollArea::Page, Vector::new(0., 180.));
state.enter(other.document, other.scenario);
assert_eq!(state.scroll_offset(), Vector::ZERO);
assert_eq!(state.details_offset(), Vector::ZERO);
view = observer(600., other);
view.process = true;
view.diff(&mut tree);
@@ -597,92 +581,91 @@ mod scenario_scroll {
{
use widget::operation::scrollable::{AbsoluteOffset, scroll_to};
for process in [false, true] {
for area in [scenario::ScrollArea::Canvas, scenario::ScrollArea::Page] {
for change in 0..4 {
let context = scenario::direct_tests::context(Id::new_v4());
let mut view = observer(600., context);
view.process = process;
view.area = area;
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
let node = view.layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(800., 700.)),
);
// Model a programmatic reveal before the observer has reported.
// Mounted identity must not depend on its last scroll notification.
let mut operation = scroll_to::<()>(
widget::Id::new("observer-test"),
AbsoluteOffset {
x: None,
y: Some(180.),
},
);
view.operate(&mut tree, Layout::new(&node), &(), &mut operation);
let mut next = context;
match change {
0 => next.scenario = Id::new_v4(),
1 => next.document = Id::new_v4(),
2 => {
next.revision += 1;
next.epoch += 5;
}
_ => {}
let area = scenario::ScrollArea::Page;
for change in 0..4 {
let context = scenario::direct_tests::context(Id::new_v4());
let mut view = observer(600., context);
view.process = process;
view.area = area;
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
let node = view.layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(800., 700.)),
);
// Model a programmatic reveal before the observer has reported.
// Mounted identity must not depend on its last scroll notification.
let mut operation = scroll_to::<()>(
widget::Id::new("observer-test"),
AbsoluteOffset {
x: None,
y: Some(180.),
},
);
view.operate(&mut tree, Layout::new(&node), &(), &mut operation);
let mut next = context;
match change {
0 => next.scenario = Id::new_v4(),
1 => next.document = Id::new_v4(),
2 => {
next.revision += 1;
next.epoch += 5;
}
view = observer(if change == 3 { 800. } else { 600. }, next);
view.process = process;
view.area = area;
view.diff(&mut tree);
let redraw = || {
iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))
};
let (messages, _) = update(&mut view, &mut tree, redraw());
let actual = messages
.into_iter()
.filter_map(|message| match message {
Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
c,
reported_area,
offset,
)) => {
assert_eq!(
(c.document, c.owner, c.revision, c.epoch),
(next.document, next.scenario, next.revision, next.epoch)
);
assert_eq!(
std::mem::discriminant(&reported_area),
std::mem::discriminant(&area)
);
Some(offset)
}
Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
c,
reported_area,
offset,
)) => {
assert_eq!(c, next);
assert_eq!(
std::mem::discriminant(&reported_area),
std::mem::discriminant(&area)
);
Some(offset)
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
actual,
vec![if change < 2 {
Vector::ZERO
} else {
Vector::new(0., 180.)
}],
"process={process} area={area:?} change={change}"
);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
_ => {}
}
view = observer(if change == 3 { 800. } else { 600. }, next);
view.process = process;
view.area = area;
view.diff(&mut tree);
let redraw = || {
iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))
};
let (messages, _) = update(&mut view, &mut tree, redraw());
let actual = messages
.into_iter()
.filter_map(|message| match message {
Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
c,
reported_area,
offset,
)) => {
assert_eq!(
(c.document, c.owner, c.revision, c.epoch),
(next.document, next.scenario, next.revision, next.epoch)
);
assert_eq!(
std::mem::discriminant(&reported_area),
std::mem::discriminant(&area)
);
Some(offset)
}
Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
c,
reported_area,
offset,
)) => {
assert_eq!(c, next);
assert_eq!(
std::mem::discriminant(&reported_area),
std::mem::discriminant(&area)
);
Some(offset)
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
actual,
vec![if change < 2 {
Vector::ZERO
} else {
Vector::new(0., 180.)
}],
"process={process} area={area:?} change={change}"
);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
}
}
}
@@ -1141,12 +1124,26 @@ struct BoxItem {
detail: String,
marker: Option<Kind>,
}
fn box_text_clip(rect: Rectangle, scale: f32, viewport: Size) -> Option<Rectangle> {
if !scale.is_finite() || scale <= 0. || rect.width <= 20. || rect.height <= 16. {
fn box_text_clip(
pan: iced::Vector,
rect: Rectangle,
scale: f32,
viewport: Size,
) -> Option<Rectangle> {
if !pan.x.is_finite()
|| !pan.y.is_finite()
|| !scale.is_finite()
|| scale <= 0.
|| rect.width <= 20.
|| rect.height <= 16.
{
return None;
}
Rectangle::new(
Point::new((rect.x + 10.) * scale, (rect.y + 8.) * scale),
Point::new(
pan.x + (rect.x + 10.) * scale,
pan.y + (rect.y + 8.) * scale,
),
Size::new((rect.width - 20.) * scale, (rect.height - 16.) * scale),
)
.intersection(&Rectangle::new(Point::ORIGIN, viewport))
@@ -1165,7 +1162,7 @@ struct Scene {
size: Size,
selected: Id,
scale: f32,
scroll_offset: iced::Vector,
camera_pan: iced::Vector,
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
pending_placement: Option<(scenario::Slot, Option<Id>)>,
lanes: BTreeMap<Id, f32>,
@@ -1220,7 +1217,7 @@ impl Scene {
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
let mut scene = Self {
scale: 1.,
scroll_offset: iced::Vector::ZERO,
camera_pan: iced::Vector::ZERO,
boxes: vec![],
lines: vec![],
size: Size::new(700., 200.),
@@ -1931,8 +1928,7 @@ impl canvas::Program<Message> for Scene {
if !editable {
state.cancel();
}
// Scrollable marks a pointer outside its visible viewport unavailable,
// even when it lies inside the full (scrolled) canvas rectangle.
// The viewport masks input outside its visible canvas bounds.
let visible = cursor.position().is_some();
if !visible
&& matches!(
@@ -1955,7 +1951,7 @@ impl canvas::Program<Message> for Scene {
SequencePress::Occurrence { .. } => {}
}
}
mouse::Cursor::Available(*position + self.scroll_offset)
mouse::Cursor::Available(*position)
}
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. })
| canvas::Event::Window(iced::window::Event::Resized(_))
@@ -1967,11 +1963,9 @@ impl canvas::Program<Message> for Scene {
}
canvas::Event::Mouse(
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
) => state
.pointer
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
) => state.pointer.map_or(cursor, mouse::Cursor::Available),
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
state.pointer = cursor.position();
cursor
}
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
@@ -1983,9 +1977,12 @@ impl canvas::Program<Message> for Scene {
}
_ => cursor,
};
let world = cursor
.position_in(bounds)
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
let world = cursor.position_in(bounds).map(|p| {
Point::new(
(p.x - self.camera_pan.x) / self.scale,
(p.y - self.camera_pan.y) / self.scale,
)
});
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if !visible {
@@ -2005,14 +2002,14 @@ impl canvas::Program<Message> for Scene {
state.press = Some(SequencePress::Message {
source,
slot,
start: cursor.position()? - self.scroll_offset,
start: cursor.position()?,
first,
moved: false,
});
return Some(Action::request_redraw().and_capture());
}
if let CanvasTool::Add(tool) = tool {
if !tool.canvas_occurrence() {
if !tool.canvas_occurrence() && tool != scenario::Tool::Lifeline {
return None;
}
state.press = Some(SequencePress::Occurrence {
@@ -2022,11 +2019,19 @@ impl canvas::Program<Message> for Scene {
});
return Some(Action::request_redraw().and_capture());
}
if self.reorder_handle().is_some_and(|r| r.contains(point)) {
if let Some(occurrence) = self
.hit(point)
.filter(|id| self.rows.contains_key(id))
.or_else(|| {
self.reorder_handle()
.filter(|r| r.contains(point))
.map(|_| self.selected)
})
{
state.press = Some(SequencePress::Reorder {
occurrence: self.selected,
parent: self.rows[&self.selected].0,
start: cursor.position()? - self.scroll_offset,
occurrence,
parent: self.rows[&occurrence].0,
start: cursor.position()?,
moved: false,
});
return Some(Action::request_redraw().and_capture());
@@ -2100,6 +2105,18 @@ impl canvas::Program<Message> for Scene {
);
}
}
SequencePress::Reorder {
occurrence,
moved: false,
..
} => {
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
context, occurrence,
)))
.and_capture(),
);
}
SequencePress::Reorder {
occurrence,
parent,
@@ -2137,6 +2154,7 @@ impl canvas::Program<Message> for Scene {
) -> Vec<canvas::Geometry> {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
f.translate(self.camera_pan);
f.scale(self.scale);
self.lifecycle.draw_lanes(&mut f, self.selected);
for b in &self.boxes {
@@ -2217,10 +2235,11 @@ impl canvas::Program<Message> for Scene {
}
}
for b in &self.boxes {
if let Some(clip) = box_text_clip(b.rect, self.scale, f.size()) {
if let Some(clip) = box_text_clip(self.camera_pan, b.rect, self.scale, f.size()) {
// Clipped child frames start with an identity transform. Match
// the card's scale explicitly, as for lifecycle Create labels.
// the card's pan and scale, as for lifecycle Create labels.
f.with_clip(clip, |label| {
label.translate(self.camera_pan);
label.scale(self.scale);
label.fill_text(Text {
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
@@ -2255,12 +2274,15 @@ impl canvas::Program<Message> for Scene {
});
}
self.lifecycle
.draw_markers(&mut f, self.selected, self.scale);
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
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));
let point = cursor.position_in(bounds).map(|p| {
Point::new(
(p.x - self.camera_pan.x) / self.scale,
(p.y - self.camera_pan.y) / self.scale,
)
});
let source = active.then_some(state.source).flatten();
let reorder = active.then_some(state.press).flatten().and_then(|press| {
if let SequencePress::Reorder { parent, .. } = press {
@@ -2305,6 +2327,18 @@ impl canvas::Program<Message> for Scene {
} else {
None
};
if reorder.is_some()
&& slot.is_none()
&& let Some(point) = point
{
f.fill_text(Text {
content: "Other operand: move stays in the current sequence".into(),
position: Point::new(point.x + 8., point.y + 12.),
size: (11. / self.scale).into(),
color: Color::from_rgb(0.75, 0.2, 0.15),
..Default::default()
});
}
if let Some(slot) = slot {
f.stroke(
&Path::line(
@@ -2510,25 +2544,26 @@ mod direct_canvas_tests {
}
}
#[test]
fn direct_scrolled_canvas_maps_raw_pointer_events_to_the_visible_slot() {
fn direct_panned_canvas_maps_ordered_pointer_events_to_the_visible_slot() {
let (f, mut scene) = scene();
scene.scroll_offset = iced::Vector::new(35., 140.);
scene.camera_pan = iced::Vector::new(-35., -140.);
let bounds = Rectangle::new(Point::new(20., 230.), scene.size);
let slot = scene.slots.last().unwrap().slot;
let y = scene.slots.last().unwrap().y;
let a = Point::new(bounds.x + scene.lanes[&f.sender], bounds.y + y) - scene.scroll_offset;
let b = Point::new(bounds.x + scene.lanes[&f.receiver], bounds.y + y) - scene.scroll_offset;
let a = Point::new(bounds.x + scene.lanes[&f.sender], bounds.y + y) + scene.camera_pan;
let b = Point::new(bounds.x + scene.lanes[&f.receiver], bounds.y + y) + scene.camera_pan;
assert!(bounds.contains(a) && bounds.contains(b));
let mut state = SequenceInput::default();
// Iced Scrollable passes raw event coordinates and a translated cursor.
// Batched pointer events use their own positions with the final cursor.
for event in [
mouse::Event::CursorMoved { position: a },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: b },
] {
send(&scene, &mut state, event, b + scene.scroll_offset, bounds);
send(&scene, &mut state, event, b, bounds);
}
assert!(
matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b + scene.scroll_offset, bounds), Some(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if sender == f.sender && receiver == f.receiver && actual == slot)
matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds), Some(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if sender == f.sender && receiver == f.receiver && actual == slot)
);
}
#[test]