Files
ARCADIA-Studio/crates/oa-desktop/src/behavior/topology/view.rs
T

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;