diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 18487522..8d395af1 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -115,6 +115,7 @@ pub enum Message { BrowseGroup(crate::explorer::Group), ExplorerPage(usize), CreateKind(Kind), + Data(crate::data_view::Event), Add(bool), AddRelation, PortAction(crate::port_workflow::Action), @@ -127,6 +128,8 @@ pub enum Message { Field(String, String), FieldText(String, text_editor::Action), FieldShape(String, Value), + FieldAppend(String, Value), + FieldValuePath(String, crate::data_values::FieldPathKey), FieldVariant(String, Value), FieldArray(String, Option, i32), Apply, @@ -204,6 +207,7 @@ pub struct App { pub page: Page, pub guide: crate::guide::State, pub behavior: crate::behavior::State, + pub data_state: crate::data_view::State, pub inspector_tab: crate::navigation::InspectorTab, pub layer: Layer, pub selected: Id, @@ -292,6 +296,7 @@ impl App { page: Page::Diagram, guide: crate::guide::State::default(), behavior: Default::default(), + data_state: Default::default(), inspector_tab: Default::default(), layer, selected, @@ -764,6 +769,8 @@ impl App { Message::Field(..) | Message::FieldText(..) | Message::FieldShape(..) + | Message::FieldAppend(..) + | Message::FieldValuePath(..) | Message::FieldVariant(..) | Message::FieldArray(..) | Message::ElementKind(_) @@ -896,6 +903,7 @@ impl App { self.connection = None; } if previous_layer != self.layer { + self.data_state = Default::default(); self.explorer_index = crate::explorer::Index::new(self.project(), self.layer); self.explorer_focus = None; self.explorer_group = None; @@ -1124,6 +1132,20 @@ impl App { self.status = e; } } + Message::FieldAppend(path, value) => { + if let Some(draft) = &mut self.draft + && let Err(error) = draft.append(&path, value) + { + self.status = error; + } + } + Message::FieldValuePath(path, key) => { + if let Some(draft) = &mut self.draft + && let Err(error) = draft.value_path(&path, key) + { + self.status = error; + } + } Message::FieldVariant(path, value) => { if let Some(d) = &mut self.draft { let result = d.materialize().and_then(|current| { @@ -1384,6 +1406,19 @@ impl App { self.fit(); } } + Message::Data(event) => { + if self.data_state.update(&event) { + return Task::none(); + } + if self.apply() { + match crate::data_view::command(self.project(), self.layer, event) { + Ok(command) => { + self.execute(command); + } + Err(error) => self.status = error, + } + } + } Message::CreateKind(kind) => self.create_kind = kind, Message::Add(child) => { if self.apply() { @@ -3847,3 +3882,50 @@ mod lint_tests { assert!(app.project().elements.is_empty()); } } + +#[cfg(test)] +mod data_paging_lifecycle_tests { + use super::*; + #[test] + fn paging_preserves_same_context_and_resets_on_layer_or_document_replacement() { + let mut p = Project::empty(); + let mut oa = None; + for layer in [Layer::Oa, Layer::Sa] { + for index in 0..70 { + let id = p + .add_element( + Kind::Interface, + layer, + &format!("Interface {layer} {index}"), + None, + ) + .unwrap(); + if layer == Layer::Oa { + oa = Some(id); + } + } + } + let mut app = App::with_project(p.clone()); + let page = Message::Data(crate::data_view::Event::Page { + section: crate::data_view::Section::Catalog, + owner: None, + page: 2, + }); + let _ = app.update(Message::Page(Page::Data)); + let _ = app.update(page.clone()); + let retained = app.data_state.clone(); + let _ = app.update(Message::Page(Page::Catalog)); + let _ = app.update(Message::Page(Page::Data)); + let _ = app.update(Message::Layer(Layer::Oa)); + assert_eq!(app.data_state, retained); + let _ = app.update(Message::Layer(Layer::Sa)); + assert_eq!(app.data_state, crate::data_view::State::default()); + let _ = app.update(page.clone()); + let _ = app.update(Message::Select(oa.unwrap())); + assert_eq!(app.layer, Layer::Oa); + assert_eq!(app.data_state, crate::data_view::State::default()); + let _ = app.update(page); + app.reset(p, None); + assert_eq!(app.data_state, crate::data_view::State::default()); + } +} diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index d4e42800..8893405c 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -78,7 +78,8 @@ pub enum Event { SecondGuard(String), Operator(Operator), Binding(Id, Choice), - Value(Id, String), + Value(crate::data_values::FieldPathKey, String), + ValueFocus(crate::data_values::FieldPathKey), } #[derive(Default)] pub struct State { @@ -100,7 +101,8 @@ struct Draft { second_guard: String, operator: FragmentOperator, bindings: BTreeMap, - values: BTreeMap, + values: BTreeMap, + value_focus: Option, } impl State { pub(super) fn pending_owner(&self) -> Option { @@ -146,6 +148,7 @@ impl State { operator: FragmentOperator::Alternative, bindings: BTreeMap::new(), values: BTreeMap::new(), + value_focus: None, }); } Event::Cancel => self.acknowledge_commit(), @@ -161,21 +164,34 @@ impl State { d.bindings.clear(); } Event::Exchange(c) => { - d.exchange = Some(c.id); - d.values.clear(); - d.sender = None; - d.receiver = None; + if d.exchange != Some(c.id) { + d.exchange = Some(c.id); + d.values.clear(); + d.value_focus = None; + d.sender = None; + d.receiver = None; + } } Event::Sender(c) => d.sender = Some(c.id), Event::Receiver(c) => d.receiver = Some(c.id), Event::Parent(c) => d.parent = c.id, Event::Style(style) => { + let previous = d.style; d.style = style; - d.call = None; - d.exchange = None; - d.sender = None; - d.receiver = None; - d.values.clear(); + if previous != style { + if previous == MessageStyle::Timer || style == MessageStyle::Timer { + d.exchange = None; + d.sender = None; + d.receiver = None; + d.call = None; + } else if (previous == MessageStyle::Reply) + != (style == MessageStyle::Reply) + { + std::mem::swap(&mut d.sender, &mut d.receiver); + d.call = None; + } + d.retain_value_paths(p); + } } Event::Call(c) => { d.call = Some(c.id); @@ -190,6 +206,10 @@ impl State { .. }) = p.elements.get(&c.id).and_then(|e| e.shared()) { + if d.exchange != *exchange { + d.values.clear(); + d.value_focus = None; + } d.exchange = *exchange; d.sender = *receiver; d.receiver = *sender; @@ -202,6 +222,14 @@ impl State { Event::Value(id, s) => { d.values.insert(id, s); } + Event::ValueFocus(key) + if d.exchange + .and_then(|id| p.relations.get(&id)) + .and_then(|r| crate::data_values::resolve(p, r, &d.kind(), &key)) + .is_some() => + { + d.value_focus = Some(key); + } _ => {} } } @@ -322,7 +350,7 @@ fn exchanges(p: &Project, d: &Draft) -> Vec { exchange: Some(r.id), sender: None, receiver: None, - message_kind: MessageKind::Asynchronous, + message_kind: d.kind(), reply_to: None, values: vec![], }, @@ -373,6 +401,37 @@ fn calls(p: &Project, d: &Draft) -> Vec { .collect() } impl Draft { + fn retain_value_paths(&mut self, p: &Project) { + let kind = self.kind(); + let exchange = self.exchange.and_then(|id| p.relations.get(&id)); + self.values.retain(|key, _| { + exchange + .and_then(|r| crate::data_values::resolve(p, r, &kind, key)) + .is_some() + }); + if self.value_focus.as_ref().is_some_and(|key| { + exchange + .and_then(|r| crate::data_values::resolve(p, r, &kind, key)) + .is_none() + }) { + self.value_focus = None; + } + } + fn value_choices( + &self, + p: &Project, + exchange: &Relation, + ) -> Result, String> { + let mut choices = crate::data_values::choices(p, exchange, &self.kind())?; + for key in self.values.keys().chain(self.value_focus.iter()) { + if !choices.iter().any(|choice| &choice.key == key) + && let Some(choice) = crate::data_values::resolve(p, exchange, &self.kind(), key) + { + choices.push(choice); + } + } + Ok(choices) + } fn kind(&self) -> MessageKind { match self.style { MessageStyle::Asynchronous => MessageKind::Asynchronous, @@ -405,7 +464,8 @@ impl Draft { .iter() .filter(|(_, v)| !v.trim().is_empty()) .map(|(field, expression)| MessageValue { - field: *field, + path: field.path.clone(), + field: field.field, expression: expression.clone(), }) .collect(), @@ -762,20 +822,65 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen ); } if let Some(exchange) = d.exchange.and_then(|id| p.relations.get(&id)) { - for field in validation::carried_fields(p, exchange) { - form = form.push( - row![ - label(p.named(field)), - text_input( - "Optional value expression", - d.values.get(&field).map(String::as_str).unwrap_or("") - ) - .on_input(move |s| event(Event::Value(field, s))) + match d.value_choices(p, exchange) { + Ok(choices) => { + let focus = d.value_focus.as_ref().and_then(|key| { + crate::data_values::resolve(p, exchange, &d.kind(), key) + }); + form = form.push( + pick_list(choices.clone(), focus.clone(), |choice| { + event(Event::ValueFocus(choice.key)) + }) + .placeholder("Explore a record or choose a value path…") + .text_size(12) .padding(8) - .size(12) - ] - .spacing(7), - ); + .width(Fill), + ); + if let Some(focus) = focus { + if let Some(parent) = crate::data_values::parent(&focus.key) { + form = form.push( + button(text("Up to enclosing field").size(11)) + .on_press(event(Event::ValueFocus(parent))), + ); + } + match crate::data_values::children(p, exchange, &d.kind(), &focus.key) { + Ok(children) if !children.is_empty() => { + form = form.push( + pick_list( + children, + None::, + |choice| event(Event::ValueFocus(choice.key)), + ) + .placeholder("Choose a field inside this record…") + .text_size(12) + .padding(8) + .width(Fill), + ) + } + Err(error) => { + form = form.push(text(error).size(11).color(style::MUTED)) + } + _ => {} + } + } + for choice in choices { + let key = choice.key; + form = form.push( + column![ + text(choice.label).size(11), + text_input( + "Optional value expression", + d.values.get(&key).map(String::as_str).unwrap_or("") + ) + .on_input(move |s| event(Event::Value(key.clone(), s))) + .padding(8) + .size(12) + ] + .spacing(4), + ); + } + } + Err(error) => form = form.push(text(error).size(12).color(style::MUTED)), } } if d.style == MessageStyle::CallReply { @@ -1189,3 +1294,200 @@ mod tests { ); } } + +#[cfg(test)] +mod data_contract_tests { + use super::*; + + #[test] + fn scenario_form_commits_two_reused_record_values_and_correlated_reply_atomically() { + let mut f = crate::data_values::tests::payload(); + if let Some(SharedBody::DataField { direction, .. }) = f + .project + .elements + .get_mut(&f.destination) + .unwrap() + .shared_mut() + { + *direction = ParameterDirection::InOut; + } + let source = f.project.relations[&f.exchange].source(); + let target = f.project.relations[&f.exchange].target(); + let mut lines = vec![]; + for (name, represented) in [("Requester", source), ("Performer", target)] { + let id = f + .project + .add_element(Kind::Lifeline, Layer::Oa, name, Some(f.scenario)) + .unwrap(); + *f.project + .elements + .get_mut(&id) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Lifeline { + represented: Some(represented), + }; + lines.push(id); + } + let mut state = State::default(); + for event in [ + Event::Start(f.scenario, Tool::Message), + Event::Name("Move coordinates".into()), + Event::Exchange(named_choice(&f.project, f.exchange)), + Event::Sender(named_choice(&f.project, lines[0])), + Event::Receiver(named_choice(&f.project, lines[1])), + ] { + state.update(&f.project, event).unwrap(); + } + assert!( + exchanges(&f.project, state.draft.as_ref().unwrap()) + .iter() + .any(|c| c.id == f.exchange) + ); + for (parameter, expression) in [(f.origin, "45.0"), (f.destination, "46.0")] { + state + .update( + &f.project, + Event::Value( + crate::data_values::FieldPathKey { + path: vec![parameter], + field: f.leaf, + }, + expression.into(), + ), + ) + .unwrap(); + } + let before = state.draft.as_ref().unwrap().values.clone(); + state + .update(&f.project, Event::Style(MessageStyle::Call)) + .unwrap(); + state + .update( + &f.project, + Event::Exchange(named_choice(&f.project, f.exchange)), + ) + .unwrap(); + assert_eq!(state.draft.as_ref().unwrap().values, before); + assert_eq!(state.draft.as_ref().unwrap().sender, Some(lines[0])); + assert_eq!(state.draft.as_ref().unwrap().receiver, Some(lines[1])); + state + .update(&f.project, Event::Style(MessageStyle::CallReply)) + .unwrap(); + assert_eq!(state.draft.as_ref().unwrap().values, before); + let command = state.prepare_pending(&f.project).unwrap(); + let mut document = + crate::document::Document::from_project(f.project.clone(), Default::default()).unwrap(); + document.execute(command).unwrap(); + let messages = document + .project() + .children(f.scenario) + .filter_map(|e| match e.shared()? { + SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind, + values, + .. + }, + .. + } => Some((message_kind, values)), + _ => None, + }) + .collect::>(); + let call = messages + .iter() + .find(|(kind, _)| **kind == MessageKind::Call) + .unwrap() + .1; + assert_eq!(call.len(), 2); + assert_eq!( + call.iter().map(|v| v.field).collect::>(), + BTreeSet::from([f.leaf]) + ); + assert_eq!( + call.iter().map(|v| v.path.clone()).collect::>(), + BTreeSet::from([vec![f.origin], vec![f.destination]]) + ); + assert!( + messages + .iter() + .find(|(kind, _)| **kind == MessageKind::Reply) + .unwrap() + .1 + .is_empty() + ); + assert!(document.undo()); + assert_eq!(document.project(), &f.project); + assert!(!document.undo()); + } + #[test] + fn recursive_value_focus_exposes_authored_leaf_and_phase_changes_retain_surviving_keys() { + let (f, next) = crate::data_values::tests::recursive_payload(); + let mut state = State::default(); + state + .update(&f.project, Event::Start(f.scenario, Tool::Message)) + .unwrap(); + state + .update( + &f.project, + Event::Exchange(named_choice(&f.project, f.exchange)), + ) + .unwrap(); + let key = crate::data_values::FieldPathKey { + path: vec![f.origin, next], + field: f.leaf, + }; + state + .update(&f.project, Event::ValueFocus(key.clone())) + .unwrap(); + assert!( + state + .draft + .as_ref() + .unwrap() + .value_choices(&f.project, &f.project.relations[&f.exchange]) + .unwrap() + .iter() + .any(|c| c.key == key) + ); + state + .update( + &f.project, + Event::Value(key.clone(), "recursive expression".into()), + ) + .unwrap(); + state + .update(&f.project, Event::Style(MessageStyle::Call)) + .unwrap(); + assert_eq!( + state.draft.as_ref().unwrap().values[&key], + "recursive expression" + ); + state + .update(&f.project, Event::Style(MessageStyle::Asynchronous)) + .unwrap(); + assert_eq!( + state.draft.as_ref().unwrap().values[&key], + "recursive expression" + ); + state + .update(&f.project, Event::Style(MessageStyle::Reply)) + .unwrap(); + assert!( + state.draft.as_ref().unwrap().values.is_empty(), + "In parameters do not become reply values" + ); + let out = crate::data_values::FieldPathKey { + path: vec![f.destination], + field: f.leaf, + }; + state + .update(&f.project, Event::Value(out.clone(), "reply value".into())) + .unwrap(); + state + .update(&f.project, Event::Style(MessageStyle::Asynchronous)) + .unwrap(); + assert_eq!(state.draft.as_ref().unwrap().values[&out], "reply value"); + } +} diff --git a/crates/oa-desktop/src/connection_tool.rs b/crates/oa-desktop/src/connection_tool.rs index 7493df8a..e7ad2feb 100644 --- a/crates/oa-desktop/src/connection_tool.rs +++ b/crates/oa-desktop/src/connection_tool.rs @@ -432,11 +432,12 @@ mod tests { let provided = element(&mut p, Kind::Interface, Layer::Sa, "Reporting"); let required = element(&mut p, Kind::Interface, Layer::Sa, "Results"); for (interface, item) in [(provided, report), (required, result)] { - *p.elements - .get_mut(&interface) - .unwrap() - .shared_mut() - .unwrap() = SharedBody::Interface { items: vec![item] }; + p.relate( + RelationKind::InterfaceItemAllocation, + "Allocated item", + interface, + item, + ); } p.relate( RelationKind::InterfaceProvision, diff --git a/crates/oa-desktop/src/data_values.rs b/crates/oa-desktop/src/data_values.rs new file mode 100644 index 00000000..521d4159 --- /dev/null +++ b/crates/oa-desktop/src/data_values.rs @@ -0,0 +1,375 @@ +//! One address for a carried value, shared by scenario creation and property editing. +use oa_core::*; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct FieldPathKey { + pub path: Vec, + pub field: Id, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FieldPathChoice { + pub key: FieldPathKey, + pub label: String, +} +impl std::fmt::Display for FieldPathChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.label) + } +} + +fn phase_allows(project: &Project, entry: &data::CarriedFieldPath, phase: &MessageKind) -> bool { + data::item_mechanism(project, entry.item) != Some(ExchangeMechanism::Operation) + || match phase { + MessageKind::Call => matches!( + entry.direction, + ParameterDirection::Unspecified + | ParameterDirection::In + | ParameterDirection::InOut + ), + MessageKind::Reply => matches!( + entry.direction, + ParameterDirection::Unspecified + | ParameterDirection::Out + | ParameterDirection::InOut + | ParameterDirection::Return + ), + _ => true, + } +} +fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathChoice { + let qualified = entry + .path + .iter() + .chain(std::iter::once(&entry.field)) + .map(|id| { + project + .elements + .get(id) + .map(|e| format!("{} (@{})", e.name, e.alias)) + .unwrap_or_else(|| id.to_string()) + }) + .collect::>() + .join(" → "); + let direction = + if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) { + match entry.direction { + ParameterDirection::Unspecified => " · direction unspecified — review", + ParameterDirection::In => " · In", + ParameterDirection::Out => " · Out", + ParameterDirection::InOut => " · InOut", + ParameterDirection::Return => " · Return", + } + } else { + "" + }; + FieldPathChoice { + key: FieldPathKey { + path: entry.path, + field: entry.field, + }, + label: format!("{} / {qualified}{direction}", project.named(entry.item)), + } +} + +pub fn choices( + project: &Project, + exchange: &Relation, + phase: &MessageKind, +) -> Result, String> { + Ok(data::try_carried_field_paths(project, exchange)? + .into_iter() + .filter(|entry| phase_allows(project, entry, phase)) + .map(|entry| named_choice(project, entry)) + .collect()) +} + +/// Resolve authored finite paths as well as the finite representative picker list. +/// In particular, optional recursive Value records may be traversed repeatedly. +pub fn resolve( + project: &Project, + exchange: &Relation, + phase: &MessageKind, + key: &FieldPathKey, +) -> Option { + data::carried_value_path(project, exchange, &key.path, key.field) + .filter(|entry| phase_allows(project, entry, phase)) + .map(|entry| named_choice(project, entry)) +} + +pub fn parent(key: &FieldPathKey) -> Option { + let (&field, path) = key.path.split_last()?; + Some(FieldPathKey { + path: path.to_vec(), + field, + }) +} + +/// Expand exactly one selected record. This keeps recursive authoring explicit +/// and finite, and never traverses Reference storage or manufactures value paths. +pub fn children( + project: &Project, + exchange: &Relation, + phase: &MessageKind, + key: &FieldPathKey, +) -> Result, String> { + if resolve(project, exchange, phase, key).is_none() { + return Ok(vec![]); + } + let Some(SharedBody::DataField { + type_ref: TypeRef::Named(record), + storage: FieldStorage::Value, + .. + }) = project.elements.get(&key.field).and_then(Element::shared) + else { + return Ok(vec![]); + }; + if !data::is_record(project, *record) { + return Ok(vec![]); + } + if key.path.len() >= data::MAX_VALUE_PATH_DEPTH { + return Err(format!( + "A value path can contain at most {} enclosing fields.", + data::MAX_VALUE_PATH_DEPTH + )); + } + let mut path = key.path.clone(); + path.push(key.field); + Ok(data::effective_fields(project, *record) + .into_iter() + .filter_map(|field| { + resolve( + project, + exchange, + phase, + &FieldPathKey { + path: path.clone(), + field: field.id, + }, + ) + }) + .collect()) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + pub struct Payload { + pub project: Project, + pub exchange: Id, + pub item: Id, + pub leaf: Id, + pub origin: Id, + pub destination: Id, + pub scenario: Id, + } + pub fn payload() -> Payload { + let mut p = Project::empty(); + let item = p + .add_element(Kind::ExchangeItem, Layer::Oa, "Move", None) + .unwrap(); + *p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { + kind: ExchangeItemKind::Information, + mechanism: ExchangeMechanism::Operation, + supertype: None, + }; + let record = p + .add_element(Kind::DataType, Layer::Oa, "Coordinate", None) + .unwrap(); + *p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype: None }, + default: None, + unit: String::new(), + }; + let leaf = p + .add_element(Kind::DataField, Layer::Oa, "latitude", Some(record)) + .unwrap(); + let origin = p + .add_element(Kind::DataField, Layer::Oa, "origin", Some(item)) + .unwrap(); + let destination = p + .add_element(Kind::DataField, Layer::Oa, "destination", Some(item)) + .unwrap(); + for (id, type_ref, direction) in [ + (leaf, TypeRef::Real, ParameterDirection::Unspecified), + (origin, TypeRef::Named(record), ParameterDirection::In), + (destination, TypeRef::Named(record), ParameterDirection::Out), + ] { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField { + type_ref, + direction, + minimum: 1, + maximum: Some(1), + unit: String::new(), + storage: FieldStorage::Value, + default: None, + }; + } + let source = p + .add_element(Kind::Activity, Layer::Oa, "Request", None) + .unwrap(); + let target = p + .add_element(Kind::Activity, Layer::Oa, "Perform", None) + .unwrap(); + let exchange = p.relate(RelationKind::Interaction, "Movement", source, target); + p.relations.get_mut(&exchange).unwrap().body = RelationBody::Interaction { + source, + target, + items: vec![item], + }; + let scenario = p + .add_element(Kind::Scenario, Layer::Oa, "Conversation", None) + .unwrap(); + validate_candidate(&p).unwrap(); + Payload { + project: p, + exchange, + item, + leaf, + origin, + destination, + scenario, + } + } + + #[test] + fn phase_choices_keep_reused_record_addresses_distinct() { + let mut f = payload(); + assert_eq!( + data::item_mechanism(&f.project, f.item), + Some(ExchangeMechanism::Operation) + ); + let call = choices( + &f.project, + &f.project.relations[&f.exchange], + &MessageKind::Call, + ) + .unwrap(); + assert!( + call.iter() + .any(|c| c.key.path == [f.origin] && c.key.field == f.leaf) + ); + assert!(!call.iter().any(|c| c.key.path == [f.destination])); + let reply = choices( + &f.project, + &f.project.relations[&f.exchange], + &MessageKind::Reply, + ) + .unwrap(); + assert!( + reply + .iter() + .any(|c| c.key.path == [f.destination] && c.key.field == f.leaf) + ); + assert!(!reply.iter().any(|c| c.key.path == [f.origin])); + if let Some(SharedBody::DataField { direction, .. }) = f + .project + .elements + .get_mut(&f.destination) + .unwrap() + .shared_mut() + { + *direction = ParameterDirection::InOut; + } + let both = choices( + &f.project, + &f.project.relations[&f.exchange], + &MessageKind::Call, + ) + .unwrap(); + let leaves = both + .iter() + .filter(|c| c.key.field == f.leaf) + .collect::>(); + assert_eq!(leaves.len(), 2); + assert_ne!(leaves[0].key, leaves[1].key); + assert_ne!(leaves[0].label, leaves[1].label); + let values = leaves + .into_iter() + .map(|c| (c.key.clone(), "1".to_string())) + .collect::>(); + assert_eq!(values.len(), 2); + } + + #[test] + fn reference_parameter_stops_descent_and_unspecified_direction_remains_visible() { + let mut f = payload(); + if let Some(SharedBody::DataField { + storage, direction, .. + }) = f.project.elements.get_mut(&f.origin).unwrap().shared_mut() + { + *storage = FieldStorage::Reference; + *direction = ParameterDirection::Unspecified; + } + let call = choices( + &f.project, + &f.project.relations[&f.exchange], + &MessageKind::Call, + ) + .unwrap(); + let direct = call.iter().find(|c| c.key.field == f.origin).unwrap(); + assert!(direct.key.path.is_empty()); + assert!(direct.label.contains("unspecified")); + assert!(!call.iter().any(|c| c.key.path.contains(&f.origin))); + } + pub fn recursive_payload() -> (Payload, Id) { + let mut f = payload(); + let record = f.project.elements[&f.leaf].parent.unwrap(); + let next = f + .project + .add_element(Kind::DataField, Layer::Oa, "next", Some(record)) + .unwrap(); + *f.project + .elements + .get_mut(&next) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::DataField { + type_ref: TypeRef::Named(record), + minimum: 0, + maximum: Some(1), + unit: String::new(), + direction: ParameterDirection::Unspecified, + storage: FieldStorage::Value, + default: None, + }; + validate_candidate(&f.project).unwrap(); + (f, next) + } + #[test] + fn recursive_records_can_be_drilled_into_and_valid_imported_paths_resolve() { + let (f, next) = recursive_payload(); + let exchange = &f.project.relations[&f.exchange]; + let record = FieldPathKey { + path: vec![f.origin], + field: next, + }; + let nested = children(&f.project, exchange, &MessageKind::Call, &record).unwrap(); + let leaf = nested.iter().find(|c| c.key.field == f.leaf).unwrap(); + assert_eq!(leaf.key.path, vec![f.origin, next]); + assert!( + !choices(&f.project, exchange, &MessageKind::Call) + .unwrap() + .iter() + .any(|c| c.key == leaf.key) + ); + assert_eq!( + resolve(&f.project, exchange, &MessageKind::Call, &leaf.key), + Some(leaf.clone()) + ); + assert_eq!(parent(&leaf.key), Some(record)); + let deepest = FieldPathKey { + path: std::iter::once(f.origin) + .chain(std::iter::repeat_n(next, data::MAX_VALUE_PATH_DEPTH - 1)) + .collect(), + field: next, + }; + assert!(resolve(&f.project, exchange, &MessageKind::Call, &deepest).is_some()); + assert!( + children(&f.project, exchange, &MessageKind::Call, &deepest) + .unwrap_err() + .contains("128") + ); + } +} diff --git a/crates/oa-desktop/src/data_view.rs b/crates/oa-desktop/src/data_view.rs new file mode 100644 index 00000000..d4493a9b --- /dev/null +++ b/crates/oa-desktop/src/data_view.rs @@ -0,0 +1,1095 @@ +//! A bounded catalog and focused dictionary preserve original member identities. +use crate::{ + app::{App, Choice, Message}, + style, +}; +use iced::{ + Element as View, Fill, + widget::{button, column, container, pick_list, row, scrollable, text}, +}; +use oa_core::*; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Copy)] +pub enum Template { + Item, + Operation, + PhysicalFlow, + Record, + Boolean, + Integer, + Real, + Text, + Enumeration, + Interface, + Field, +} +#[derive(Debug, Clone)] +pub enum Event { + Create(Template, Option), + Allocate { + interface: Id, + item: Id, + }, + Associate { + source: Id, + target: Id, + }, + Page { + section: Section, + owner: Option, + page: usize, + }, +} +const PAGE_SIZE: usize = 24; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Section { + Catalog, + Fields, + Allocations, + Associations, +} +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct State { + catalog: usize, + owner: Option, + fields: usize, + allocations: usize, + associations: usize, +} +impl State { + /// Browsing changes no model or property draft. + pub fn update(&mut self, event: &Event) -> bool { + let Event::Page { + section, + owner, + page, + } = event + else { + return false; + }; + if *section != Section::Catalog && self.owner != *owner { + self.owner = *owner; + self.fields = 0; + self.allocations = 0; + self.associations = 0; + } + *match section { + Section::Catalog => &mut self.catalog, + Section::Fields => &mut self.fields, + Section::Allocations => &mut self.allocations, + Section::Associations => &mut self.associations, + } = *page; + true + } + fn page(&self, section: Section, owner: Option) -> usize { + if section != Section::Catalog && self.owner != owner { + return 0; + } + match section { + Section::Catalog => self.catalog, + Section::Fields => self.fields, + Section::Allocations => self.allocations, + Section::Associations => self.associations, + } + } +} +fn page_range(total: usize, page: usize) -> std::ops::Range { + let page = page.min(total.saturating_sub(1) / PAGE_SIZE); + let start = page * PAGE_SIZE; + start..start.saturating_add(PAGE_SIZE).min(total) +} + +pub fn command(project: &Project, layer: Layer, event: Event) -> Result { + match event { + Event::Page { .. } => Err("Paging is a view action.".into()), + Event::Create(template, parent) => { + let (kind, name) = match template { + Template::Item => (Kind::ExchangeItem, "New exchange item"), + Template::Operation => (Kind::ExchangeItem, "New operation"), + Template::PhysicalFlow => (Kind::ExchangeItem, "New physical flow"), + Template::Interface => (Kind::Interface, "New interface"), + Template::Field => (Kind::DataField, "New field"), + _ => (Kind::DataType, "New data type"), + }; + let mut e = project + .make_element(kind, layer, name, parent) + .ok_or("This data kind is unavailable in the selected layer.")?; + let body = match template { + Template::Item | Template::Operation | Template::PhysicalFlow => { + SharedBody::ExchangeItem { + kind: if matches!(template, Template::PhysicalFlow) { + ExchangeItemKind::Material + } else { + ExchangeItemKind::Information + }, + mechanism: match template { + Template::Operation => ExchangeMechanism::Operation, + Template::PhysicalFlow => ExchangeMechanism::Flow, + _ => ExchangeMechanism::Unspecified, + }, + supertype: None, + } + } + Template::Interface => SharedBody::Interface { supertype: None }, + Template::Field => { + let owner = parent + .and_then(|id| project.elements.get(&id)) + .ok_or("Choose the record or item that owns this field.")?; + if owner.layer() != layer + || !(owner.kind() == Kind::ExchangeItem + || data::is_record(project, owner.id)) + { + return Err("Choose a record or exchange item in this layer.".into()); + } + let names: BTreeSet<_> = data::effective_fields(project, owner.id) + .into_iter() + .map(|f| f.name.as_str()) + .collect(); + let mut i = 1; + while names.contains(e.name.as_str()) { + i += 1; + e.name = format!("New field {i}"); + } + SharedBody::DataField { + type_ref: TypeRef::String, + minimum: 1, + maximum: Some(1), + unit: String::new(), + direction: if data::item_mechanism(project, owner.id) + == Some(ExchangeMechanism::Operation) + { + ParameterDirection::In + } else { + ParameterDirection::Unspecified + }, + storage: FieldStorage::Value, + default: None, + } + } + _ => SharedBody::DataType { + definition: match template { + Template::Boolean => DataTypeDefinition::Boolean, + Template::Integer => DataTypeDefinition::Integer { + minimum: None, + maximum: None, + }, + Template::Real => DataTypeDefinition::Real { + minimum: None, + maximum: None, + }, + Template::Text => DataTypeDefinition::String { + minimum_length: 0, + maximum_length: None, + }, + Template::Enumeration => DataTypeDefinition::Enumeration { + literals: vec!["New value".into()], + }, + _ => DataTypeDefinition::Record { supertype: None }, + }, + default: None, + unit: String::new(), + }, + }; + e.body = ElementBody::shared_in(layer, body); + Ok(Command::CreateElement(e)) + } + Event::Allocate { interface, item } => { + let id = Id::new_v4(); + let order = data::effective_interface_allocations(project, interface).iter().filter_map(|r| match r.body { RelationBody::InterfaceItemAllocation { order, .. } => Some(order), _ => None }).max().map_or(Ok(0), |order| order.checked_add(1).ok_or("Allocation order is full; edit existing orders before adding another allocation."))?; + Ok(Command::CreateRelation(Relation { + id, + alias: project.fresh_alias("item_allocation", "allocation", id), + name: "Interface item allocation".into(), + description: String::new(), + body: RelationBody::InterfaceItemAllocation { + source: interface, + target: item, + order, + transmission: TransmissionProtocol::Unspecified, + acquisition: AcquisitionProtocol::Unspecified, + }, + })) + } + Event::Associate { source, target } => { + let id = Id::new_v4(); + Ok(Command::CreateRelation(Relation { + id, + alias: project.fresh_alias("data_association", "association", id), + name: "Data association".into(), + description: String::new(), + body: RelationBody::DataAssociation { + source, + target, + source_role: "source".into(), + target_role: "target".into(), + source_minimum: 0, + source_maximum: None, + target_minimum: 0, + target_maximum: None, + source_navigable: false, + target_navigable: false, + }, + })) + } + } +} +fn action(label: &str, event: Event) -> View<'static, Message> { + button(text(label.to_owned()).size(11)) + .style(style::nav) + .padding(7) + .on_press(Message::Data(event)) + .into() +} +fn open(label: impl Into, id: Id) -> View<'static, Message> { + button(text(label.into()).size(12)) + .style(style::nav) + .padding(6) + .on_press(Message::Select(id)) + .into() +} +fn count(minimum: u32, maximum: Option) -> String { + format!( + "{minimum}..{}", + maximum.map_or("*".into(), |v| v.to_string()) + ) +} +fn type_label(p: &Project, reference: &TypeRef) -> String { + match reference { + TypeRef::Named(id) => p.named(*id).into(), + other => format!("{other:?}"), + } +} +fn short_label(value: &str) -> String { + let mut chars = value.chars(); + let mut text: String = chars.by_ref().take(120).collect(); + if chars.next().is_some() { + text.push('…'); + } + text +} +fn literal_label(value: &DataValue) -> String { + match value { + DataValue::Boolean(v) => v.to_string(), + DataValue::Integer(v) => v.to_string(), + DataValue::Real(v) => v.to_string(), + DataValue::String(v) | DataValue::Enumeration(v) => serde_json::json!(v).to_string(), + } +} +fn default_label(value: &Option>) -> String { + match value { + None => "default absent".into(), + Some(v) if v.is_empty() => "explicit empty default []".into(), + Some(v) => format!( + "default [{}{}]", + v.iter() + .take(3) + .map(|v| short_label(&literal_label(v))) + .collect::>() + .join(", "), + if v.len() > 3 { ", …" } else { "" } + ), + } +} +fn definition_label(definition: &DataTypeDefinition) -> String { + match definition { + DataTypeDefinition::Boolean => "Boolean".into(), + DataTypeDefinition::Integer { minimum, maximum } => format!( + "Integer · {} … {}", + minimum.map_or("−∞".into(), |v| v.to_string()), + maximum.map_or("+∞".into(), |v| v.to_string()) + ), + DataTypeDefinition::Real { minimum, maximum } => format!( + "Real · {} … {}", + minimum.map_or("−∞".into(), |v| v.to_string()), + maximum.map_or("+∞".into(), |v| v.to_string()) + ), + DataTypeDefinition::String { + minimum_length, + maximum_length, + } => format!( + "Text · {} Unicode characters", + count(*minimum_length, *maximum_length) + ), + DataTypeDefinition::Enumeration { literals } => format!( + "Enumeration · {} literals · {}", + literals.len(), + literals + .iter() + .take(4) + .map(|v| short_label(v)) + .collect::>() + .join(" · ") + ), + DataTypeDefinition::Record { .. } => "Record".into(), + } +} + +fn pager( + state: &State, + section: Section, + owner: Option, + total: usize, +) -> View<'static, Message> { + let range = page_range(total, state.page(section, owner)); + let page = range.start / PAGE_SIZE; + let mut previous = button(text("Previous").size(11)).style(style::nav); + if page > 0 { + previous = previous.on_press(Message::Data(Event::Page { + section, + owner, + page: page - 1, + })); + } + let mut next = button(text("Next").size(11)).style(style::nav); + if range.end < total { + next = next.on_press(Message::Data(Event::Page { + section, + owner, + page: page + 1, + })); + } + row![ + previous, + text(if total == 0 { + "0 entries".into() + } else { + format!("{}–{} of {total}", range.start + 1, range.end) + }) + .size(11), + next + ] + .spacing(8) + .align_y(iced::Center) + .into() +} +fn catalog_entries(p: &Project, layer: Layer) -> Vec<&Element> { + let mut entries = p + .elements + .values() + .filter(|e| { + e.layer() == layer + && matches!( + e.kind(), + Kind::ExchangeItem | Kind::DataType | Kind::Interface | Kind::Port + ) + }) + .collect::>(); + entries.sort_by(|a, b| { + (a.kind().label(), &a.name, &a.alias).cmp(&(b.kind().label(), &b.name, &b.alias)) + }); + entries +} +fn focused_owner(p: &Project, selected: Id) -> Option<&Element> { + if let Some(element) = p.elements.get(&selected) { + return if element.kind() == Kind::DataField { + element.parent.and_then(|id| p.elements.get(&id)) + } else if matches!( + element.kind(), + Kind::ExchangeItem | Kind::DataType | Kind::Interface | Kind::Port + ) { + Some(element) + } else { + None + }; + } + p.relations + .get(&selected) + .filter(|r| { + matches!( + r.kind(), + RelationKind::InterfaceItemAllocation | RelationKind::DataAssociation + ) + }) + .and_then(|r| p.elements.get(&r.source())) +} +fn field_row(p: &Project, owner: Id, field: &Element, selected: Id) -> View<'static, Message> { + let Some(SharedBody::DataField { + type_ref, + minimum, + maximum, + direction, + storage, + default, + .. + }) = field.shared() + else { + return text("").into(); + }; + let mut content = + column![open(format!("{} · @{}", field.name, field.alias), field.id)].spacing(4); + let unit = data::effective_field_unit(p, field) + .filter(|s| !s.is_empty()) + .map(|s| format!(" · {s}")) + .unwrap_or_default(); + let type_default = if default.is_none() { + data::effective_element_default(p, field) + .map(|d| { + format!( + " · type default {} (per element)", + short_label(&literal_label(d)) + ) + }) + .unwrap_or_default() + } else { + String::new() + }; + content = content.push( + text(format!( + "{} [{}] · {storage:?} · {direction:?}{unit} · {}{type_default}", + type_label(p, type_ref), + count(*minimum, *maximum), + default_label(default) + )) + .size(11) + .color(style::MUTED), + ); + if let Some(origin) = field.parent.filter(|id| *id != owner) { + content = content.push(open( + format!("Inherited from {} · open original owner", p.named(origin)), + origin, + )); + } + if let TypeRef::Named(id) = type_ref { + content = content.push(open( + if data::is_record(p, *id) { + format!( + "{} · {}", + if *storage == FieldStorage::Reference { + "Open referenced record" + } else { + "Explore nested value fields" + }, + p.named(*id) + ) + } else { + format!("Open named type · {}", p.named(*id)) + }, + *id, + )); + } + container(content) + .padding(8) + .width(Fill) + .style(if selected == field.id { + style::card + } else { + style::subtle + }) + .into() +} +fn focused_details(app: &App, e: &Element) -> View<'static, Message> { + let p = app.project(); + let state = &app.data_state; + let mut card = column![ + text("Focused definition").size(16), + open(format!("{} · @{}", e.name, e.alias), e.id), + button(text("Edit definition").size(11)) + .style(style::nav) + .on_press(Message::EditProperties(e.id)) + ] + .spacing(8); + if let Some(base) = data::supertype(e) { + card = card.push(open(format!("Specializes {}", p.named(base)), base)); + } + match e.shared() { + Some(SharedBody::ExchangeItem { + kind, mechanism, .. + }) => card = card.push(text(format!("{kind:?} · {mechanism:?}")).size(12)), + Some(SharedBody::DataType { + definition, + default, + unit, + }) => { + card = card.push( + text(format!( + "{}{} · {}", + definition_label(definition), + if unit.is_empty() { + String::new() + } else { + format!(" · {unit}") + }, + default + .as_ref() + .map_or("default absent".into(), |d| format!( + "default {}", + short_label(&literal_label(d)) + )) + )) + .size(12), + ); + } + Some(SharedBody::Interface { .. }) => { + let allocations = data::effective_interface_allocations(p, e.id); + card = card.push(text("Effective item allocations").size(13)); + card = card.push(pager( + state, + Section::Allocations, + Some(e.id), + allocations.len(), + )); + for allocation in &allocations[page_range( + allocations.len(), + state.page(Section::Allocations, Some(e.id)), + )] { + let RelationBody::InterfaceItemAllocation { + target, + order, + transmission, + acquisition, + .. + } = allocation.body + else { + continue; + }; + let inherited = allocation.source() != e.id; + card = card.push(open( + format!( + "{order} · {} · {transmission:?} / {acquisition:?}{}", + p.named(target), + if inherited { + format!(" · inherited from {}", p.named(allocation.source())) + } else { + String::new() + } + ), + allocation.id, + )); + let mut controls = row![ + open("Open item", target), + button( + text(if inherited { + "Edit original allocation" + } else { + "Protocols / order" + }) + .size(11) + ) + .style(style::nav) + .on_press(Message::EditProperties(allocation.id)) + ] + .spacing(5); + if !inherited { + controls = controls.push( + button(text("Remove allocation").size(11)) + .style(style::nav) + .on_press(Message::Command(Command::Delete(allocation.id))), + ); + } + card = card.push(controls); + } + let allocated = allocations + .iter() + .map(|r| r.target()) + .collect::>(); + let choices = p + .elements + .values() + .filter(|item| { + item.layer() == e.layer() + && item.kind() == Kind::ExchangeItem + && !allocated.contains(&item.id) + }) + .map(|item| Choice { + id: item.id, + label: format!("{} · @{}", item.name, item.alias), + }) + .collect::>(); + let interface = e.id; + card = card.push( + pick_list(choices, None::, move |c| { + Message::Data(Event::Allocate { + interface, + item: c.id, + }) + }) + .placeholder("Allocate an existing item…") + .text_size(12) + .padding(7), + ); + } + _ => {} + } + if e.kind() == Kind::ExchangeItem || data::is_record(p, e.id) { + let fields = data::effective_fields(p, e.id); + card = card.push(text("Effective fields · base fields followed by local fields").size(13)); + card = card.push(pager(state, Section::Fields, Some(e.id), fields.len())); + for field in &fields[page_range(fields.len(), state.page(Section::Fields, Some(e.id)))] { + card = card.push(field_row(p, e.id, field, app.selected)); + } + card = card.push(action( + if data::item_mechanism(p, e.id) == Some(ExchangeMechanism::Operation) { + "+ Input parameter" + } else { + "+ Field" + }, + Event::Create(Template::Field, Some(e.id)), + )); + } + if data::is_record(p, e.id) { + let source = e.id; + let choices = p + .elements + .values() + .filter(|target| target.layer() == e.layer() && data::is_record(p, target.id)) + .map(|target| Choice { + id: target.id, + label: format!("{} · @{}", target.name, target.alias), + }) + .collect::>(); + card = card.push( + pick_list(choices, None::, move |c| { + Message::Data(Event::Associate { + source, + target: c.id, + }) + }) + .placeholder("Associate with a record…") + .text_size(12) + .padding(7), + ); + let associations = p + .relations + .values() + .filter(|r| { + r.kind() == RelationKind::DataAssociation + && (r.source() == e.id || r.target() == e.id) + }) + .collect::>(); + if !associations.is_empty() { + card = card.push(text("Reference associations").size(13)); + card = card.push(pager( + state, + Section::Associations, + Some(e.id), + associations.len(), + )); + for relation in &associations[page_range( + associations.len(), + state.page(Section::Associations, Some(e.id)), + )] { + card = card.push(open( + format!( + "Association · {} ↔ {} · @{}", + p.named(relation.source()), + p.named(relation.target()), + relation.alias + ), + relation.id, + )); + if let RelationBody::DataAssociation { + source_navigable, + target_navigable, + .. + } = &relation.body + { + card = card.push( + text(format!( + "{}{} · {}{}", + data::association_end_label(relation, true).unwrap_or_default(), + if *source_navigable { + " · navigable" + } else { + "" + }, + data::association_end_label(relation, false).unwrap_or_default(), + if *target_navigable { + " · navigable" + } else { + "" + } + )) + .size(11) + .color(style::MUTED), + ); + } + } + } + } + container(card) + .padding(12) + .width(Fill) + .style(style::card) + .into() +} +pub fn view(app: &App) -> View<'_, Message> { + let p = app.project(); + let mut content = column![ + text("Data & interfaces").size(23), + text("Select a definition to explore its fields, nested types and interface contracts.") + .size(12) + .color(style::MUTED), + row![ + action("+ Item", Event::Create(Template::Item, None)), + action("+ Operation", Event::Create(Template::Operation, None)), + action( + "+ Physical flow", + Event::Create(Template::PhysicalFlow, None) + ), + action("+ Interface", Event::Create(Template::Interface, None)) + ] + .spacing(5), + row![ + action("+ Record", Event::Create(Template::Record, None)), + action("+ Boolean", Event::Create(Template::Boolean, None)), + action("+ Integer", Event::Create(Template::Integer, None)), + action("+ Real", Event::Create(Template::Real, None)), + action("+ Text", Event::Create(Template::Text, None)), + action("+ Enumeration", Event::Create(Template::Enumeration, None)) + ] + .spacing(5) + ] + .spacing(14); + if let Some(owner) = focused_owner(p, app.selected).filter(|e| e.layer() == app.layer) { + content = content.push(focused_details(app, owner)); + } + let entries = catalog_entries(p, app.layer); + content = content.push(text("Dictionary catalog").size(16)); + content = content.push(pager( + &app.data_state, + Section::Catalog, + None, + entries.len(), + )); + for e in &entries[page_range(entries.len(), app.data_state.page(Section::Catalog, None))] { + content = content.push( + container( + column![ + open(format!("{} · @{}", e.name, e.alias), e.id), + text(e.kind().label()).size(11).color(style::MUTED) + ] + .spacing(3), + ) + .padding(8) + .width(Fill) + .style(style::subtle), + ); + } + scrollable(content.padding(24)).into() +} + +#[cfg(test)] +mod tests { + use super::*; + fn send(app: &mut App, message: Message) { + let _ = app.update(message); + } + + #[test] + fn contextual_data_actions_use_typed_defaults_and_one_undo() { + let mut app = App::with_project(Project::empty()); + let before = app.project().clone(); + send( + &mut app, + Message::Data(Event::Create(Template::PhysicalFlow, None)), + ); + let flow = app.selected; + assert!( + matches!( + app.project().elements[&flow].shared(), + Some(SharedBody::ExchangeItem { + kind: ExchangeItemKind::Material, + mechanism: ExchangeMechanism::Flow, + .. + }) + ), + "{}", + app.status + ); + send(&mut app, Message::Undo); + assert_eq!(app.project(), &before); + send( + &mut app, + Message::Data(Event::Create(Template::Operation, None)), + ); + let operation = app.selected; + send( + &mut app, + Message::Data(Event::Create(Template::Field, Some(operation))), + ); + let field = app.selected; + assert_eq!(app.project().elements[&field].parent, Some(operation)); + assert!( + matches!( + app.project().elements[&field].shared(), + Some(SharedBody::DataField { + minimum: 1, + maximum: Some(1), + direction: ParameterDirection::In, + storage: FieldStorage::Value, + default: None, + .. + }) + ), + "{}", + app.status + ); + send(&mut app, Message::Undo); + assert!(!app.project().contains(field)); + assert!(app.project().contains(operation)); + } + + #[test] + fn allocation_rows_edit_relationship_identity_and_remove_only_that_allocation() { + let mut app = App::with_project(Project::empty()); + send(&mut app, Message::Data(Event::Create(Template::Item, None))); + let item = app.selected; + send( + &mut app, + Message::Data(Event::Create(Template::Interface, None)), + ); + let first = app.selected; + send( + &mut app, + Message::Data(Event::Create(Template::Interface, None)), + ); + let second = app.selected; + send( + &mut app, + Message::Data(Event::Allocate { + interface: first, + item, + }), + ); + let allocation = app.selected; + assert_eq!( + app.project().relations[&allocation].source(), + first, + "{}", + app.status + ); + send( + &mut app, + Message::Data(Event::Allocate { + interface: second, + item, + }), + ); + let other = app.selected; + send(&mut app, Message::EditProperties(allocation)); + send( + &mut app, + Message::FieldShape( + "/body/InterfaceItemAllocation/transmission".into(), + serde_json::json!("Synchronous"), + ), + ); + send(&mut app, Message::Apply); + assert!(matches!( + app.project().relations[&allocation].body, + RelationBody::InterfaceItemAllocation { + transmission: TransmissionProtocol::Synchronous, + .. + } + )); + assert!(matches!( + app.project().relations[&other].body, + RelationBody::InterfaceItemAllocation { + transmission: TransmissionProtocol::Unspecified, + .. + } + )); + let before = app.project().clone(); + let source_before = app.source_snapshot.clone(); + send(&mut app, Message::Command(Command::Delete(allocation))); + assert!(!app.project().contains(allocation)); + assert!(app.project().contains(item)); + assert!(app.project().contains(other)); + send(&mut app, Message::Undo); + assert_eq!(app.project(), &before); + assert_eq!(app.source_snapshot, source_before); + assert_eq!(data::interface_items(app.project(), first), vec![item]); + let _ = view(&app); + } + + #[test] + fn association_creation_is_a_separate_relation_with_editable_end_contracts() { + let mut app = App::with_project(Project::empty()); + send( + &mut app, + Message::Data(Event::Create(Template::Record, None)), + ); + let record = app.selected; + send( + &mut app, + Message::Data(Event::Associate { + source: record, + target: record, + }), + ); + let association = app.selected; + assert!( + matches!(app.project().relations[&association].body,RelationBody::DataAssociation {source,target,source_navigable:false,target_navigable:false,..} if source==record&&target==record), + "{}", + app.status + ); + send(&mut app, Message::EditProperties(association)); + send( + &mut app, + Message::Field("/body/DataAssociation/source_role".into(), "parent".into()), + ); + send( + &mut app, + Message::Field( + "/body/DataAssociation/target_role".into(), + "children".into(), + ), + ); + send( + &mut app, + Message::FieldShape( + "/body/DataAssociation/source_maximum".into(), + serde_json::json!(1), + ), + ); + send( + &mut app, + Message::FieldShape( + "/body/DataAssociation/target_navigable".into(), + serde_json::json!(true), + ), + ); + send(&mut app, Message::Apply); + assert_eq!( + data::association_end_label(&app.project().relations[&association], true).as_deref(), + Some("parent [0..1]") + ); + assert!(data::effective_fields(app.project(), record).is_empty()); + } + + #[test] + fn large_inherited_dictionary_has_bounded_widgets_and_every_original_field_is_reachable() { + let mut p = Project::empty(); + let mut base = None; + let mut originals = vec![]; + for i in 0..512 { + let record = p + .add_element(Kind::DataType, Layer::Oa, &format!("Record {i:03}"), None) + .unwrap(); + *p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype: base }, + default: None, + unit: String::new(), + }; + let field = p + .add_element( + Kind::DataField, + Layer::Oa, + &format!("Value {i}"), + Some(record), + ) + .unwrap(); + originals.push(field); + base = Some(record); + } + let selected = base.unwrap(); + let mut app = App::with_project(p); + send(&mut app, Message::Select(selected)); + let fields = data::effective_fields(app.project(), selected); + let mut visited = vec![]; + for page in 0..fields.len().div_ceil(PAGE_SIZE) { + let range = page_range(fields.len(), page); + assert!(range.len() <= PAGE_SIZE); + visited.extend(fields[range].iter().map(|e| e.id)); + } + assert_eq!(visited, originals); + assert_eq!( + focused_owner(app.project(), originals[300]).unwrap().id, + app.project().elements[&originals[300]].parent.unwrap() + ); + let catalog = catalog_entries(app.project(), Layer::Oa); + let catalog_ids = (0..catalog.len().div_ceil(PAGE_SIZE)) + .flat_map(|page| { + catalog[page_range(catalog.len(), page)] + .iter() + .map(|e| e.id) + }) + .collect::>(); + assert_eq!(catalog_ids.len(), 512); + let view = view(&app); + let tree = iced::advanced::widget::Tree::new(view.as_widget()); + fn nodes(tree: &iced::advanced::widget::Tree) -> usize { + 1 + tree.children.iter().map(nodes).sum::() + } + assert!( + nodes(&tree) < 1000, + "{} widget nodes for a single focused page", + nodes(&tree) + ); + } + + #[test] + fn browsing_pages_preserves_invalid_numeric_draft_and_inherited_allocation_identity() { + let mut app = App::with_project(Project::empty()); + send( + &mut app, + Message::Data(Event::Create(Template::Integer, None)), + ); + let integer = app.selected; + let bound = "/body/Oa/Shared/DataType/definition/Integer/minimum"; + send( + &mut app, + Message::FieldShape(bound.into(), serde_json::json!(0)), + ); + send(&mut app, Message::Field(bound.into(), "-".into())); + let accepted = app.project().clone(); + send( + &mut app, + Message::Data(Event::Page { + section: Section::Catalog, + owner: None, + page: usize::MAX, + }), + ); + assert_eq!(app.project(), &accepted); + assert_eq!(app.selected, integer); + assert_eq!(app.draft.as_ref().unwrap().inputs[bound].raw, "-"); + let _ = view(&app); + assert_eq!(page_range(0, usize::MAX), 0..0); + + let mut p = Project::empty(); + let base = p + .add_element(Kind::Interface, Layer::Oa, "Base contract", None) + .unwrap(); + let child = p + .add_element(Kind::Interface, Layer::Oa, "Derived contract", None) + .unwrap(); + *p.elements.get_mut(&child).unwrap().shared_mut().unwrap() = SharedBody::Interface { + supertype: Some(base), + }; + let mut originals = BTreeSet::new(); + for i in 0..73 { + let item = p + .add_element(Kind::ExchangeItem, Layer::Oa, &format!("Item {i}"), None) + .unwrap(); + let allocation = p.relate( + RelationKind::InterfaceItemAllocation, + &format!("Allocation {i}"), + base, + item, + ); + originals.insert(allocation); + } + let allocations = data::effective_interface_allocations(&p, child); + let visited = (0..allocations.len().div_ceil(PAGE_SIZE)) + .flat_map(|page| { + allocations[page_range(allocations.len(), page)] + .iter() + .map(|r| r.id) + }) + .collect::>(); + assert_eq!(visited, originals); + for id in visited { + assert_eq!(focused_owner(&p, id).unwrap().id, base); + } + let mut state = State::default(); + state.update(&Event::Page { + section: Section::Allocations, + owner: Some(child), + page: 2, + }); + assert_eq!(state.page(Section::Allocations, Some(child)), 2); + assert_eq!(state.page(Section::Allocations, Some(base)), 0); + } +} diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index a0662431..e938f3ed 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -292,6 +292,7 @@ impl DiagramCanvas<'_> { request.placement = Some(node); } else if let Some(edge) = self .hit_label(&scene.routed, local) + .or_else(|| self.hit_association_label(&scene.routed, local)) .or_else(|| self.hit_edge(&scene.routed, local).map(|(id, _)| id)) { request.object = edge; @@ -369,6 +370,33 @@ impl DiagramCanvas<'_> { }) .map(|edge| edge.id) } + fn hit_association_label(&self, routed: &RoutedView, p: Point) -> Option { + let p = self.world(p); + routed + .edges + .values() + .flat_map(|edge| { + self.project + .relations + .get(&edge.id) + .into_iter() + .flat_map(move |relation| routing::association_end_geometry(relation, edge)) + .map(move |end| (edge.id, end.label)) + }) + .filter(|(_, label)| { + Rectangle::new( + point(label.bounds.position), + Size::new(label.bounds.size.width, label.bounds.size.height), + ) + .contains(p) + }) + .min_by(|a, b| { + p.distance(point(a.1.center)) + .total_cmp(&p.distance(point(b.1.center))) + .then(a.0.cmp(&b.0)) + }) + .map(|(id, _)| id) + } fn preview_diagram(&self, state: &State) -> Diagram { let mut diagram = self.project.diagrams[&self.diagram].clone(); if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) { @@ -725,7 +753,10 @@ impl canvas::Program for DiagramCanvas<'_> { state.drag = Some((id, p, position)); state.preview = Some(position); Some(Action::publish(Message::SelectNode(id)).and_capture()) - } else if let Some(id) = self.hit_label(&scene.routed, p) { + } else if let Some(id) = self + .hit_label(&scene.routed, p) + .or_else(|| self.hit_association_label(&scene.routed, p)) + { Some(Action::publish(Message::Select(id)).and_capture()) } else if let Some((id, segment)) = self.hit_edge(&scene.routed, p) { if id == self.selected @@ -1010,6 +1041,37 @@ impl canvas::Program for DiagramCanvas<'_> { ..Default::default() }); } + if let Some(relation) = self.project.relations.get(&edge.id) { + for end in routing::association_end_geometry(relation, routed) { + if end.navigable { + let [a, tip, b] = navigation_mark(end.tip, end.inward); + f.stroke( + &Path::new(|p| { + p.move_to(a); + p.line_to(tip); + p.line_to(b); + }), + Stroke::default().with_color(color).with_width(1.7), + ); + } + let label = &end.label; + f.fill_rectangle( + point(label.bounds.position), + Size::new(label.bounds.size.width, label.bounds.size.height), + Color::WHITE, + ); + f.fill_text(Text { + content: label.text.clone(), + position: point(label.center), + color: if active { style::TEAL } else { style::MUTED }, + size: 13.into(), + align_x: iced::widget::text::Alignment::Center, + align_y: iced::alignment::Vertical::Center, + max_width: f32::INFINITY, + ..Default::default() + }); + } + } } for n in &view.nodes { if !containers.contains(&n.id) { @@ -1257,6 +1319,17 @@ fn endpoint_position(view: &ProjectedView, id: Id) -> Option { }) }) } +fn navigation_mark(tip: Position, inward: Position) -> [Point; 3] { + let tip = point(tip); + let delta = point(inward) - tip; + let direction = delta / delta.x.hypot(delta.y).max(0.001); + let normal = Vector::new(-direction.y, direction.x) * 4.; + [ + tip + direction * 9. + normal, + tip, + tip + direction * 9. - normal, + ] +} #[derive(Clone, Copy, PartialEq, Eq)] enum PortGlyph { Function, @@ -1544,6 +1617,21 @@ pub fn export_svg(p: &Project, diagram: Id) -> String { if !label.text.is_empty() { svg.push_str(&format!("{}",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text))); } + if let Some(relation) = p.relations.get(&edge.id) { + for end in routing::association_end_geometry(relation, route) { + let side = if end.source_end { "source" } else { "target" }; + svg.push_str(&format!( + "", + edge.id + )); + if end.navigable { + let [a, tip, b] = navigation_mark(end.tip, end.inward); + svg.push_str(&format!("",a.x,a.y,tip.x,tip.y,b.x,b.y)); + } + let label = &end.label; + svg.push_str(&format!("{}",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text))); + } + } } for n in &view.nodes { if !containers.contains(&n.id) { @@ -2353,6 +2441,98 @@ mod tests { assert!(geometry.bounds.position.y <= route.label.bounds.position.y); } #[test] + fn association_end_contracts_share_svg_geometry_and_select_without_moving_name_label() { + for self_loop in [false, true] { + let mut p = Project::empty(); + let a = p.add(Kind::DataType, "Tree", 100., 100.); + let b = if self_loop { + a + } else { + p.add(Kind::DataType, "Branch", 650., 100.) + }; + for id in BTreeSet::from([a, b]) { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype: None }, + default: None, + unit: String::new(), + }; + } + let edge = p.relate(RelationKind::DataAssociation, "Hierarchy", a, b); + p.relations.get_mut(&edge).unwrap().body = RelationBody::DataAssociation { + source: a, + target: b, + source_role: "parent".into(), + target_role: "children".into(), + source_minimum: 0, + source_maximum: Some(1), + target_minimum: 0, + target_maximum: None, + source_navigable: false, + target_navigable: true, + }; + let diagram = p.ensure_diagram(Layer::Oa); + let projected = project_view(&p, diagram); + assert!( + !projected + .edges + .iter() + .find(|e| e.id == edge) + .unwrap() + .oriented + ); + let routed = route_diagram(&p, &p.diagrams[&diagram], &projected); + let ends = routing::association_end_geometry(&p.relations[&edge], &routed.edges[&edge]); + assert_eq!(ends.len(), 2); + let svg = export_svg(&p, diagram); + assert!(svg.contains("parent [0..1]")); + assert!(svg.contains("children [0..*]")); + assert!(!svg.contains("data-navigation=\"source\"")); + assert!(svg.contains("data-navigation=\"target\"")); + let mut canvas = DiagramCanvas { + project: &p, + diagram, + selected: edge, + zoom: 0.7, + pan: Vector::new(350., 350.), + context: Context { + document: p.id, + diagram, + revision: 0, + epoch: 0, + }, + selected_bend: None, + editable: true, + connection: None, + }; + for end in ends { + assert!(svg.contains(&format!( + "x=\"{}\" y=\"{}\" text-anchor", + end.label.center.x, end.label.center.y + ))); + let at = Point::new( + end.label.center.x * canvas.zoom + canvas.pan.x, + end.label.center.y * canvas.zoom + canvas.pan.y, + ); + let mut state = State::default(); + assert_eq!(canvas.hit_association_label(&routed, at), Some(edge)); + assert!( + matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::Select(id)) if id==edge) + ); + assert!( + state.route_drag.is_none(), + "Endpoint contract labels never drag the association name" + ); + assert_eq!( + canvas + .context_request(&state, at, Rectangle::with_size(Size::new(1800., 1200.))) + .object, + edge + ); + canvas.selected = p.id; + } + } + } + #[test] fn detached_label_selects_connection_and_occluding_card_owns_its_hit() { let (mut p, diagram, edge) = route_fixture(); p.diagrams.get_mut(&diagram).unwrap().routes[0].label = Some(EdgeLabel { diff --git a/crates/oa-desktop/src/forms.rs b/crates/oa-desktop/src/forms.rs index a63c0c3d..a7f30ba1 100644 --- a/crates/oa-desktop/src/forms.rs +++ b/crates/oa-desktop/src/forms.rs @@ -14,6 +14,7 @@ use iced::{ use oa_core::*; use serde_json::{Value, json}; use std::collections::{BTreeMap, BTreeSet}; +mod data_fields; mod port_fields; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -218,7 +219,7 @@ impl Draft { } else if path.ends_with("bindings") { json!({"referenced": Id::nil(), "local": Id::nil()}) } else if path.ends_with("values") { - json!({"field": Id::nil(), "expression": ""}) + json!({"path": [], "field": Id::nil(), "expression": ""}) } else if path.ends_with("routes") { json!({"edge": Id::nil(), "waypoints": [], "label": null}) } else if path.ends_with("waypoints") { @@ -242,6 +243,33 @@ impl Draft { self.rebuild(); Ok(()) } + pub fn append(&mut self, path: &str, value: Value) -> Result<(), String> { + let mut candidate = self.materialize()?; + candidate + .pointer_mut(path) + .and_then(Value::as_array_mut) + .ok_or("List no longer exists.")? + .push(value); + self.value = candidate; + self.rebuild(); + Ok(()) + } + pub fn value_path( + &mut self, + path: &str, + key: crate::data_values::FieldPathKey, + ) -> Result<(), String> { + let mut candidate = self.materialize()?; + let row = candidate + .pointer_mut(path) + .and_then(Value::as_object_mut) + .ok_or("Message value no longer exists.")?; + row.insert("path".into(), json!(key.path)); + row.insert("field".into(), json!(key.field)); + self.value = candidate; + self.rebuild(); + Ok(()) + } pub fn command(&self) -> Result { let mut value = self.materialize()?; let decode = |e: serde_json::Error| e.to_string(); @@ -411,6 +439,7 @@ fn reference(path: &str) -> bool { | "Named" | "diagram" | "machine" + | "supertype" ) || matches!( parent, "items" @@ -440,6 +469,7 @@ fn optional_reference(path: &str) -> bool { | "reply_to" | "lifeline" | "state" + | "supertype" ) } fn multiline(path: &str) -> bool { @@ -462,9 +492,11 @@ fn free_string(path: &str) -> bool { multiline(path) || matches!( path.rsplit('/').next(), - Some("name" | "uri" | "revision" | "fragment" | "unit") + Some("name" | "uri" | "revision" | "fragment" | "unit" | "source_role" | "target_role") ) || path.split('/').rev().nth(1) == Some("literals") + || (path.contains("/default/") + && matches!(path.rsplit('/').next(), Some("String" | "Enumeration"))) } // These are choices for the closed model, not a second validation boundary. @@ -662,21 +694,37 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec { .collect(); } if field == "field" { + let address_path = path + .strip_suffix("/field") + .and_then(|row| value.pointer(&format!("{row}/path"))) + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + .unwrap_or_default(); return element .as_ref() .and_then(|e| match e.shared()? { SharedBody::Occurrence { body: OccurrenceBody::Exchange { - exchange: Some(id), .. + exchange: Some(id), + message_kind, + .. }, .. - } => project.relations.get(id), + } => project + .relations + .get(id) + .and_then(|r| crate::data_values::choices(project, r, message_kind).ok()), _ => None, }) - .map(|r| validation::carried_fields(project, r).into_iter().collect()) - .unwrap_or_default(); + .unwrap_or_default() + .into_iter() + .filter(|choice| choice.key.path == address_path) + .map(|choice| choice.key.field) + .collect::>() + .into_iter() + .collect(); } + let scenario = element .as_ref() .and_then(|e| e.parent) @@ -707,6 +755,40 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec { return false; } match field { + "supertype" => element.as_ref().is_some_and(|owner| { + if candidate.id == owner.id + || candidate.kind() != owner.kind() + || (candidate.kind() == Kind::DataType + && !data::is_record(project, candidate.id)) + { + return false; + } + if let ( + Some(SharedBody::ExchangeItem { + kind, mechanism, .. + }), + Some(SharedBody::ExchangeItem { + kind: base_kind, + mechanism: base_mechanism, + .. + }), + ) = (owner.shared(), candidate.shared()) + && (kind != base_kind + || (*base_mechanism != ExchangeMechanism::Unspecified + && mechanism != base_mechanism)) + { + return false; + } + let mut next = Some(candidate.id); + let mut seen = BTreeSet::new(); + while let Some(id) = next { + if id == owner.id || !seen.insert(id) { + return false; + } + next = project.elements.get(&id).and_then(data::supertype); + } + true + }), "machine" => candidate.kind() == Kind::StateMachine, "parent" => element.as_ref().is_some_and(|child| { if candidate.id == draft.original @@ -1058,6 +1140,20 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)> let words: &[&str] = match field { "side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"], "layer" => &["Oa", "Sa"], + "mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"], + "storage" => &["Value", "Reference"], + "direction" if path.contains("DataField") => { + &["Unspecified", "In", "Out", "InOut", "Return"] + } + "transmission" => &[ + "Unspecified", + "Synchronous", + "Asynchronous", + "Unicast", + "Multicast", + "Broadcast", + ], + "acquisition" => &["Unspecified", "Read", "Accept"], "operator" => &["Alternative", "Parallel", "Loop", "Optional"], "viewpoint" if path.contains("/Oa/") => &[ "OperationalActivity", @@ -1066,7 +1162,7 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)> ], "viewpoint" => &["Functional", "BoundaryInterface", "MixedLegacy"], "phase" => &["Entry", "Do", "Exit"], - "kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy", "Event"], + "kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy"], "kind" if path.contains("Control") => &["Choice", "Merge", "Fork", "Join", "Loop"], "kind" if path.contains("State") => &[ "State", @@ -1132,11 +1228,20 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)> if field == "definition" { return vec![ ("Boolean".into(), json!("Boolean")), - ("Integer".into(), json!("Integer")), - ("Real".into(), json!("Real")), - ("Text".into(), json!("String")), + ( + "Integer".into(), + json!({"Integer":{"minimum":null,"maximum":null}}), + ), + ( + "Real".into(), + json!({"Real":{"minimum":null,"maximum":null}}), + ), + ( + "Text".into(), + json!({"String":{"minimum_length":0,"maximum_length":null}}), + ), ("Enumeration".into(), json!({"Enumeration":{"literals":[]}})), - ("Record".into(), json!("Record")), + ("Record".into(), json!({"Record":{"supertype":null}})), ]; } if field == "type_ref" { @@ -1273,6 +1378,9 @@ fn property<'a>( label: &str, skip_variant: bool, ) -> View<'a, Message> { + if let Some(view) = data_fields::property(draft, project, value, path) { + return view; + } let label_view = || text(label.to_owned()).size(11).color(style::MUTED); if path.ends_with("/Port/legacy") { let mut content = column![].spacing(4); @@ -2075,6 +2183,8 @@ mod tests { Layer::Oa, SharedBody::ExchangeItem { kind: ExchangeItemKind::Energy, + mechanism: ExchangeMechanism::Flow, + supertype: None, }, ); let mut draft = Draft::from_project(&p, item).unwrap(); @@ -2091,8 +2201,12 @@ mod tests { let interface = p .add_element(Kind::Interface, Layer::Oa, "Power contract", None) .unwrap(); - p.elements.get_mut(&interface).unwrap().body = - ElementBody::shared_in(Layer::Oa, SharedBody::Interface { items: vec![item] }); + p.relate( + RelationKind::InterfaceItemAllocation, + "Allocated item", + interface, + item, + ); assert!(execute_batch(&p, &[draft.command().unwrap()]).is_err()); assert!(draft.dirty()); assert_eq!(p.elements[&item].layer(), Layer::Oa); diff --git a/crates/oa-desktop/src/forms/data_fields.rs b/crates/oa-desktop/src/forms/data_fields.rs new file mode 100644 index 00000000..97b8afbe --- /dev/null +++ b/crates/oa-desktop/src/forms/data_fields.rs @@ -0,0 +1,683 @@ +//! Typed data contracts use the same draft and command boundary as other properties. +use super::*; +use iced::widget::column; + +fn element(draft: &Draft, project: &Project) -> Option { + serde_json::from_value(draft.materialize().unwrap_or_else(|_| draft.value.clone())) + .ok() + .or_else(|| project.elements.get(&draft.original).cloned()) +} + +fn definition(project: &Project, reference: &TypeRef) -> Option { + Some(match reference { + TypeRef::Boolean => DataTypeDefinition::Boolean, + TypeRef::Integer => DataTypeDefinition::Integer { + minimum: None, + maximum: None, + }, + TypeRef::Real => DataTypeDefinition::Real { + minimum: None, + maximum: None, + }, + TypeRef::String => DataTypeDefinition::String { + minimum_length: 0, + maximum_length: None, + }, + TypeRef::Named(id) => match project.elements.get(id)?.shared()? { + SharedBody::DataType { definition, .. } => definition.clone(), + _ => return None, + }, + }) +} + +fn literal(definition: &DataTypeDefinition) -> Option { + Some(match definition { + DataTypeDefinition::Boolean => json!({"Boolean": false}), + DataTypeDefinition::Integer { minimum, .. } => json!({"Integer": minimum.unwrap_or(0)}), + DataTypeDefinition::Real { minimum, .. } => json!({"Real": minimum.unwrap_or(0.)}), + DataTypeDefinition::String { .. } => json!({"String": ""}), + DataTypeDefinition::Enumeration { literals } => json!({"Enumeration": literals.first()?}), + DataTypeDefinition::Record { .. } => return None, + }) +} + +pub(super) fn property<'a>( + draft: &'a Draft, + project: &'a Project, + value: &'a Value, + path: &str, +) -> Option> { + if path.split('/').rev().nth(1) == Some("values") && value.is_object() { + return Some(message_value(draft, project, value, path)); + } + let leaf = path.rsplit('/').next()?; + if leaf == "direction" && path.contains("/DataField/") { + let operation = element(draft, project) + .and_then(|e| e.parent) + .and_then(|id| data::item_mechanism(project, id)) + == Some(ExchangeMechanism::Operation); + if !operation { + let mut out = column![ + text("Parameter direction").size(11).color(style::MUTED), + text("Unspecified · direction applies to direct Operation parameters.").size(11) + ] + .spacing(5); + if value.as_str() != Some("Unspecified") { + out = out + .push( + text(format!( + "Current {:?} requires review.", + value.as_str().unwrap_or_default() + )) + .size(11), + ) + .push( + button(text("Use Unspecified").size(11)) + .style(style::nav) + .on_press(Message::FieldShape(path.into(), json!("Unspecified"))), + ); + } + return Some(out.into()); + } + } + let bounded = (path.contains("/definition/Integer/") || path.contains("/definition/Real/")) + && matches!(leaf, "minimum" | "maximum"); + let count_max = matches!(leaf, "maximum_length" | "source_maximum" | "target_maximum") + || (leaf == "maximum" && path.contains("/DataField/")); + if bounded || count_max { + let label = match leaf { + "minimum" => "Inclusive lower bound", + "maximum" if bounded => "Inclusive upper bound", + "maximum_length" => "Maximum Unicode characters", + "source_maximum" => "Source maximum count", + "target_maximum" => "Target maximum count", + _ => "Maximum count", + }; + let mut out = column![text(label).size(11).color(style::MUTED)].spacing(5); + if value.is_null() { + out = out.push( + button( + text(if bounded { + "Unbounded · set bound" + } else { + "Unlimited · set maximum" + }) + .size(12), + ) + .style(style::nav) + .on_press(Message::FieldShape( + path.into(), + if path.contains("/Real/") { + json!(0.) + } else if count_max { + let minimum_path = path.replace("maximum", "minimum"); + json!( + draft + .materialize() + .ok() + .and_then(|v| v.pointer(&minimum_path).and_then(Value::as_u64)) + .unwrap_or(1) + .max(1) + ) + } else { + json!(0) + }, + )), + ); + } else if let Some(input) = draft.inputs.get(path) { + let owned = path.to_owned(); + out = out + .push( + text_input("", &input.raw) + .on_input(move |s| Message::Field(owned.clone(), s)) + .size(12) + .padding(8), + ) + .push( + button(text("Clear bound").size(10)) + .style(style::nav) + .on_press(Message::FieldShape(path.into(), Value::Null)), + ); + } + return Some(out.into()); + } + if leaf == "default" && (path.contains("/DataField/") || path.contains("/DataType/")) { + let owner = element(draft, project); + let (definition, collection, reference, minimum) = + match owner.as_ref().and_then(Element::shared) { + Some(SharedBody::DataType { definition, .. }) => { + (Some(definition.clone()), false, false, 0) + } + Some(SharedBody::DataField { + type_ref, + storage, + minimum, + .. + }) => ( + definition(project, type_ref), + true, + *storage == FieldStorage::Reference, + *minimum, + ), + _ => (None, false, false, 0), + }; + let initial = definition.as_ref().and_then(literal); + let mut out = column![text("Default").size(11).color(style::MUTED)].spacing(6); + if reference { + out = out.push( + text("References have no literal default; they identify an existing record.") + .size(11) + .color(style::MUTED), + ); + } + if value.is_null() { + out = out.push(text("Absent · no authored default").size(12)); + if !reference { + if let Some(initial) = initial { + out = out.push( + button(text("Set literal default").size(11)) + .style(style::nav) + .on_press(Message::FieldShape( + path.into(), + if collection { + json!([initial]) + } else { + initial + }, + )), + ); + } + if collection && minimum == 0 { + out = out.push( + button(text("Set explicit empty collection").size(11)) + .style(style::nav) + .on_press(Message::FieldShape(path.into(), json!([]))), + ); + } + } + } else { + out = out.push( + button(text("Clear default · make absent").size(11)) + .style(style::nav) + .on_press(Message::FieldShape(path.into(), Value::Null)), + ); + if let Some(values) = value.as_array() { + if values.is_empty() { + out = out.push(text("Explicit empty collection []").size(12)); + } + for (i, value) in values.iter().enumerate() { + out = out + .push(literal_editor( + draft, + project, + value, + &format!("{path}/{i}"), + definition.as_ref(), + )) + .push( + button(text("Remove value").size(10)) + .style(style::nav) + .on_press(Message::FieldArray(path.into(), Some(i), 0)), + ); + } + if let Some(initial) = initial + && !reference + { + out = out.push( + button(text("+ Add literal").size(11)) + .style(style::nav) + .on_press(Message::FieldAppend(path.into(), initial)), + ); + } + } else { + out = out.push(literal_editor( + draft, + project, + value, + path, + definition.as_ref(), + )); + } + } + return Some(out.into()); + } + None +} + +fn literal_editor<'a>( + draft: &'a Draft, + project: &'a Project, + value: &'a Value, + path: &str, + definition: Option<&DataTypeDefinition>, +) -> View<'a, Message> { + if let Some(DataTypeDefinition::Enumeration { literals }) = definition { + let current = draft + .materialize() + .ok() + .and_then(|v| { + v.pointer(&format!("{path}/Enumeration")) + .and_then(Value::as_str) + .map(str::to_owned) + }) + .or_else(|| { + value + .get("Enumeration") + .and_then(Value::as_str) + .map(str::to_owned) + }); + let mut choices = literals.clone(); + if let Some(current) = ¤t + && !choices.contains(current) + { + choices.push(current.clone()); + } + let path = path.to_owned(); + let picker = pick_list(choices, current, move |s| { + Message::FieldShape(path.clone(), json!({"Enumeration": s})) + }) + .text_size(12) + .padding(8) + .width(Fill); + return if value.get("Enumeration").is_none() { + column![text("Current default uses another literal type. Choose an enumeration literal or clear the default.").size(11).color(style::MUTED),picker].spacing(5).into() + } else { + picker.into() + }; + } + let mut out = column![].spacing(5); + if let Some(fields) = value.as_object() { + for (tag, payload) in fields { + out = out.push(super::property( + draft, + project, + payload, + &format!("{path}/{tag}"), + &friendly(tag), + true, + )); + } + } else { + out = out.push( + text("Current literal requires review; clear it to choose a typed value.").size(11), + ); + } + out.into() +} + +fn message_value<'a>( + draft: &'a Draft, + project: &'a Project, + value: &'a Value, + path: &str, +) -> View<'a, Message> { + let edited = draft.materialize().unwrap_or_else(|_| draft.value.clone()); + let row = edited.pointer(path).unwrap_or(value).clone(); + let current = serde_json::from_value::(row.clone()).ok(); + let owner = element(draft, project); + let context = owner + .as_ref() + .and_then(Element::shared) + .and_then(|body| match body { + SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + exchange: Some(id), + message_kind, + .. + }, + .. + } => project.relations.get(id).map(|r| (r, message_kind)), + _ => None, + }); + let choices = context + .map(|(r, phase)| crate::data_values::choices(project, r, phase)) + .unwrap_or_else(|| Ok(vec![])); + let mut out = column![text("Qualified carried field").size(11).color(style::MUTED)].spacing(6); + match choices { + Err(error) => out = out.push(text(error).size(11).color(style::MUTED)), + Ok(mut choices) => { + let current_key = current.as_ref().map(|v| crate::data_values::FieldPathKey { + path: v.path.clone(), + field: v.field, + }); + let mut selected = current_key + .as_ref() + .and_then(|key| choices.iter().find(|c| &c.key == key)) + .cloned() + .or_else(|| { + context.and_then(|(r, phase)| { + current_key + .as_ref() + .and_then(|key| crate::data_values::resolve(project, r, phase, key)) + }) + }); + if let Some(choice) = &selected + && !choices.iter().any(|c| c.key == choice.key) + { + choices.push(choice.clone()); + } + if selected.is_none() + && let Some(key) = current_key.clone() + && !key.field.is_nil() + { + out = out.push(text("Current address needs review. Choose an explicit path to resolve a legacy, ambiguous or incompatible value.").size(11).color(style::MUTED)); + let retained = crate::data_values::FieldPathChoice { + label: format!( + "Current address · {} / {}", + key.path + .iter() + .map(|id| project.named(*id)) + .collect::>() + .join(" → "), + project.named(key.field) + ), + key, + }; + selected = Some(retained.clone()); + choices.push(retained); + } + let owned = path.to_owned(); + out = out.push( + pick_list(choices, selected, move |choice| { + Message::FieldValuePath(owned.clone(), choice.key) + }) + .placeholder("Choose a carried field path…") + .text_size(12) + .padding(8) + .width(Fill), + ); + } + } + if let (Some((exchange, phase)), Some(current)) = (context, current) { + let key = crate::data_values::FieldPathKey { + path: current.path, + field: current.field, + }; + if crate::data_values::resolve(project, exchange, phase, &key).is_some() { + if let Some(parent) = crate::data_values::parent(&key) { + out = out.push( + button(text("Up to enclosing field").size(11)) + .style(style::nav) + .on_press(Message::FieldValuePath(path.into(), parent)), + ); + } + match crate::data_values::children(project, exchange, phase, &key) { + Ok(children) if !children.is_empty() => { + let path = path.to_owned(); + out = out.push( + pick_list( + children, + None::, + move |choice| Message::FieldValuePath(path.clone(), choice.key), + ) + .placeholder("Choose a field inside this record…") + .text_size(12) + .padding(8) + .width(Fill), + ); + } + Err(error) => out = out.push(text(error).size(11).color(style::MUTED)), + _ => {} + } + } + } + if let Some(expression) = value.get("expression") { + out = out.push(super::property( + draft, + project, + expression, + &format!("{path}/expression"), + "Value expression", + false, + )); + } + out.into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selecting_qualified_path_preserves_expression_typed_before_the_next_render() { + let (mut f, next) = crate::data_values::tests::recursive_payload(); + if let Some(SharedBody::DataField { direction, .. }) = f + .project + .elements + .get_mut(&f.destination) + .unwrap() + .shared_mut() + { + *direction = ParameterDirection::InOut; + } + let call = f + .project + .add_element(Kind::Occurrence, Layer::Oa, "Call", Some(f.scenario)) + .unwrap(); + *f.project + .elements + .get_mut(&call) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: Some(f.exchange), + sender: None, + receiver: None, + message_kind: MessageKind::Call, + reply_to: None, + values: vec![MessageValue { + path: vec![f.origin], + field: f.leaf, + expression: "old".into(), + }], + }, + precondition: String::new(), + postcondition: String::new(), + }; + f.project + .elements + .get_mut(&f.scenario) + .unwrap() + .steps_mut() + .unwrap() + .push(call); + let mut app = crate::app::App::with_project(f.project.clone()); + let path = "/body/Oa/Shared/Occurrence/body/Exchange/values/0"; + let _ = app.update(Message::Select(call)); + let _ = app.update(Message::Field( + format!("{path}/expression"), + "newly typed".into(), + )); + let _ = app.update(Message::FieldValuePath( + path.into(), + crate::data_values::FieldPathKey { + path: vec![f.destination], + field: f.leaf, + }, + )); + let _ = app.update(Message::Apply); + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = app.project().elements[&call].shared() + else { + panic!() + }; + assert_eq!( + values, + &vec![MessageValue { + path: vec![f.destination], + field: f.leaf, + expression: "newly typed".into() + }], + "{}", + app.status + ); + let _ = app.update(Message::Undo); + assert_eq!(app.project(), &f.project); + let nested = crate::data_values::children( + app.project(), + &app.project().relations[&f.exchange], + &MessageKind::Call, + &crate::data_values::FieldPathKey { + path: vec![f.origin], + field: next, + }, + ) + .unwrap() + .into_iter() + .find(|c| c.key.field == f.leaf) + .unwrap(); + let _ = app.update(Message::Select(call)); + let _ = app.update(Message::FieldValuePath(path.into(), nested.key.clone())); + let _ = app.update(Message::Apply); + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = app.project().elements[&call].shared() + else { + panic!() + }; + assert_eq!(values[0].path, vec![f.origin, next], "{}", app.status); + assert!( + crate::data_values::resolve( + app.project(), + &app.project().relations[&f.exchange], + &MessageKind::Call, + &nested.key + ) + .is_some() + ); + let _ = super::super::view(app.draft.as_ref().unwrap(), app.project()); + } + + #[test] + fn numeric_bound_drafts_keep_partial_and_full_precision_values_out_of_accepted_state() { + let mut p = Project::empty(); + let id = p + .add_element(Kind::DataType, Layer::Oa, "Counter", None) + .unwrap(); + let mut draft = Draft::from_project(&p, id).unwrap(); + let definition = "/body/Oa/Shared/DataType/definition"; + draft + .shape( + definition, + json!({"Integer":{"minimum":null,"maximum":i64::MAX}}), + ) + .unwrap(); + let maximum = format!("{definition}/Integer/maximum"); + draft.input(&maximum, "-".into()); + assert!(draft.command().is_err()); + assert!(draft.shape(&maximum, Value::Null).is_err()); + assert_eq!(draft.inputs[&maximum].raw, "-"); + draft.input(&maximum, "9223372036854775808".into()); + assert!(draft.command().is_err()); + draft.input(&maximum, i64::MAX.to_string()); + draft + .shape( + "/body/Oa/Shared/DataType/default", + json!({"Integer":i64::MAX}), + ) + .unwrap(); + let accepted = execute_batch(&p, &[draft.command().unwrap()]).unwrap(); + assert!(matches!( + accepted.elements[&id].shared(), + Some(SharedBody::DataType { + definition: DataTypeDefinition::Integer { + maximum: Some(i64::MAX), + .. + }, + default: Some(DataValue::Integer(i64::MAX)), + .. + }) + )); + draft.input(&maximum, "10".into()); + assert!(execute_batch(&accepted, &[draft.command().unwrap()]).is_err()); + assert_eq!(draft.inputs[&maximum].raw, "10"); + } + + #[test] + fn defaults_distinguish_absent_empty_and_typed_literals_with_escaped_unicode() { + let mut p = Project::empty(); + let item = p + .add_element(Kind::ExchangeItem, Layer::Oa, "Payload", None) + .unwrap(); + let id = p + .add_element(Kind::DataField, Layer::Oa, "Optional names", Some(item)) + .unwrap(); + let mut draft = Draft::from_project(&p, id).unwrap(); + let root = "/body/Oa/Shared/DataField"; + draft.shape(&format!("{root}/minimum"), json!(0)).unwrap(); + draft + .shape(&format!("{root}/maximum"), Value::Null) + .unwrap(); + draft.shape(&format!("{root}/default"), json!([])).unwrap(); + let empty = execute_batch(&p, &[draft.command().unwrap()]).unwrap(); + assert!( + matches!(empty.elements[&id].shared(),Some(SharedBody::DataField {default:Some(v),..}) if v.is_empty()) + ); + draft + .shape(&format!("{root}/default"), Value::Null) + .unwrap(); + let absent = execute_batch(&empty, &[draft.command().unwrap()]).unwrap(); + assert!(matches!( + absent.elements[&id].shared(), + Some(SharedBody::DataField { default: None, .. }) + )); + draft + .shape(&format!("{root}/default"), json!([{"String":"é🙂\n"}])) + .unwrap(); + draft.input(&format!("{root}/default/0/String"), "é🙂\n".into()); + draft + .append(&format!("{root}/default"), json!({"String":"next"})) + .unwrap(); + assert_eq!( + draft + .materialize() + .unwrap() + .pointer(&format!("{root}/default/0/String")), + Some(&json!("é🙂\n")) + ); + draft.array(&format!("{root}/default"), Some(1), 0).unwrap(); + draft.set_exact(true).unwrap(); + let saved = execute_batch(&absent, &[draft.command().unwrap()]).unwrap(); + assert!( + matches!(saved.elements[&id].shared(),Some(SharedBody::DataField {default:Some(v),..}) if v == &vec![DataValue::String("é🙂\n".into())]) + ); + let _ = super::super::view(&draft, &saved); + draft + .shape(&format!("{root}/storage"), json!("Reference")) + .unwrap(); + assert!(execute_batch(&saved, &[draft.command().unwrap()]).is_err()); + } + + #[test] + fn supertype_picker_excludes_descendants_and_keeps_original_inherited_field_identity() { + let mut p = Project::empty(); + let base = p + .add_element(Kind::DataType, Layer::Oa, "Base", None) + .unwrap(); + let child = p + .add_element(Kind::DataType, Layer::Oa, "Child", None) + .unwrap(); + for (id, supertype) in [(base, None), (child, Some(base))] { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype }, + default: None, + unit: String::new(), + }; + } + let field = p + .add_element(Kind::DataField, Layer::Oa, "Owned by base", Some(base)) + .unwrap(); + let draft = Draft::from_project(&p, base).unwrap(); + let path = "/body/Oa/Shared/DataType/definition/Record/supertype"; + assert!(!reference_ids(&draft, &p, path).contains(&child)); + let child_draft = Draft::from_project(&p, child).unwrap(); + assert!(reference_ids(&child_draft, &p, path).contains(&base)); + assert_eq!(data::effective_fields(&p, child)[0].id, field); + assert_eq!(p.elements[&field].parent, Some(base)); + } +} diff --git a/crates/oa-desktop/src/forms/port_fields.rs b/crates/oa-desktop/src/forms/port_fields.rs index 8d45594e..f9b0e77b 100644 --- a/crates/oa-desktop/src/forms/port_fields.rs +++ b/crates/oa-desktop/src/forms/port_fields.rs @@ -144,13 +144,7 @@ fn component_items_fit(project: &Project, port: Id, items: &[Id]) -> bool { } let declared: Vec<_> = interfaces .iter() - .filter_map(|id| project.elements.get(id)) - .filter_map(|e| match e.shared() { - Some(SharedBody::Interface { items }) => Some(items), - _ => None, - }) - .flatten() - .copied() + .flat_map(|id| data::interface_items(project, *id)) .collect(); items_satisfy(project, items, &declared) } @@ -714,8 +708,12 @@ mod tests { interface, ); assert!(reference_ids(&draft, &project, target).is_empty()); - project.elements.get_mut(&interface).unwrap().body = - ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![item] }); + project.relate( + RelationKind::InterfaceItemAllocation, + "Allocated item", + interface, + item, + ); assert_eq!(reference_ids(&draft, &project, target), vec![output]); } diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index 244b0742..c8f5572a 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -4,6 +4,8 @@ mod behavior; mod connection_edit; mod connection_tool; mod context_menu; +mod data_values; +mod data_view; mod diagram; mod document; mod explorer; diff --git a/crates/oa-desktop/src/port_preview.rs b/crates/oa-desktop/src/port_preview.rs index fbfc6899..e2960138 100644 --- a/crates/oa-desktop/src/port_preview.rs +++ b/crates/oa-desktop/src/port_preview.rs @@ -157,12 +157,9 @@ pub fn port_context_rows(project: &Project, port: Id) -> Vec { } if let Some(legacy) = legacy { rows.push(legacy_line(&context, legacy)); - if let Some(interface) = legacy.interface - && let Some(SharedBody::Interface { items }) = - context.element(interface).and_then(Element::shared) - { - for item in items { - origins.entry(*item).or_default().push(format!( + if let Some(interface) = legacy.interface { + for item in data::interface_items(project, interface) { + origins.entry(item).or_default().push(format!( "preserved interface {} (suggestion only; not an active typed contract)", context.label(interface) )); @@ -711,11 +708,17 @@ mod tests { let (other_fe, commands) = exchange(&mut project, source, target, vec![second]); project = execute_batch(&project, &commands).unwrap(); let interface = add(&mut project, Kind::Interface, "Incident service"); - project.elements.get_mut(&interface).unwrap().body = ElementBody::shared_in( - Layer::Sa, - SharedBody::Interface { - items: vec![first, second], - }, + project.relate( + RelationKind::InterfaceItemAllocation, + "First item", + interface, + first, + ); + project.relate( + RelationKind::InterfaceItemAllocation, + "Second item", + interface, + second, ); let connection = &project.relations[&boundary]; let (a, b) = (connection.source(), connection.target()); @@ -790,8 +793,12 @@ mod tests { .. } = fixture(); let interface = add(&mut project, Kind::Interface, "Preserved contract"); - project.elements.get_mut(&interface).unwrap().body = - ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![first] }); + project.relate( + RelationKind::InterfaceItemAllocation, + "Allocated item", + interface, + first, + ); let legacy = port( &mut project, source, diff --git a/crates/oa-desktop/src/port_workflow.rs b/crates/oa-desktop/src/port_workflow.rs index ca3d26e9..2eec945b 100644 --- a/crates/oa-desktop/src/port_workflow.rs +++ b/crates/oa-desktop/src/port_workflow.rs @@ -168,12 +168,9 @@ impl State { _ => {} } } - if let Some(SharedBody::Interface { items }) = old - .and_then(|old| old.interface) - .and_then(|id| p.elements.get(&id)) - .and_then(|e| e.shared()) - { - incoming.extend(items); + if let Some(interface) = old.and_then(|old| old.interface) { + let items = data::interface_items(p, interface); + incoming.extend(items.iter().copied()); outgoing.extend(items); } for r in p.relations.values() { diff --git a/crates/oa-desktop/src/tests.rs b/crates/oa-desktop/src/tests.rs index 4e45e410..e18507a8 100644 --- a/crates/oa-desktop/src/tests.rs +++ b/crates/oa-desktop/src/tests.rs @@ -250,7 +250,7 @@ fn opening_legacy_source_surfaces_report_without_modifying_original_bytes() { assert_eq!(app.page, Page::Validation); assert_eq!(app.project(), &original); assert!(!app.dirty()); - assert!(app.source_snapshot.starts_with("arcadia 3;")); + assert!(app.source_snapshot.starts_with("arcadia 4;")); assert!(serde_json::from_slice::(&bytes).unwrap()["schema_version"] == 2); } diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index 13e51461..34c967a9 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -703,69 +703,7 @@ fn inspector(app: &App) -> View<'_, Message> { } fn data_catalog(app: &App) -> View<'_, Message> { - let p = app.project(); - let mut content = column![heading( - "Data & interfaces", - "Define information, material and energy at the system boundary." - )] - .spacing(15); - for kind in Kind::ALL { - let is_data = matches!( - kind, - Kind::ExchangeItem | Kind::DataType | Kind::DataField | Kind::Interface | Kind::Port - ); - if !is_data { - continue; - } - let elements = p - .elements - .values() - .filter(|e| e.layer() == app.layer && e.kind() == kind) - .collect::>(); - if elements.is_empty() { - continue; - } - content = content.push( - text(format!("{} · {}", kind.label(), elements.len())) - .size(15) - .color(style::kind_color(kind)), - ); - for e in elements { - content = content.push(crate::context_menu::Area::new( - button( - row![ - column![ - text(&e.name).size(14), - label(format!( - "@{}{}", - e.alias, - e.parent - .map(|id| format!(" · in {}", p.named(id))) - .unwrap_or_default() - )), - text(short(&e.description, 125)) - .size(12) - .color(style::MUTED) - ] - .spacing(5) - .width(Fill), - text("→").size(17) - ] - .align_y(iced::Center), - ) - .padding(14) - .width(Fill) - .style(if e.id == app.selected { - style::selected - } else { - style::nav - }) - .on_press(Message::Select(e.id)), - move |anchor| crate::model_menu::open(e.id, anchor), - )); - } - } - scrollable(content.padding(24)).into() + crate::data_view::view(app) } fn traces(app: &App) -> View<'_, Message> { @@ -1099,6 +1037,51 @@ fn checks(app: &App) -> View<'_, Message> { app.project().named(endpoint.exchange) ))); } + card=card.push(label(format!("Data migration: {} semantic defaults · {} interface allocations · {} unresolved value addresses",report.semantic_defaults.len(),report.generated_interface_allocations.len(),report.unresolved_message_values.len()))); + for default in report.semantic_defaults.iter().take(6) { + card = card + .push(action( + &format!( + "{} · {}", + app.project().named(default.object), + default.field + ), + Message::Select(default.object), + )) + .push(label(&default.description)); + } + for allocation in report.generated_interface_allocations.iter().take(6) { + card = card + .push(action( + &format!( + "Created item allocation · @{} · order {}", + allocation.alias, allocation.order + ), + Message::Select(allocation.id), + )) + .push(label(format!( + "{} → {}", + app.project().named(allocation.source), + app.project().named(allocation.target) + ))); + } + for value in report.unresolved_message_values.iter().take(6) { + card = card + .push(action( + &format!( + "Review qualified value · {}", + app.project().named(value.occurrence) + ), + Message::EditProperties(value.occurrence), + )) + .push(label(&value.reason)); + } + if report.semantic_defaults.len() > 6 + || report.generated_interface_allocations.len() > 6 + || report.unresolved_message_values.len() > 6 + { + card=card.push(label("Showing the first six entries in each data migration category. Model checks below link remaining unresolved contracts.")); + } for boundary in &report.orientation_defaults { card = card.push(action( &format!(