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 clean = !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.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()); 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 { 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 ) ] .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("Layout is derived. The reorder handle changes display order within its column; it does not author a position.").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::>(), 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 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;