//! Local behavior creation. A draft is not a model object: only its validated //! batch may cross Document::execute, and acknowledgement follows success. use iced::{ Element as UiElement, Fill, widget::{button, checkbox, column, container, row, scrollable, text, text_input}, }; use oa_core::*; use serde::{Deserialize, Serialize}; const PAGE_SIZE: usize = 6; pub const NAME_INPUT: &str = "behavior-creation-name"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Family { Sequence, Scenario, Machine, } impl Family { fn kind(self, layer: Layer) -> Kind { match (self, layer) { (Self::Sequence, Layer::Oa) => Kind::Process, (Self::Sequence, Layer::Sa) => Kind::FunctionalChain, (Self::Scenario, _) => Kind::Scenario, (Self::Machine, _) => Kind::StateMachine, } } fn title(self, layer: Layer) -> &'static str { match (self, layer) { (Self::Sequence, Layer::Oa) => "Operational process", (Self::Sequence, Layer::Sa) => "Functional chain", (Self::Scenario, _) => "Scenario", (Self::Machine, _) => "State machine", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Picker { Owner, Context, } impl Picker { fn index(self) -> usize { match self { Self::Owner => 0, Self::Context => 1, } } } #[derive(Debug, Clone)] pub enum Event { Source(super::source_draft::Event), Open(Layer), Choose(Family), Name(String), Owner(Id), Viewpoint(ScenarioViewpoint), Context(Option), ContextOptions(bool), FirstView(bool), Search(Picker, String), Page(Picker, usize), Cancel, Submit, } #[derive(Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Browser { query: String, page: usize, } #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Draft { #[serde(default)] source: super::source_draft::State, #[serde(default = "Id::new_v4")] invocation: Id, version: u32, document: Id, layer: Layer, family: Option, name: String, owner: Option, viewpoint: Option, context: Option, #[serde(default)] context_open: bool, first_view: bool, element: Id, diagram: Id, placement: Id, browsers: [Browser; 2], } #[derive(Default)] pub struct State { draft: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Created { pub behavior: Id, pub layer: Layer, pub diagram: Option, } impl State { pub fn source_active(&self) -> bool { self.draft.as_ref().is_some_and(|d| d.source.engaged()) } pub fn source_open(&self) -> bool { self.draft.as_ref().is_some_and(|d| d.source.is_open()) } pub fn source_back(&mut self) -> bool { self.draft.as_mut().is_some_and(|d| d.source.back()) } pub fn has_pending(&self) -> bool { self.draft.is_some() } pub fn pending_layer(&self) -> Option { self.draft.as_ref().map(|d| d.layer) } /// Capture this before successful acknowledgement; never select these IDs /// on mere planning or a rejected source/model transaction. pub fn created(&self) -> Option { let d = self.draft.as_ref()?; d.family.map(|family| Created { behavior: d.element, layer: d.layer, diagram: (family == Family::Machine && d.first_view).then_some(d.diagram), }) } /// Match before execution, then apply this target only after success. /// Identity alone is insufficient: changed bodies or trailing operations /// must not inherit the prepared creation's selection/opening contract. pub fn created_by(&self, p: &Project, command: &Command) -> Option { let target = self.created()?; (self.prepare_pending(p).ok().as_ref() == Some(command)).then_some(target) } pub fn recovery_snapshot(&self) -> Result, String> { self.draft .as_ref() .map(serde_json::to_value) .transpose() .map_err(|e| e.to_string()) } pub fn from_recovery(value: serde_json::Value) -> Result { let draft: Draft = serde_json::from_value(value).map_err(|e| e.to_string())?; if draft.version != 1 { return Err("Unsupported behavior creation draft version.".into()); } // Raw values and stale references remain editable; planning validates // against the current document rather than silently fixing the draft. Ok(Self { draft: Some(draft) }) } pub fn acknowledge_commit(&mut self) { self.draft = None; } pub fn prepare_pending(&self, p: &Project) -> Result { self.draft .as_ref() .ok_or("Choose New to start a behavior.")? .command(p) } pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { if let Event::Source(event) = event { if self .draft .as_mut() .ok_or("Start the consumer form first.")? .source .dismiss(&event)? { return Ok(None); } let d = self .draft .as_ref() .ok_or("Start the consumer form first.")?; if d.family != Some(Family::Machine) { return Err("This reference does not support staged source creation.".into()); } if d.document != p.id { return Err("This behavior draft belongs to another document.".into()); } let mut next = d.clone(); if let Some(id) = next.source.update( p, super::source_draft::Scope::Machine { layer: d.layer }, d.invocation, event, )? { next.owner = Some(id); let preview = next .source .preview(p, next.owner)? .ok_or("New owner preview is missing.")?; if !eligible(&preview, next.layer, Picker::Owner, id) { return Err("This participant cannot own the machine.".into()); } } self.draft = Some(next); return Ok(None); } if self.source_open() && !matches!(&event, Event::Cancel) { return Err("Use the new source or go Back before editing the parent form.".into()); } match event { Event::Cancel => { self.draft = None; return Ok(None); } Event::Open(layer) => { if self.has_pending() { return Err("Create or cancel the pending behavior first.".into()); } self.draft = Some(Draft { source: Default::default(), invocation: Id::new_v4(), version: 1, document: p.id, layer, family: None, name: String::new(), owner: None, viewpoint: None, context: None, context_open: false, first_view: false, element: Id::new_v4(), diagram: Id::new_v4(), placement: Id::new_v4(), browsers: Default::default(), }); return Ok(None); } Event::Submit => return self.prepare_pending(p).map(Some), _ => {} } let d = self .draft .as_mut() .ok_or("Choose New to start a behavior.")?; if d.document != p.id { return Err("This draft belongs to another document. Cancel it to start here.".into()); } match event { Event::Choose(family) => { if d.family.is_some_and(|old| old != family) { return Err( "Cancel this draft before choosing a different behavior family.".into(), ); } d.family = Some(family); } Event::Name(name) => d.name = name, Event::Owner(id) if d.family == Some(Family::Machine) => { if !eligible(p, d.layer, Picker::Owner, id) { return Err("Choose an existing participant in this layer.".into()); } d.owner = Some(id); } Event::Viewpoint(viewpoint) if d.family == Some(Family::Scenario) => { if !viewpoints(d.layer).contains(&viewpoint) { return Err("Choose a scenario viewpoint from this layer.".into()); } d.viewpoint = Some(viewpoint); } Event::Context(id) if d.family == Some(Family::Scenario) => { if id.is_some_and(|id| !eligible(p, d.layer, Picker::Context, id)) { return Err("Choose an existing same-layer scenario context.".into()); } d.context = id; } Event::ContextOptions(open) if d.family == Some(Family::Scenario) => { d.context_open = open; } Event::FirstView(value) if d.family == Some(Family::Machine) => d.first_view = value, Event::Search(picker, query) => d.browsers[picker.index()] = Browser { query, page: 0 }, Event::Page(picker, page) => d.browsers[picker.index()].page = page, Event::Cancel | Event::Open(_) | Event::Submit => unreachable!(), _ => return Err("That field does not apply to this behavior family.".into()), } Ok(None) } } fn viewpoints(layer: Layer) -> [ScenarioViewpoint; 2] { match layer { Layer::Oa => [ ScenarioViewpoint::OperationalActivity, ScenarioViewpoint::ParticipantInteraction, ], Layer::Sa => [ ScenarioViewpoint::Functional, ScenarioViewpoint::BoundaryInterface, ], } } fn viewpoint_title(viewpoint: ScenarioViewpoint) -> &'static str { match viewpoint { ScenarioViewpoint::OperationalActivity => "Activities · operational exchanges", ScenarioViewpoint::ParticipantInteraction => "Participants · interactions", ScenarioViewpoint::Functional => "Functions · functional exchanges", ScenarioViewpoint::BoundaryInterface => "System boundary · interface exchanges", ScenarioViewpoint::MixedLegacy => "Legacy mixed viewpoint", } } fn eligible(p: &Project, layer: Layer, picker: Picker, id: Id) -> bool { p.elements.get(&id).is_some_and(|e| { e.layer() == layer && match picker { Picker::Owner => validation::parent_permits(e.kind(), Kind::StateMachine), Picker::Context => { e.kind().is_sequence() || e.kind().is_work() || e.kind().is_performer() || matches!( e.kind(), Kind::Capability | Kind::SystemCapability | Kind::OperationalMission | Kind::Mission ) } } }) } fn choices<'a>(p: &'a Project, layer: Layer, picker: Picker, query: &str) -> Vec<&'a Element> { let query = query.trim().to_lowercase(); let mut matches: Vec<_> = p .elements .values() .filter(|e| eligible(p, layer, picker, e.id)) .filter(|e| query.is_empty() || reference_label(p, e.id).to_lowercase().contains(&query)) .collect(); matches.sort_by(|a, b| { a.name .to_lowercase() .cmp(&b.name.to_lowercase()) .then(a.id.cmp(&b.id)) }); matches } fn reference_context(p: &Project, e: &Element) -> String { let mut names = Vec::new(); let mut parent = e.parent; let mut seen = std::collections::BTreeSet::new(); while let Some(id) = parent { if !seen.insert(id) { break; } let Some(ancestor) = p.elements.get(&id) else { break; }; names.push(ancestor.name.as_str()); parent = ancestor.parent; } names.reverse(); let location = if names.is_empty() { format!("{} root", e.layer()) } else { format!("{} / {}", e.layer(), names.join(" / ")) }; format!("{} · {} · {location}", e.kind(), e.alias) } pub(super) fn reference_label(p: &Project, id: Id) -> String { p.elements .get(&id) .map(|e| format!("{} · {}", e.name, reference_context(p, e))) .unwrap_or_else(|| "Missing element — choose a replacement".into()) } impl Draft { fn command(&self, p: &Project) -> Result { self.source.check_scope( super::source_draft::Scope::Machine { layer: self.layer }, self.owner, )?; let preview = self.source.preview(p, self.owner)?; let command = self.consumer_command(preview.as_ref().unwrap_or(p))?; self.source.combine(p, self.owner, command) } fn consumer_command(&self, p: &Project) -> Result { if self.document != p.id { return Err("This draft belongs to another document.".into()); } let family = self .family .ok_or("Choose the behavior you want to create.")?; if self.name.trim().is_empty() { return Err("Enter the behavior's name before creating it.".into()); } if [self.element, self.diagram, self.placement] .into_iter() .any(|id| p.contains(id)) || self.element == self.diagram || self.element == self.placement || self.diagram == self.placement { return Err( "A reserved creation identity is already in use. Cancel and start a fresh draft." .into(), ); } let kind = family.kind(self.layer); let parent = if family == Family::Machine { Some( self.owner .filter(|id| eligible(p, self.layer, Picker::Owner, *id)) .ok_or("Choose the participant that owns this state machine.")?, ) } else { if self.owner.is_some() { return Err("Processes and scenarios belong at the layer root; context is a separate reference.".into()); } None }; if family != Family::Machine && self.first_view { return Err("A state view can only accompany a state machine.".into()); } let mut body = ElementBody::new(kind, self.layer) .ok_or("This behavior is unavailable in this layer.")?; if family == Family::Scenario { let viewpoint = self .viewpoint .filter(|v| viewpoints(self.layer).contains(v)) .ok_or("Choose the scenario's viewpoint explicitly.")?; if self .context .is_some_and(|id| !eligible(p, self.layer, Picker::Context, id)) { return Err( "The selected scenario context is missing or no longer compatible.".into(), ); } body = ElementBody::shared_in( self.layer, SharedBody::Scenario { viewpoint, context: self.context, precondition: String::new(), postcondition: String::new(), steps: Vec::new(), }, ); } else if self.viewpoint.is_some() || self.context.is_some() { return Err( "Scenario viewpoint and context cannot be applied to another behavior family." .into(), ); } let element = Element { id: self.element, alias: p.fresh_alias(&self.name, "behavior", self.element), name: self.name.clone(), description: String::new(), parent, body, }; let mut commands = vec![Command::CreateElement(element)]; if self.first_view { let working = execute_batch(p, &commands).map_err(|e| e.to_string())?; // Reuse the state editor's geometry contract. A new machine has one // root placement; reserve both presentation IDs across retries. let plan = super::state_edit::create_view(&working, self.element) .map_err(|e| e.to_string())?; let Command::Batch(mut view_commands) = plan.command else { return Err("Unexpected state-view plan.".into()); }; if view_commands.len() != 2 { return Err("Unexpected additional operation in the first state-view plan.".into()); } let Some(Command::Layout(layout)) = view_commands.pop() else { return Err("State-view plan must finish with its layout operation.".into()); }; let Some(Command::CreateDiagram(mut diagram)) = view_commands.pop() else { return Err("State-view plan is missing its diagram.".into()); }; if diagram.id != layout || diagram.nodes.len() != 1 { return Err("A first state view must contain only its new machine.".into()); } let mut node = diagram .nodes .into_values() .next() .ok_or("State-view root is missing.")?; if node.element != self.element { return Err("State-view root differs from the new machine.".into()); } diagram.id = self.diagram; diagram.alias = working.fresh_alias(&diagram.name, "state_view", self.diagram); node.id = self.placement; node.alias = working.fresh_alias( &format!("{} placement", self.name), "placement", self.placement, ); diagram.nodes = [(node.id, node)].into_iter().collect(); commands.push(Command::CreateDiagram(diagram)); commands.push(Command::Layout(self.diagram)); } let command = Command::Batch(commands); execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?; Ok(command) } } impl State { /// Parent maps these local events into the existing guarded Behavior route. /// The card's fields scroll; Create/Cancel stay outside that scroll region. pub fn view<'a>(&'a self, p: &'a Project) -> Option> { self.view_with(p) } fn view_with<'a, R: iced::advanced::text::Renderer + 'a>( &'a self, p: &'a Project, ) -> Option> { let d = self.draft.as_ref()?; if let Some(view) = d.source.view(p) { return Some( column![ text(format!( "State machine name: {}", crate::views::short(&d.name, 60) )) .size(12), view.map(Event::Source) ] .spacing(6) .height(Fill) .into(), ); } let mut fields = column![].spacing(10); let title = if let Some(family) = d.family { fields = fields.push( text_input("Exact behavior name", &d.name) .id(NAME_INPUT) .on_input(Event::Name) .on_submit(Event::Submit) .padding(8), ); match family { Family::Sequence => { fields = fields.push( text(format!( "Owned by the {} layer root. Add work and exchange contributions after creation.", d.layer )) .size(12), ); } Family::Scenario => { fields = fields.push( text(format!( "Owned by the {} layer root. Choose what the lifelines represent:", d.layer )) .size(12), ); for viewpoint in viewpoints(d.layer) { fields = fields.push( button(text(viewpoint_title(viewpoint)).size(13)) .style(if d.viewpoint == Some(viewpoint) { button::primary } else { button::secondary }) .on_press(Event::Viewpoint(viewpoint)) .padding(7), ); } let context_label = if d.context_open { "Hide optional context".into() } else if let Some(id) = d.context { format!("Context: {} · Change…", reference_label(p, id)) } else { "Add optional context…".into() }; fields = fields.push( button(text(context_label).size(12)) .on_press(Event::ContextOptions(!d.context_open)) .padding(6), ); if d.context_open { fields = fields.push(picker_view(p, d, Picker::Context)); } } Family::Machine => { if let Some(label) = d.source.label(d.owner) { fields = fields.push(text(label).size(13)).push(text("Create and open commits the new owner and machine together, including the first view only when selected.").size(12)); } fields = fields.push(picker_view(p, d, Picker::Owner)); fields = fields.push( button("Create new owner…") .on_press(Event::Source(super::source_draft::Event::Open( d.invocation, ))) .padding(7), ); fields = fields.push( checkbox(d.first_view) .label("Create the first state view") .on_toggle(Event::FirstView), ); fields = fields.push(text("The machine starts without regions or states. Add those explicitly in its workspace.").size(12)); } } format!("New {}", family.title(d.layer)) } else { for family in [Family::Sequence, Family::Scenario, Family::Machine] { let purpose = match family { Family::Sequence => "A path through work and exchanges", Family::Scenario => "Ordered interactions from an explicit viewpoint", Family::Machine => "States and modes of an explicitly chosen participant", }; fields = fields.push( button( column![text(family.title(d.layer)).size(15), text(purpose).size(12)] .spacing(3), ) .on_press(Event::Choose(family)) .padding(10) .width(Fill), ); } format!("New behavior · {}", d.layer) }; let mut actions = row![button("Cancel · Esc").on_press(Event::Cancel).padding(8)].spacing(8); if d.family.is_some() { actions = actions.push(button("Create and open").on_press(Event::Submit).padding(8)); } Some( container( column![ text(title).size(20), scrollable(fields) .id("behavior-creation-fields") .height(Fill), container(actions).id("behavior-creation-actions") ] .spacing(12), ) .padding(18) .max_width(680) .height(Fill) .style(crate::style::card) .into(), ) } } fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>( p: &'a Project, d: &'a Draft, picker: Picker, ) -> UiElement<'a, Event, iced::Theme, R> { let browser = &d.browsers[picker.index()]; let options = choices(p, d.layer, picker, &browser.query); let page = browser .page .min(options.len().saturating_sub(1) / PAGE_SIZE); let selected = match picker { Picker::Owner => d.owner, Picker::Context => d.context, }; let title = match picker { Picker::Owner => "Owned by participant", Picker::Context => "Scenario context · optional", }; let mut out = column![ text(title).size(13), text_input("Search names, aliases or parent context", &browser.query) .on_input(move |query| Event::Search(picker, query)) .padding(6) ] .spacing(5); if let Some(id) = selected { out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12)); } else if picker == Picker::Context { out = out.push(text("No context selected").size(12)); } if options.is_empty() { out = out.push(text(match picker { Picker::Owner => "No matching participant. Refine the search or choose Create new owner.", Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.", }).size(12)); } for e in options.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) { let message = match picker { Picker::Owner => Event::Owner(e.id), Picker::Context => Event::Context(Some(e.id)), }; out = out.push( container( button( column![ text(&e.name).size(13), text(reference_context(p, e)).size(11) ] .spacing(2), ) .style(if selected == Some(e.id) { button::primary } else { button::secondary }) .on_press(message) .padding(6) .width(Fill), ) .id(format!("behavior-creation-candidate-{}", e.id)), ); } let mut navigation = row![text(format!("{} matches", options.len())).size(11)].spacing(8); if page > 0 { navigation = navigation.push(button("Previous").on_press(Event::Page(picker, page - 1))); } if (page + 1) * PAGE_SIZE < options.len() { navigation = navigation.push(button("Next").on_press(Event::Page(picker, page + 1))); } if picker == Picker::Context && selected.is_some() { navigation = navigation.push(button("Clear context").on_press(Event::Context(None))); } out.push(navigation).into() } #[cfg(test)] mod app_tests; #[cfg(test)] mod tests;