479 lines
18 KiB
Rust
479 lines
18 KiB
Rust
use super::*;
|
||
use iced::widget::column;
|
||
pub(super) fn picker<'a>(
|
||
mut choices: Vec<Choice>,
|
||
d: &'a Draft,
|
||
index: usize,
|
||
value: Option<Id>,
|
||
placeholder: &'a str,
|
||
change: fn(Choice) -> Event,
|
||
) -> Element<'a, Message> {
|
||
let selected = value
|
||
.and_then(|id| choices.iter().find(|c| c.id == id))
|
||
.cloned();
|
||
let browser = &d.browsers[index];
|
||
let query = browser.search.trim().to_lowercase();
|
||
choices.retain(|c| c.label.to_lowercase().contains(&query));
|
||
let range = choice_page(choices.len(), browser.page);
|
||
let page = range.start / 24;
|
||
let mut out = column![
|
||
text_input("Search compatible choices…", &browser.search)
|
||
.on_input(move |v| event(Event::Search(index, v)))
|
||
.padding(7)
|
||
.size(12)
|
||
]
|
||
.spacing(5);
|
||
if let Some(selected) = selected
|
||
.as_ref()
|
||
.filter(|c| !choices[range.clone()].iter().any(|v| v.id == c.id))
|
||
{
|
||
out = out.push(label(format!("Selected: {}", selected.label)));
|
||
}
|
||
out = out.push(
|
||
row![
|
||
button("Previous")
|
||
.on_press_maybe((page > 0).then(|| event(Event::Page(index, page - 1)))),
|
||
text(format!(
|
||
"{} choices · {}/{}",
|
||
choices.len(),
|
||
page + 1,
|
||
choices.len().div_ceil(24).max(1)
|
||
))
|
||
.size(11),
|
||
button("Next").on_press_maybe(
|
||
(range.end < choices.len()).then(|| event(Event::Page(index, page + 1)))
|
||
)
|
||
]
|
||
.spacing(6),
|
||
);
|
||
out.push(
|
||
pick_list(choices[range].to_vec(), selected, move |v| event(change(v)))
|
||
.placeholder(placeholder)
|
||
.padding(8)
|
||
.text_size(12)
|
||
.width(Fill),
|
||
)
|
||
.into()
|
||
}
|
||
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> {
|
||
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 selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
|
||
if !pending && explicit {
|
||
out = out
|
||
.push(
|
||
row![
|
||
button("Select / reorder")
|
||
.style(if state.tool == CanvasTool::Select {
|
||
button::primary
|
||
} else {
|
||
style::nav
|
||
})
|
||
.on_press(choose(CanvasTool::Select)),
|
||
button("Work")
|
||
.style(
|
||
if state.tool == CanvasTool::Add(Tool::Work, ControlKind::Choice) {
|
||
button::primary
|
||
} else {
|
||
style::nav
|
||
}
|
||
)
|
||
.on_press(choose(CanvasTool::Add(Tool::Work, ControlKind::Choice))),
|
||
button("Exchange")
|
||
.style(
|
||
if state.tool == CanvasTool::Add(Tool::Exchange, ControlKind::Choice) {
|
||
button::primary
|
||
} else {
|
||
style::nav
|
||
}
|
||
)
|
||
.on_press(choose(CanvasTool::Add(Tool::Exchange, ControlKind::Choice))),
|
||
button("Reuse")
|
||
.style(
|
||
if state.tool == CanvasTool::Add(Tool::Reuse, ControlKind::Choice) {
|
||
button::primary
|
||
} else {
|
||
style::nav
|
||
}
|
||
)
|
||
.on_press(choose(CanvasTool::Add(Tool::Reuse, ControlKind::Choice)))
|
||
]
|
||
.spacing(6),
|
||
)
|
||
.push(
|
||
row![
|
||
pick_list(
|
||
vec![
|
||
ControlKind::Choice,
|
||
ControlKind::Merge,
|
||
ControlKind::Fork,
|
||
ControlKind::Join,
|
||
ControlKind::Loop
|
||
]
|
||
.into_iter()
|
||
.map(ControlChoice)
|
||
.collect::<Vec<_>>(),
|
||
if let CanvasTool::Add(Tool::Control, kind) = state.tool {
|
||
Some(ControlChoice(kind))
|
||
} else {
|
||
None
|
||
},
|
||
move |c| Message::TopologyCanvas(CanvasEvent::Choose(
|
||
context,
|
||
CanvasTool::Add(Tool::Control, c.0)
|
||
))
|
||
)
|
||
.placeholder("Draw control…")
|
||
.text_size(12)
|
||
.padding(8),
|
||
button("Sequence link")
|
||
.style(if state.tool == CanvasTool::Connect {
|
||
button::primary
|
||
} else {
|
||
style::nav
|
||
})
|
||
.on_press(choose(CanvasTool::Connect)),
|
||
button("Two paths…").on_press(event(Event::Start(
|
||
owner,
|
||
Tool::Branch,
|
||
selected
|
||
)))
|
||
]
|
||
.spacing(6),
|
||
);
|
||
}
|
||
out=out.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}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 explicit {
|
||
let mut graph = Graph::expanded(p, owner, app.selected, &state.expanded);
|
||
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
||
graph.interaction = Some((
|
||
context,
|
||
state.tool,
|
||
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
|
||
));
|
||
graph.scroll_offset = state.scroll_offset();
|
||
let scale = graph.scale;
|
||
out = out.push(
|
||
row![
|
||
button("Fit overview")
|
||
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
|
||
button("−").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||
context,
|
||
Some(scale / 1.25)
|
||
))),
|
||
label(format!("{:.0}%", scale * 100.)),
|
||
button("+").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||
context,
|
||
Some(scale * 1.25)
|
||
))),
|
||
button("100%").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||
context,
|
||
Some(1.)
|
||
)))
|
||
]
|
||
.spacing(6),
|
||
);
|
||
if graph.nodes.is_empty() {
|
||
out = out.push(label(
|
||
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
|
||
));
|
||
}
|
||
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
|
||
let canvas = scrollable(
|
||
canvas::Canvas::new(graph)
|
||
.width(size.width)
|
||
.height(size.height),
|
||
)
|
||
.id("process-canvas")
|
||
.direction(scrollable::Direction::Both {
|
||
vertical: scrollable::Scrollbar::default(),
|
||
horizontal: scrollable::Scrollbar::default(),
|
||
})
|
||
.height(if pending {
|
||
(app.window_size.height - 510.).clamp(160., 240.)
|
||
} else {
|
||
(app.window_size.height - 420.).clamp(190., 420.)
|
||
});
|
||
out = out.push(
|
||
iced::widget::sensor(
|
||
container(super::super::scenario_scroll::observe_topology(
|
||
context,
|
||
scenario::ScrollArea::Canvas,
|
||
canvas,
|
||
))
|
||
.padding(8)
|
||
.width(Fill)
|
||
.style(style::card),
|
||
)
|
||
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
|
||
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
|
||
);
|
||
} else {
|
||
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.),
|
||
);
|
||
}
|
||
if let Some(id) = selected {
|
||
let mut actions =
|
||
row![button("Contribution properties").on_press(Message::EditProperties(id))]
|
||
.spacing(6);
|
||
if let Some(target) = direct::referenced(p, owner, id) {
|
||
actions = actions.push(
|
||
button(text(format!("Open source: {}", short(p.named(target), 28))).size(12))
|
||
.on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))),
|
||
);
|
||
if matches!(
|
||
p.elements[&id].shared(),
|
||
Some(SharedBody::Occurrence {
|
||
body: OccurrenceBody::SequenceReference { .. },
|
||
..
|
||
})
|
||
) {
|
||
actions = actions.push(
|
||
button(if state.expanded.contains(&id) {
|
||
"Collapse"
|
||
} else {
|
||
"Expand"
|
||
})
|
||
.on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))),
|
||
);
|
||
}
|
||
}
|
||
out = out.push(actions);
|
||
} else if sequence_links(p, owner)
|
||
.iter()
|
||
.any(|r| r.id == app.selected)
|
||
{
|
||
out = out.push(
|
||
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
|
||
);
|
||
}
|
||
if !state.return_to.is_empty() {
|
||
out = out.push(
|
||
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
||
);
|
||
}
|
||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||
guard.as_str()
|
||
} else {
|
||
""
|
||
};
|
||
form = form.push(label(format!(
|
||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||
p.named(link.source()),
|
||
p.named(link.target()),
|
||
guard
|
||
)));
|
||
} else if d.fixed
|
||
&& d.tool == Tool::Exchange
|
||
&& let Some(before) = d.before
|
||
{
|
||
form = form.push(label(format!(
|
||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||
p.named(before)
|
||
)));
|
||
} else if d.fixed && d.tool != Tool::Connect {
|
||
form = form.push(label(
|
||
d.insertion
|
||
.before
|
||
.map_or("Append an unconnected contribution".into(), |id| {
|
||
format!("Unconnected contribution before {}", p.named(id))
|
||
}),
|
||
));
|
||
}
|
||
if !legacy_order(p, owner).is_empty() {
|
||
form = form.push(label(
|
||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||
));
|
||
}
|
||
if matches!(
|
||
d.tool,
|
||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||
) {
|
||
let choices = targets(p, owner, d.tool)
|
||
.into_iter()
|
||
.filter(|c| compatible_source(p, d, c.id))
|
||
.collect();
|
||
form = form.push(picker(
|
||
choices,
|
||
d,
|
||
0,
|
||
d.target,
|
||
"Choose existing source…",
|
||
Event::Target,
|
||
));
|
||
}
|
||
if d.tool != Tool::Connect {
|
||
form = form.push(
|
||
text_input("Contribution name (defaults to source)", &d.name)
|
||
.on_input(|v| event(Event::Name(v)))
|
||
.padding(8)
|
||
.size(12),
|
||
);
|
||
}
|
||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||
let kinds = if d.tool == Tool::Branch {
|
||
vec![ControlKind::Choice, ControlKind::Fork]
|
||
} else {
|
||
vec![
|
||
ControlKind::Choice,
|
||
ControlKind::Merge,
|
||
ControlKind::Fork,
|
||
ControlKind::Join,
|
||
ControlKind::Loop,
|
||
]
|
||
};
|
||
form = form.push(
|
||
pick_list(
|
||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||
Some(ControlChoice(d.control)),
|
||
|v| event(Event::Control(v)),
|
||
)
|
||
.padding(8)
|
||
.text_size(12),
|
||
);
|
||
}
|
||
if d.tool == Tool::Branch {
|
||
form = form.push(picker(
|
||
targets(p, owner, Tool::Work),
|
||
d,
|
||
1,
|
||
d.alternate,
|
||
"Second path work…",
|
||
Event::Alternate,
|
||
));
|
||
if d.control != ControlKind::Fork {
|
||
form = form
|
||
.push(
|
||
text_input("First path guard", &d.guard)
|
||
.on_input(|v| event(Event::Guard(v)))
|
||
.padding(8),
|
||
)
|
||
.push(
|
||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||
.padding(8),
|
||
);
|
||
}
|
||
}
|
||
if d.fixed {
|
||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||
}
|
||
} else {
|
||
form = form
|
||
.push(picker(
|
||
contributions(p, owner),
|
||
d,
|
||
2,
|
||
d.before,
|
||
"After contribution…",
|
||
Event::Before,
|
||
))
|
||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||
.push(picker(
|
||
contributions(p, owner),
|
||
d,
|
||
3,
|
||
d.after,
|
||
"Before contribution…",
|
||
Event::After,
|
||
))
|
||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||
}
|
||
if d.tool != Tool::Branch
|
||
&& d.insertion.link.is_none()
|
||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||
{
|
||
form = form.push(
|
||
text_input(
|
||
"Path condition (empty remains a Choice / Loop warning)",
|
||
&d.guard,
|
||
)
|
||
.on_input(|v| event(Event::Guard(v)))
|
||
.padding(8)
|
||
.size(12),
|
||
);
|
||
}
|
||
let valid = match d.command(p) {
|
||
Ok(_) => true,
|
||
Err(problem) => {
|
||
form = form.push(label(problem));
|
||
false
|
||
}
|
||
};
|
||
let actions = row![
|
||
button("Create").on_press_maybe(valid.then(|| event(Event::Submit))),
|
||
button("Cancel").on_press(event(Event::Cancel))
|
||
]
|
||
.spacing(7);
|
||
out = out.push(
|
||
container(
|
||
column![
|
||
actions,
|
||
scrollable(form).height((app.window_size.height - 520.).clamp(150., 260.))
|
||
]
|
||
.spacing(8),
|
||
)
|
||
.padding(12)
|
||
.style(style::card),
|
||
);
|
||
}
|
||
super::super::scenario_scroll::observe_topology(
|
||
context,
|
||
scenario::ScrollArea::Page,
|
||
scrollable(out.padding(18)),
|
||
)
|
||
}
|