use super::*; use iced::widget::column; pub(super) fn picker<'a>( mut choices: Vec, d: &'a Draft, index: usize, value: Option, 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 { 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::>(), 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::>(), 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;