634 lines
24 KiB
Rust
634 lines
24 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, 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 ordered = !legacy_order(p, owner).is_empty();
|
|
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
|
let clean =
|
|
!app.busy && !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
|
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
|
|
let mut navigation = column![].spacing(6);
|
|
if let Some(Relation {
|
|
body:
|
|
RelationBody::ContextualLink {
|
|
owner: caller,
|
|
source,
|
|
target,
|
|
purpose,
|
|
},
|
|
..
|
|
}) = p.relations.get(&app.selected)
|
|
&& *caller == owner
|
|
{
|
|
navigation=navigation.push(text(format!("From: {}",super::contextual::address_label(p,owner,source))).size(12))
|
|
.push(text(format!("To: {}",super::contextual::address_label(p,owner,target))).size(12))
|
|
.push(text(match purpose {ContextualLinkPurpose::Precedence {..}=>"Precedence · hidden contributions attach visually to their folded reference",_=>"Assembly incidence · no execution order is implied"}).size(11));
|
|
}
|
|
if let Some(address) = state.qualified_selection(p, owner) {
|
|
navigation=navigation.push(text(super::contextual::address_label(p,owner,&address.endpoint)).size(12))
|
|
.push(text("Qualified contribution · definition properties affect every use of the source.").size(11))
|
|
.push(button("Open contribution definition").on_press(Message::TopologyCanvas(CanvasEvent::OpenQualified(context,address.endpoint.clone()))));
|
|
if matches!(
|
|
p.elements
|
|
.get(&address.endpoint.occurrence)
|
|
.and_then(|e| e.shared()),
|
|
Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::SequenceReference { .. },
|
|
..
|
|
})
|
|
) {
|
|
navigation = navigation.push(
|
|
button(if state.contextual_expanded.contains(&address.endpoint) {
|
|
"Fold nested reference"
|
|
} else {
|
|
"Expand nested reference"
|
|
})
|
|
.on_press(Message::TopologyCanvas(CanvasEvent::ExpandQualified(
|
|
context,
|
|
address.endpoint.clone(),
|
|
))),
|
|
);
|
|
}
|
|
}
|
|
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))),
|
|
);
|
|
}
|
|
}
|
|
navigation = navigation.push(actions);
|
|
} else if sequence_links(p, owner)
|
|
.iter()
|
|
.any(|r| r.id == app.selected)
|
|
{
|
|
navigation = navigation.push(
|
|
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
|
|
);
|
|
}
|
|
if !state.return_to.is_empty() {
|
|
navigation = navigation.push(
|
|
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
|
);
|
|
}
|
|
navigation = navigation
|
|
.push(
|
|
button("Two paths...").on_press_maybe(clean.then_some(event(Event::Start(
|
|
owner,
|
|
Tool::Branch,
|
|
selected,
|
|
)))),
|
|
)
|
|
.push(
|
|
button("Qualified link / assembly...").on_press_maybe(clean.then_some(event(
|
|
Event::Start(owner, Tool::Contextual, Some(app.selected)),
|
|
))),
|
|
);
|
|
if ordered {
|
|
navigation = navigation.push(button("Append exchange...").on_press_maybe(clean.then_some(
|
|
Message::TopologyCanvas(CanvasEvent::AppendExchange(context)),
|
|
)));
|
|
let commands = preserved_order(p, owner, None);
|
|
if !commands.is_empty() {
|
|
navigation = navigation.push(
|
|
button("Edit path links and guards")
|
|
.on_press_maybe(clean.then_some(Message::Command(unique_batch(p, commands)))),
|
|
);
|
|
}
|
|
}
|
|
let mut graph = Graph::expanded_qualified(
|
|
p,
|
|
owner,
|
|
app.selected,
|
|
&state.expanded,
|
|
&state.contextual_expanded,
|
|
);
|
|
graph.selection = app.multiselect_snapshot();
|
|
graph.selected_preview = state
|
|
.qualified_selection(p, owner)
|
|
.filter(|address| {
|
|
address
|
|
.endpoint
|
|
.references
|
|
.first()
|
|
.copied()
|
|
.unwrap_or(address.endpoint.occurrence)
|
|
== app.selected
|
|
})
|
|
.map(|address| address.endpoint.clone());
|
|
if let Some(crate::diagram_usage::Target::ProcessOccurrence(endpoint)) =
|
|
graph.selection.as_ref().and_then(|s| s.primary())
|
|
{
|
|
graph.selected_preview = (!endpoint.references.is_empty()).then(|| endpoint.clone());
|
|
}
|
|
graph.selected_preview_link = app
|
|
.diagram_focus(crate::diagram_usage::Surface::Process(owner))
|
|
.and_then(|usage| match &usage.target {
|
|
crate::diagram_usage::Target::ProcessPreviewLink {
|
|
relation,
|
|
source,
|
|
target,
|
|
} => Some(PreviewLink {
|
|
relation: *relation,
|
|
source: source.clone(),
|
|
target: target.clone(),
|
|
}),
|
|
_ => None,
|
|
});
|
|
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
|
|
graph.camera_pan = state.pan;
|
|
graph.pending_insertion = state
|
|
.draft
|
|
.as_ref()
|
|
.filter(|d| d.owner == owner && d.fixed)
|
|
.map(|d| d.insertion);
|
|
graph.interaction = Some((context, state.tool, clean));
|
|
let scale = graph.scale;
|
|
let scope = super::super::canvas_workspace::Scope {
|
|
scenario_view: None,
|
|
document: context.document,
|
|
owner,
|
|
revision: context.revision,
|
|
epoch: app.camera_epoch,
|
|
};
|
|
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
|
|
let camera = move |e| Message::TopologyCanvas(CanvasEvent::Camera(context, scope.epoch, e));
|
|
let canvas = container(
|
|
iced::widget::sensor(
|
|
super::super::canvas_workspace::Viewport::new(
|
|
canvas::Canvas::new(graph).width(Fill).height(Fill),
|
|
scope,
|
|
scale,
|
|
keyboard,
|
|
camera,
|
|
)
|
|
.pan_background(state.tool == CanvasTool::Select),
|
|
)
|
|
.key((context.document, owner))
|
|
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
|
|
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
|
|
)
|
|
.id("process-viewport")
|
|
.width(Fill)
|
|
.height(Fill)
|
|
.style(style::card)
|
|
.into();
|
|
let header = column![
|
|
out.width(Fill),
|
|
super::super::canvas_workspace::controls(
|
|
scope,
|
|
keyboard,
|
|
scale,
|
|
event(Event::Shelf(Shelf::Selection)),
|
|
move |zoom| Message::TopologyCanvas(CanvasEvent::Zoom(context, zoom)),
|
|
camera
|
|
),
|
|
selection_controls(app)
|
|
]
|
|
.spacing(4);
|
|
let entries = [
|
|
("S", "Select contributions or paths", CanvasTool::Select),
|
|
(
|
|
"W",
|
|
"Work: click a path or empty space",
|
|
CanvasTool::Add(Tool::Work, ControlKind::Choice),
|
|
),
|
|
(
|
|
"E",
|
|
"Exchange: click a path or empty space",
|
|
CanvasTool::Add(Tool::Exchange, ControlKind::Choice),
|
|
),
|
|
(
|
|
"ref",
|
|
"Reuse process / chain",
|
|
CanvasTool::Add(Tool::Reuse, ControlKind::Choice),
|
|
),
|
|
(
|
|
"OR",
|
|
"Choice",
|
|
CanvasTool::Add(Tool::Control, ControlKind::Choice),
|
|
),
|
|
(
|
|
"M",
|
|
"Merge",
|
|
CanvasTool::Add(Tool::Control, ControlKind::Merge),
|
|
),
|
|
(
|
|
"AND",
|
|
"Fork",
|
|
CanvasTool::Add(Tool::Control, ControlKind::Fork),
|
|
),
|
|
(
|
|
"J",
|
|
"Join",
|
|
CanvasTool::Add(Tool::Control, ControlKind::Join),
|
|
),
|
|
(
|
|
"IT",
|
|
"Loop",
|
|
CanvasTool::Add(Tool::Control, ControlKind::Loop),
|
|
),
|
|
(
|
|
"->",
|
|
"Sequence link: click or drag between contributions",
|
|
CanvasTool::Connect,
|
|
),
|
|
];
|
|
let palette = super::super::canvas_workspace::palette(
|
|
scope,
|
|
keyboard,
|
|
camera,
|
|
entries.into_iter().map(|(glyph, label, choice)| {
|
|
(
|
|
glyph,
|
|
label,
|
|
choice == state.tool,
|
|
clean.then_some(Message::TopologyCanvas(CanvasEvent::Choose(
|
|
context, choice,
|
|
))),
|
|
)
|
|
}),
|
|
);
|
|
let details = scrollable(navigation.push(text("Drag selected local cards to the upper or lower half of a card to insert before or after it in the same column. The group keeps source order; paths stay unchanged. Qualified previews remain read-only.").size(11)))
|
|
.id("process-details").width(Fill).height(Fill).into();
|
|
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) {
|
|
if let Some(child) = d.source.view(p) {
|
|
form_panel = column![
|
|
text(format!(
|
|
"Consumer name: {}",
|
|
crate::views::short(&d.name, 60)
|
|
))
|
|
.size(12),
|
|
child.map(|e| event(Event::Source(e)))
|
|
]
|
|
.spacing(6)
|
|
.height(Fill)
|
|
.into();
|
|
} else {
|
|
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
|
if let Some(contextual) = &d.contextual {
|
|
form = form
|
|
.push(
|
|
text_input("Link name (retain current when blank)", &d.name)
|
|
.on_input(|v| event(Event::Name(v)))
|
|
.padding(8)
|
|
.size(12),
|
|
)
|
|
.push(super::contextual::view(p, owner, contextual));
|
|
} else {
|
|
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 && 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
|
|
{
|
|
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 d.tool == Tool::Work {
|
|
if let Some(title) = d.source.label(d.target) {
|
|
form = form.push(label(title)).push(label("Create commits the new work and contribution together at the chosen path position."));
|
|
}
|
|
form = form.push(button("Create new source…").on_press(event(Event::Source(
|
|
super::super::source_draft::Event::Open(d.invocation),
|
|
))));
|
|
}
|
|
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();
|
|
if d.tool == Tool::Branch {
|
|
form = form.push(label("First path work"));
|
|
}
|
|
form = form.push(picker(
|
|
choices,
|
|
d,
|
|
0,
|
|
d.target,
|
|
if d.tool == Tool::Branch {
|
|
"Choose first path work…"
|
|
} else {
|
|
"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(label("Second path work")).push(picker(
|
|
targets(p, owner, Tool::Work),
|
|
d,
|
|
1,
|
|
d.alternate,
|
|
"Second path work…",
|
|
Event::Alternate,
|
|
));
|
|
if d.control != ControlKind::Fork {
|
|
form = form
|
|
.push(label("First path guard"))
|
|
.push(
|
|
text_input("First path guard", &d.guard)
|
|
.on_input(|v| event(Event::Guard(v)))
|
|
.padding(8),
|
|
)
|
|
.push(label("Second path guard"))
|
|
.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(label("Preceding contribution"))
|
|
.push(picker(
|
|
contributions(p, owner),
|
|
d,
|
|
2,
|
|
d.before,
|
|
"After contribution…",
|
|
Event::Before,
|
|
))
|
|
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
|
.push(label("Following contribution"))
|
|
.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(if d.contextual.is_some() {
|
|
"Apply link"
|
|
} else {
|
|
"Create"
|
|
})
|
|
.on_press_maybe(valid.then(|| event(Event::Submit))),
|
|
button("Cancel").on_press(event(Event::Cancel))
|
|
]
|
|
.spacing(7);
|
|
form_panel = editor_panel(d.invocation, actions.into(), form.into());
|
|
}
|
|
}
|
|
let editor = if pending {
|
|
Some(form_panel)
|
|
} else {
|
|
state.shelf.map(|_| details)
|
|
};
|
|
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
|
}
|
|
|
|
fn selection_controls(app: &App) -> Element<'_, Message> {
|
|
use crate::app::multiselect::Event as SelectionEvent;
|
|
let snapshot = app.multiselect_snapshot();
|
|
let enabled = !app.busy
|
|
&& !app.behavior.has_pending()
|
|
&& !app.draft_dirty()
|
|
&& !app.source_dirty()
|
|
&& app.behavior.topology.tool == CanvasTool::Select;
|
|
let count = snapshot.as_ref().map_or(0, |s| s.targets().len());
|
|
let mut controls = row![label(format!("{count} selected"))]
|
|
.spacing(7)
|
|
.align_y(iced::Center)
|
|
.height(32);
|
|
if let Some(snapshot) = snapshot.filter(|s| !s.targets().is_empty()) {
|
|
controls = controls.push(button("Clear selection").on_press_maybe(
|
|
enabled.then(|| Message::DiagramSelection(SelectionEvent::Clear(snapshot.context()))),
|
|
));
|
|
controls = controls.push(
|
|
label(format!(
|
|
"Properties: {}",
|
|
short(app.project().named(app.selected), 24)
|
|
))
|
|
.wrapping(iced::widget::text::Wrapping::None),
|
|
);
|
|
}
|
|
// These two rows exist even with an empty set so selection never moves the
|
|
// canvas viewport or changes the camera's interpretation of the pointer.
|
|
column![
|
|
controls,
|
|
label("Shift+click toggles · drag selected cards to reorder · previews are read-only")
|
|
.wrapping(iced::widget::text::Wrapping::None)
|
|
]
|
|
.height(56)
|
|
.into()
|
|
}
|
|
|
|
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
|
|
session: Id,
|
|
actions: iced::Element<'a, Message, iced::Theme, R>,
|
|
fields: iced::Element<'a, Message, iced::Theme, R>,
|
|
) -> iced::Element<'a, Message, iced::Theme, R> {
|
|
container(
|
|
container(
|
|
column![
|
|
container(actions).id("process-actions"),
|
|
super::super::form_session::reset_on_change(
|
|
session,
|
|
scrollable(fields).id("process-form").height(Fill)
|
|
)
|
|
]
|
|
.spacing(8),
|
|
)
|
|
.width(Fill)
|
|
.height(Fill)
|
|
.padding(12)
|
|
.style(style::card),
|
|
)
|
|
.width(Fill)
|
|
.height(Fill)
|
|
.padding(iced::Padding {
|
|
top: 0.,
|
|
right: 18.,
|
|
bottom: 18.,
|
|
left: 18.,
|
|
})
|
|
.into()
|
|
}
|
|
|
|
#[cfg(all(test, debug_assertions))]
|
|
#[path = "layout_tests.rs"]
|
|
mod layout_tests;
|