1096 lines
38 KiB
Rust
1096 lines
38 KiB
Rust
//! 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<Id>),
|
||
Allocate {
|
||
interface: Id,
|
||
item: Id,
|
||
},
|
||
Associate {
|
||
source: Id,
|
||
target: Id,
|
||
},
|
||
Page {
|
||
section: Section,
|
||
owner: Option<Id>,
|
||
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<Id>,
|
||
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<Id>) -> 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<usize> {
|
||
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<Command, String> {
|
||
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<String>, 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<u32>) -> 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<Vec<DataValue>>) -> 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::<Vec<_>>()
|
||
.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::<Vec<_>>()
|
||
.join(" · ")
|
||
),
|
||
DataTypeDefinition::Record { .. } => "Record".into(),
|
||
}
|
||
}
|
||
|
||
fn pager(
|
||
state: &State,
|
||
section: Section,
|
||
owner: Option<Id>,
|
||
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::<Vec<_>>();
|
||
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::<BTreeSet<_>>();
|
||
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::<Vec<_>>();
|
||
let interface = e.id;
|
||
card = card.push(
|
||
pick_list(choices, None::<Choice>, 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::<Vec<_>>();
|
||
card = card.push(
|
||
pick_list(choices, None::<Choice>, 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::<Vec<_>>();
|
||
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::<BTreeSet<_>>();
|
||
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::<usize>()
|
||
}
|
||
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::<BTreeSet<_>>();
|
||
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);
|
||
}
|
||
}
|