Files
ARCADIA-Studio/crates/oa-desktop/src/behavior/topology/view.rs
T
Codex 01bb52a76d
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Improve behavior authoring and typed trace history (untested checkpoint)
2026-09-10 11:07:07 -07:00

665 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
let mut controls = Column::new().spacing(6);
if !pending {
controls = controls
.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),
);
}
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 {
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 !pending {
controls = controls.push(button("Qualified link / assembly…").on_press(event(
Event::Start(owner, Tool::Contextual, Some(app.selected)),
)));
}
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))),
);
}
let diagram = {
let mut graph = Graph::expanded_qualified(
p,
owner,
app.selected,
&state.expanded,
&state.contextual_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(),
));
// Explicit graphs no longer have a scrolling page ancestor. A retained
// offset from an ordered view must not distort their pointer coordinates.
graph.scroll_offset = state.canvas_offset();
let scale = graph.scale;
let mut header = column![
row![
out.width(Fill),
button(
text(if state.focused {
"Restore panels"
} else {
"Focus canvas"
})
.size(12)
)
.on_press(Message::TopologyCanvas(CanvasEvent::Focus(context)))
.padding(6)
]
.spacing(6)
.align_y(iced::Center),
row![
button(text("Tools").size(12))
.padding(6)
.style(if state.shelf == Some(Shelf::Tools) {
button::primary
} else {
style::nav
})
.on_press(event(Event::Shelf(Shelf::Tools))),
button(text("Selection details").size(12))
.padding(6)
.style(if state.shelf == Some(Shelf::Selection) {
button::primary
} else {
style::nav
})
.on_press(event(Event::Shelf(Shelf::Selection))),
button(text("Fit overview").size(12))
.padding(6)
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
button(text("−").size(12))
.padding(6)
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(scale / 1.25)
))),
label(format!("{:.0}%", scale * 100.)),
button(text("+").size(12))
.padding(6)
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(scale * 1.25)
))),
button(text("100%").size(12))
.padding(6)
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(1.)
)))
]
.spacing(6)
.wrap(),
]
.spacing(6);
if state.fit_limited(graph.size, (app.window_size.width - 600.).max(200.)) {
header = header
.push(text("Minimum zoom reached · scroll to see the remaining graph.").size(11));
}
if graph.nodes.is_empty() {
controls = controls.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(),
})
.width(Fill)
.height(Fill);
let canvas = container(
iced::widget::sensor(super::super::scenario_scroll::observe_topology(
context,
scenario::ScrollArea::Canvas,
canvas,
))
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
)
.padding(8)
.width(Fill)
.height(Fill)
.style(style::card);
let details: Element<'_, Message> = match state.shelf {
Some(Shelf::Tools) => controls.into(),
_ => navigation.push(text("Full qualified identities and definition actions appear here for the selected contribution or link.").size(11)).into(),
};
explicit_pane(
header.into(),
details,
canvas.into(),
state.shelf.is_some(),
pending,
)
};
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) {
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 !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(actions.into(), form.into());
}
panes(diagram, form_panel, pending)
}
/// A permanent three-slot tree: variable controls never remount the canvas.
/// The shelf scrolls independently; the canvas receives actual remaining height.
fn explicit_pane<'a, R: iced::advanced::text::Renderer + 'a>(
header: iced::Element<'a, Message, iced::Theme, R>,
details: iced::Element<'a, Message, iced::Theme, R>,
canvas: iced::Element<'a, Message, iced::Theme, R>,
show_details: bool,
pending: bool,
) -> iced::Element<'a, Message, iced::Theme, R> {
// Column::push drops Fixed(0) children. from_vec retains the closed shelf
// slot so its disclosure cannot move/remount the following canvas tree.
Column::from_vec(vec![
container(header).width(Fill).into(),
scrollable(details)
.id("process-details")
.width(Fill)
.height(if show_details {
if pending { 64. } else { 112. }
} else {
0.
})
.into(),
container(canvas).width(Fill).height(Fill).into(),
])
.spacing(6)
.padding(8)
.width(Fill)
.height(Fill)
.into()
}
/// Keep the diagram mounted while an independent editor uses the remaining
/// workspace height. The editor must never be clipped by the page scrollable.
fn panes<'a, R: iced::advanced::Renderer + 'a>(
diagram: iced::Element<'a, Message, iced::Theme, R>,
form: iced::Element<'a, Message, iced::Theme, R>,
pending: bool,
) -> iced::Element<'a, Message, iced::Theme, R> {
column![
container(diagram).width(Fill).height(if pending {
iced::Length::FillPortion(1)
} else {
Fill
}),
container(form).width(Fill).height(if pending {
iced::Length::FillPortion(1)
} else {
iced::Length::Shrink
}),
]
.width(Fill)
.height(Fill)
.into()
}
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
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![actions, 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;