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
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -836,6 +836,12 @@ struct GraphEdge {
|
||||
incidence: bool,
|
||||
layer: EdgeLayer,
|
||||
}
|
||||
// Ordered exchange adjacency is a projection, never a persisted relation ID.
|
||||
struct OrderedPath {
|
||||
source: Id,
|
||||
target: Id,
|
||||
points: Vec<Point>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EdgeLayer {
|
||||
Direct,
|
||||
@@ -863,6 +869,8 @@ struct Graph<'a> {
|
||||
scroll_offset: iced::Vector,
|
||||
nodes: Vec<GraphNode>,
|
||||
edges: Vec<GraphEdge>,
|
||||
ordered_paths: Vec<OrderedPath>,
|
||||
ordered: bool,
|
||||
size: Size,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
@@ -884,11 +892,17 @@ impl<'a> Graph<'a> {
|
||||
let context_index = oa_core::topology_context::TopologyIndex::new(p);
|
||||
let choices = contributions(p, owner);
|
||||
let links = sequence_links(p, owner);
|
||||
let order = legacy_order(p, owner);
|
||||
let adjacency: Vec<_> = links
|
||||
.iter()
|
||||
.map(|r| (r.source(), r.target()))
|
||||
.chain(order.windows(2).map(|pair| (pair[0], pair[1])))
|
||||
.collect();
|
||||
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
|
||||
let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
|
||||
let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
|
||||
for r in &links {
|
||||
*incoming.entry(r.target()).or_default() += 1;
|
||||
for &(_, target) in &adjacency {
|
||||
*incoming.entry(target).or_default() += 1;
|
||||
}
|
||||
let mut queue: Vec<_> = choices
|
||||
.iter()
|
||||
@@ -903,8 +917,7 @@ impl<'a> Graph<'a> {
|
||||
if !done.insert(id) {
|
||||
continue;
|
||||
}
|
||||
for r in links.iter().filter(|r| r.source() == id) {
|
||||
let next = r.target();
|
||||
for &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) {
|
||||
ranks.insert(next, ranks[&next].max(ranks[&id] + 1));
|
||||
let count = incoming.get_mut(&next).expect("Graph endpoint");
|
||||
*count = count.saturating_sub(1);
|
||||
@@ -1031,6 +1044,29 @@ impl<'a> Graph<'a> {
|
||||
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
|
||||
nodes.push(node);
|
||||
}
|
||||
let ordered_paths = order
|
||||
.windows(2)
|
||||
.map(|pair| {
|
||||
let a = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == pair[0])
|
||||
.expect("ordered source")
|
||||
.rect;
|
||||
let b = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == pair[1])
|
||||
.expect("ordered target")
|
||||
.rect;
|
||||
OrderedPath {
|
||||
source: pair[0],
|
||||
target: pair[1],
|
||||
points: vec![
|
||||
Point::new(a.x + a.width, a.center_y()),
|
||||
Point::new(b.x, b.center_y()),
|
||||
],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut edges = vec![];
|
||||
let mut routed_pairs = BTreeSet::new();
|
||||
for (i, r) in links.into_iter().enumerate() {
|
||||
@@ -1165,6 +1201,8 @@ impl<'a> Graph<'a> {
|
||||
scroll_offset: iced::Vector::ZERO,
|
||||
nodes,
|
||||
edges,
|
||||
ordered_paths,
|
||||
ordered: !order.is_empty(),
|
||||
size,
|
||||
selected,
|
||||
scale: 1.,
|
||||
@@ -1264,6 +1302,18 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
for pass in PAINT_PASSES {
|
||||
match pass {
|
||||
PaintPass::DirectEdges | PaintPass::ContextualEdges => {
|
||||
if pass == PaintPass::DirectEdges {
|
||||
for path in &self.ordered_paths {
|
||||
let selected =
|
||||
path.source == self.selected || path.target == self.selected;
|
||||
draw_arrow(
|
||||
&mut f,
|
||||
&path.points,
|
||||
if selected { style::TEAL } else { style::MUTED },
|
||||
if selected { 2.5 } else { 1.5 },
|
||||
);
|
||||
}
|
||||
}
|
||||
for e in self.edges.iter().filter(|edge| {
|
||||
edge.layer
|
||||
== if pass == PaintPass::DirectEdges {
|
||||
@@ -1465,6 +1515,11 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
if cursor.position_in(bounds).is_some_and(|p| {
|
||||
let p = Point::new(p.x / self.scale, p.y / self.scale);
|
||||
self.nodes.iter().any(|n| n.rect.contains(p))
|
||||
|| self.ordered_paths.iter().any(|path| {
|
||||
path.points.windows(2).any(|pair| {
|
||||
crate::diagram::distance_to_segment(p, pair[0], pair[1]) < 8. / self.scale
|
||||
})
|
||||
})
|
||||
|| self.edges.iter().any(|e| {
|
||||
e.label.contains(p)
|
||||
|| e.points
|
||||
|
||||
@@ -238,9 +238,7 @@ impl State {
|
||||
) {
|
||||
return Err("Choose a contribution tool.".into());
|
||||
}
|
||||
if !legacy_order(p, context.owner).is_empty() {
|
||||
return Err("Convert the ordered path to explicit links before drawing.".into());
|
||||
}
|
||||
let order = legacy_order(p, context.owner);
|
||||
let link = insertion
|
||||
.link
|
||||
.map(|id| {
|
||||
@@ -268,6 +266,13 @@ impl State {
|
||||
if let Some(r) = link {
|
||||
d.before = Some(r.source());
|
||||
d.after = Some(r.target());
|
||||
} else if !order.is_empty() {
|
||||
let index = insertion
|
||||
.before
|
||||
.and_then(|id| order.iter().position(|v| *v == id))
|
||||
.unwrap_or(order.len());
|
||||
d.before = index.checked_sub(1).map(|i| order[i]);
|
||||
d.after = order.get(index).copied();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -416,6 +421,13 @@ impl Input {
|
||||
}
|
||||
}
|
||||
impl Graph<'_> {
|
||||
fn ordered_path_at(&self, point: Point) -> Option<&OrderedPath> {
|
||||
self.ordered_paths.iter().find(|path| {
|
||||
path.points.windows(2).any(|pair| {
|
||||
crate::diagram::distance_to_segment(point, pair[0], pair[1]) < 8. / self.scale
|
||||
})
|
||||
})
|
||||
}
|
||||
fn node_at(&self, point: Point) -> Option<Id> {
|
||||
self.nodes
|
||||
.iter()
|
||||
@@ -579,7 +591,9 @@ impl Graph<'_> {
|
||||
link: self
|
||||
.edge_at(point)
|
||||
.filter(|_| self.node_at(point).is_none()),
|
||||
before: self.node_at(point),
|
||||
before: self.node_at(point).or_else(|| {
|
||||
self.ordered_path_at(point).map(|path| path.target)
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -807,6 +821,13 @@ impl Graph<'_> {
|
||||
draw_arrow(f, &e.points, style::TEAL, 3.);
|
||||
}
|
||||
"Insert on this path · preserve its guard"
|
||||
} else if let Some(path) = self.ordered_path_at(point) {
|
||||
draw_arrow(f, &path.points, style::TEAL, 3.);
|
||||
"Insert on ordered path · preserve its order"
|
||||
} else if self.ordered && self.node_at(point).is_some() {
|
||||
"Insert before this contribution · preserve path"
|
||||
} else if self.ordered {
|
||||
"Continue the ordered path"
|
||||
} else if self.node_at(point).is_some() {
|
||||
"Add before this contribution · unconnected"
|
||||
} else {
|
||||
|
||||
@@ -970,6 +970,359 @@ fn direct_process_append_preserves_existing_order_and_rejects_incompatible_excha
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_interactive_owner_view(p: &Project, owner: Id) {
|
||||
use iced::advanced::widget::Tree;
|
||||
fn contains(tree: &Tree, tag: iced::advanced::widget::tree::Tag) -> bool {
|
||||
tree.tag == tag || tree.children.iter().any(|child| contains(child, tag))
|
||||
}
|
||||
let mut app = App::with_project(p.clone());
|
||||
app.layer = p.elements[&owner].layer();
|
||||
app.page = crate::app::Page::Behavior;
|
||||
app.selected = owner;
|
||||
app.behavior.active_owner = Some(owner);
|
||||
let graph: Element<'_, Message> = canvas::Canvas::new(Graph::new(p, owner, owner)).into();
|
||||
let tag = Tree::new(graph.as_widget()).tag;
|
||||
let view = view::view(&app, owner, Column::new());
|
||||
assert!(
|
||||
contains(&Tree::new(view.as_widget()), tag),
|
||||
"every path uses the interactive graph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_three_ordered_exchanges_preserve_outer_paths_on_insertion() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut f, next) = empty_exchange_fixture(layer);
|
||||
f.p = execute_batch(
|
||||
&f.p,
|
||||
&[Command::Connect(
|
||||
f.p.relations[&f.exchange].kind(),
|
||||
"Return for reassessment".into(),
|
||||
f.work[2],
|
||||
f.work[0],
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
let last =
|
||||
f.p.relations
|
||||
.values()
|
||||
.find(|r| r.name == "Return for reassessment")
|
||||
.unwrap()
|
||||
.id;
|
||||
for exchange in [f.exchange, next, last] {
|
||||
let node = occurrence(
|
||||
&f.p,
|
||||
f.owner,
|
||||
"Ordered contribution",
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::default(),
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap();
|
||||
}
|
||||
for index in [0, 2] {
|
||||
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
|
||||
let before = doc.bundle().clone();
|
||||
let old_order = legacy_order(doc.project(), f.owner);
|
||||
let graph = Graph::new(doc.project(), f.owner, f.owner);
|
||||
assert_eq!(graph.ordered_paths.len(), 2);
|
||||
assert!(graph.edges.is_empty());
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin(
|
||||
doc.project(),
|
||||
context(f.owner),
|
||||
Tool::Work,
|
||||
ControlKind::Choice,
|
||||
Insertion {
|
||||
link: None,
|
||||
before: Some(old_order[index]),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
state
|
||||
.update(
|
||||
doc.project(),
|
||||
Event::Target(Choice {
|
||||
id: f.work[index],
|
||||
label: "Shared activity".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
doc.execute(state.prepare_pending(doc.project()).unwrap())
|
||||
.unwrap();
|
||||
let added = created(&f.p, doc.project(), f.owner);
|
||||
let mut expected = old_order;
|
||||
expected.insert(index, added);
|
||||
assert_eq!(doc.project().elements[&f.owner].steps().unwrap(), &expected);
|
||||
let links = sequence_links(doc.project(), f.owner);
|
||||
assert_eq!(links.len(), 3);
|
||||
for pair in expected.windows(2) {
|
||||
assert_eq!(
|
||||
links
|
||||
.iter()
|
||||
.filter(|r| r.source() == pair[0] && r.target() == pair[1])
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
for (id, relation) in &f.p.relations {
|
||||
assert_eq!(&doc.project().relations[id], relation);
|
||||
}
|
||||
let after = doc.bundle().clone();
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
assert_eq!(doc.project(), &f.p);
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.bundle(), &after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_incompatible_imported_order_rejects_conversion_without_losing_draft() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut f, _) = empty_exchange_fixture(layer);
|
||||
// Repeated exchange uses have different occurrences, but this legacy path
|
||||
// has an endpoint gap. Converting its implied adjacency must reject.
|
||||
for _ in 0..2 {
|
||||
let node = occurrence(
|
||||
&f.p,
|
||||
f.owner,
|
||||
"Repeated exchange",
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(f.exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::default(),
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap();
|
||||
}
|
||||
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
|
||||
let before = doc.bundle().clone();
|
||||
assert_interactive_owner_view(doc.project(), f.owner);
|
||||
assert_eq!(
|
||||
Graph::new(doc.project(), f.owner, f.owner)
|
||||
.ordered_paths
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin(
|
||||
doc.project(),
|
||||
context(f.owner),
|
||||
Tool::Control,
|
||||
ControlKind::Choice,
|
||||
Insertion::default(),
|
||||
)
|
||||
.unwrap();
|
||||
state
|
||||
.update(
|
||||
doc.project(),
|
||||
Event::Name("Retain this proposed decision".into()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(state.prepare_pending(doc.project()).is_err());
|
||||
assert_eq!(
|
||||
state.draft.as_ref().unwrap().name,
|
||||
"Retain this proposed decision"
|
||||
);
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
assert_eq!(doc.project(), &f.p);
|
||||
assert!(!doc.undo(), "rejection must not create history");
|
||||
state.update(doc.project(), Event::Cancel).unwrap();
|
||||
assert!(state.draft.is_none());
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_empty_first_and_second_exchange_keep_the_same_interactive_renderer() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, next) = empty_exchange_fixture(layer);
|
||||
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
|
||||
for (index, target) in [f.exchange, next].into_iter().enumerate() {
|
||||
assert_interactive_owner_view(doc.project(), f.owner);
|
||||
let before = doc.bundle().clone();
|
||||
let mut graph = Graph::new(doc.project(), f.owner, f.owner);
|
||||
let c = context(f.owner);
|
||||
graph.interaction = Some((
|
||||
c,
|
||||
CanvasTool::Add(Tool::Exchange, ControlKind::Choice),
|
||||
true,
|
||||
));
|
||||
let mut input = Input::default();
|
||||
let bounds = Rectangle::with_size(graph.size);
|
||||
let point = Point::new(graph.size.width - 10., graph.size.height - 10.);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
point,
|
||||
bounds,
|
||||
);
|
||||
let Some(Message::TopologyCanvas(CanvasEvent::Begin { insertion, .. })) = send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
point,
|
||||
bounds,
|
||||
) else {
|
||||
panic!("canvas retains direct exchange insertion");
|
||||
};
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin(
|
||||
doc.project(),
|
||||
c,
|
||||
Tool::Exchange,
|
||||
ControlKind::Choice,
|
||||
insertion,
|
||||
)
|
||||
.unwrap();
|
||||
state
|
||||
.update(
|
||||
doc.project(),
|
||||
Event::Target(Choice {
|
||||
id: target,
|
||||
label: "Exchange".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
doc.execute(state.prepare_pending(doc.project()).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
doc.project().elements[&f.owner].steps().unwrap().len(),
|
||||
index + 1
|
||||
);
|
||||
assert_eq!(sequence_links(doc.project(), f.owner).len(), index);
|
||||
assert_interactive_owner_view(doc.project(), f.owner);
|
||||
let after = doc.bundle().clone();
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.bundle(), &after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_imported_order_is_projected_without_mutation_and_first_canvas_edit_is_atomic() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut f, next) = empty_exchange_fixture(layer);
|
||||
for exchange in [f.exchange, next] {
|
||||
let node = occurrence(
|
||||
&f.p,
|
||||
f.owner,
|
||||
"Imported exchange",
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::default(),
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap();
|
||||
}
|
||||
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
|
||||
let before = doc.bundle().clone();
|
||||
let order = legacy_order(doc.project(), f.owner);
|
||||
assert_eq!(order.len(), 2);
|
||||
assert_interactive_owner_view(doc.project(), f.owner);
|
||||
let mut graph = Graph::new(doc.project(), f.owner, order[0]);
|
||||
assert!(
|
||||
graph.edges.is_empty(),
|
||||
"projection never invents relation identities"
|
||||
);
|
||||
assert_eq!(graph.ordered_paths.len(), 1);
|
||||
assert!(position(&graph, order[0]).x < position(&graph, order[1]).x);
|
||||
let points = &graph.ordered_paths[0].points;
|
||||
let point = Point::new((points[0].x + points[1].x) / 2., points[0].y);
|
||||
let c = context(f.owner);
|
||||
graph.interaction = Some((c, CanvasTool::Add(Tool::Work, ControlKind::Choice), true));
|
||||
let mut input = Input::default();
|
||||
let bounds = Rectangle::with_size(graph.size);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
point,
|
||||
bounds,
|
||||
);
|
||||
let Some(Message::TopologyCanvas(CanvasEvent::Begin { insertion, .. })) = send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
point,
|
||||
bounds,
|
||||
) else {
|
||||
panic!("ordered arrow supports direct insertion");
|
||||
};
|
||||
assert_eq!(
|
||||
insertion,
|
||||
Insertion {
|
||||
link: None,
|
||||
before: Some(order[1])
|
||||
}
|
||||
);
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin(doc.project(), c, Tool::Work, ControlKind::Choice, insertion)
|
||||
.unwrap();
|
||||
state
|
||||
.update(
|
||||
doc.project(),
|
||||
Event::Target(Choice {
|
||||
id: f.work[1],
|
||||
label: "Respond".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
doc.execute(state.prepare_pending(doc.project()).unwrap())
|
||||
.unwrap();
|
||||
let inserted = created(&f.p, doc.project(), f.owner);
|
||||
assert_eq!(
|
||||
doc.project().elements[&f.owner].steps().unwrap(),
|
||||
&[order[0], inserted, order[1]]
|
||||
);
|
||||
let links = sequence_links(doc.project(), f.owner);
|
||||
assert_eq!(links.len(), 2);
|
||||
for (a, b) in [(order[0], inserted), (inserted, order[1])] {
|
||||
assert!(
|
||||
links
|
||||
.iter()
|
||||
.any(|link| link.source() == a && link.target() == b)
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
Graph::new(doc.project(), f.owner, inserted)
|
||||
.ordered_paths
|
||||
.is_empty()
|
||||
);
|
||||
for id in f.work {
|
||||
assert_eq!(doc.project().elements[&id], f.p.elements[&id]);
|
||||
}
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
assert_eq!(doc.project(), &f.p);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_palette_preserves_fit_and_manual_camera() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
|
||||
@@ -59,21 +59,16 @@ pub(super) fn choice_page(len: usize, requested: usize) -> std::ops::Range<usize
|
||||
let start = requested.min(len.saturating_sub(1) / 24) * 24;
|
||||
start..(start + 24).min(len)
|
||||
}
|
||||
pub(super) fn view<'a>(
|
||||
app: &'a App,
|
||||
owner: Id,
|
||||
mut out: Column<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
let p = app.project();
|
||||
let context = app.topology_context().expect("visible process context");
|
||||
let state = &app.behavior.topology;
|
||||
let pending = state.draft.is_some();
|
||||
let explicit =
|
||||
uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty);
|
||||
let ordered = !legacy_order(p, owner).is_empty();
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
|
||||
let mut controls = Column::new().spacing(6);
|
||||
if !pending && explicit {
|
||||
if !pending {
|
||||
controls = controls
|
||||
.push(
|
||||
row![
|
||||
@@ -156,10 +151,22 @@ pub(super) fn view<'a>(
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{
|
||||
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if ordered{"Ordered exchange path · click a path to insert or empty canvas to continue; the edit preserves its order"}else{match state.tool{
|
||||
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
|
||||
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
|
||||
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
|
||||
if !pending && ordered {
|
||||
controls = controls.push(button("Append exchange…").on_press(Message::TopologyCanvas(
|
||||
CanvasEvent::AppendExchange(context),
|
||||
)));
|
||||
let commands = preserved_order(p, owner, None);
|
||||
if !commands.is_empty() {
|
||||
controls = controls.push(
|
||||
button("Edit path links and guards")
|
||||
.on_press(Message::Command(unique_batch(p, commands))),
|
||||
);
|
||||
}
|
||||
}
|
||||
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
|
||||
let mut navigation = column![].spacing(6);
|
||||
if let Some(Relation {
|
||||
@@ -249,7 +256,7 @@ pub(super) fn view<'a>(
|
||||
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
||||
);
|
||||
}
|
||||
let diagram = if explicit {
|
||||
let diagram = {
|
||||
let mut graph = Graph::expanded_qualified(
|
||||
p,
|
||||
owner,
|
||||
@@ -373,58 +380,6 @@ pub(super) fn view<'a>(
|
||||
state.shelf.is_some(),
|
||||
pending,
|
||||
)
|
||||
} else {
|
||||
out = out
|
||||
.push(
|
||||
button(if state.focused {
|
||||
"Restore explorer and properties"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Focus(context))),
|
||||
)
|
||||
.push(controls)
|
||||
.push(navigation);
|
||||
out = out.push(label(
|
||||
"Ordered exchange path · append the next exchange or convert to explicit links.",
|
||||
));
|
||||
if !pending {
|
||||
out = out.push(
|
||||
button("Append exchange…")
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::AppendExchange(context))),
|
||||
).push(label("Append creates the next exchange contribution and an explicit sequence link from the final contribution in one undo step."));
|
||||
}
|
||||
let commands = preserved_order(p, owner, None);
|
||||
if !commands.is_empty() {
|
||||
out = out.push(
|
||||
button("Convert ordered path to explicit links")
|
||||
.on_press(Message::Command(unique_batch(p, commands))),
|
||||
);
|
||||
} else {
|
||||
out=out.push(label("A single exchange has no path link to convert. Append the next exchange, or involve work, a control or a reusable process."))
|
||||
.push(row![button("Add work…").on_press(event(Event::Start(owner,Tool::Work,selected))),
|
||||
button("Add control…").on_press(event(Event::Start(owner,Tool::Control,selected))),
|
||||
button("Reuse…").on_press(event(Event::Start(owner,Tool::Reuse,selected)))].spacing(6));
|
||||
}
|
||||
let scene = Scene::sequence(p, owner, app.selected);
|
||||
let size = scene.size;
|
||||
out = out.push(
|
||||
scrollable(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(240.),
|
||||
);
|
||||
super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
scrollable(out.padding(18)),
|
||||
)
|
||||
};
|
||||
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||
@@ -451,6 +406,17 @@ pub(super) fn view<'a>(
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed && ordered {
|
||||
form = form.push(label(match (d.before, d.after) {
|
||||
(Some(before), Some(after)) => format!(
|
||||
"Insert between {} → {} · preserve the ordered path",
|
||||
p.named(before),
|
||||
p.named(after)
|
||||
),
|
||||
(Some(before), None) => format!("Continue after {}", p.named(before)),
|
||||
(None, Some(after)) => format!("Insert before {}", p.named(after)),
|
||||
_ => "Start the ordered path".into(),
|
||||
}));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
|
||||
Reference in New Issue
Block a user