593 lines
25 KiB
Rust
593 lines
25 KiB
Rust
use super::*;
|
|
use crate::{style, views::short};
|
|
use iced::{
|
|
Element, Fill,
|
|
widget::{
|
|
Space, button, checkbox, column, container, pick_list, row, scrollable, text, text_input,
|
|
},
|
|
};
|
|
|
|
fn event(event: Event) -> Message {
|
|
Message::Workflow(crate::workflow::Event::Semantic(event))
|
|
}
|
|
fn action<'a>(label: impl Into<String>, event_value: Event) -> iced::widget::Button<'a, Message> {
|
|
button(text(label.into()).size(12))
|
|
.padding([7, 9])
|
|
.style(style::nav)
|
|
.on_press(event(event_value))
|
|
}
|
|
pub(super) fn choice(project: &Project, id: Id) -> Choice {
|
|
let alias = project
|
|
.elements
|
|
.get(&id)
|
|
.map(|v| v.alias.as_str())
|
|
.or_else(|| project.relations.get(&id).map(|v| v.alias.as_str()))
|
|
.or_else(|| project.artifact_links.get(&id).map(|v| v.alias.as_str()));
|
|
Choice {
|
|
id,
|
|
label: alias.map_or_else(
|
|
|| format!("Deleted reference · {id}"),
|
|
|alias| format!("{} · @{alias}", project.named(id)),
|
|
),
|
|
}
|
|
}
|
|
fn words(value: &str) -> String {
|
|
let mut out = String::new();
|
|
for (index, c) in value.chars().enumerate() {
|
|
if c == '_' {
|
|
out.push(' ');
|
|
} else {
|
|
if index > 0 && c.is_uppercase() {
|
|
out.push(' ');
|
|
}
|
|
out.push(c);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
pub(super) fn group(facet: &FacetSelector) -> &'static str {
|
|
match facet {
|
|
FacetSelector::Header(_) => "Name and ownership",
|
|
FacetSelector::Data(_) | FacetSelector::Field(_) | FacetSelector::Literal(_) => {
|
|
"Data and defaults"
|
|
}
|
|
FacetSelector::Port(_) | FacetSelector::Interface(_) | FacetSelector::ExchangeItem(_) => {
|
|
"Contracts and interfaces"
|
|
}
|
|
FacetSelector::Scenario(_)
|
|
| FacetSelector::Lifeline(_)
|
|
| FacetSelector::Occurrence(_)
|
|
| FacetSelector::Event(_)
|
|
| FacetSelector::State(_)
|
|
| FacetSelector::Annotation(_)
|
|
| FacetSelector::Binding(_) => "Behavior and scenarios",
|
|
FacetSelector::Relation(_) | FacetSelector::Constraint(_) => {
|
|
"Relationships and constraints"
|
|
}
|
|
FacetSelector::Member { .. } | FacetSelector::Order(_) => "Membership and order",
|
|
FacetSelector::Child(_)
|
|
| FacetSelector::Incident { .. }
|
|
| FacetSelector::Artifact { .. } => "Attached records",
|
|
}
|
|
}
|
|
pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
|
|
let keys = |keys: &KeyPair<Id>| {
|
|
format!(
|
|
"{} ↔ {}",
|
|
keys.oa
|
|
.map(|id| choice(project, id).label)
|
|
.unwrap_or_else(|| "Absent".into()),
|
|
keys.sa
|
|
.map(|id| choice(project, id).label)
|
|
.unwrap_or_else(|| "Absent".into())
|
|
)
|
|
};
|
|
match facet {
|
|
FacetSelector::Member {
|
|
collection,
|
|
keys: pair,
|
|
} => format!("{} · {}", words(&format!("{collection:?}")), keys(pair)),
|
|
FacetSelector::Order(collection) => format!("{} order", words(&format!("{collection:?}"))),
|
|
FacetSelector::Literal(pair) => format!(
|
|
"Literal · {} ↔ {}",
|
|
pair.oa.as_deref().unwrap_or("Absent"),
|
|
pair.sa.as_deref().unwrap_or("Absent")
|
|
),
|
|
FacetSelector::Annotation(pair) => format!(
|
|
"Message value · {} ↔ {}",
|
|
pair.oa
|
|
.as_ref()
|
|
.map(|key| annotation_title(project, key))
|
|
.unwrap_or_else(|| "Absent".into()),
|
|
pair.sa
|
|
.as_ref()
|
|
.map(|key| annotation_title(project, key))
|
|
.unwrap_or_else(|| "Absent".into())
|
|
),
|
|
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair)),
|
|
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair)),
|
|
FacetSelector::Incident { keys: pair, end } => format!("Incident {end:?} · {}", keys(pair)),
|
|
FacetSelector::Artifact { keys: pair, facet } => {
|
|
format!("Artifact {} · {}", words(&format!("{facet:?}")), keys(pair))
|
|
}
|
|
_ => {
|
|
let json = serde_json::to_value(facet).expect("Typed selector");
|
|
let value = json
|
|
.as_object()
|
|
.expect("Family")
|
|
.values()
|
|
.next()
|
|
.expect("Leaf");
|
|
words(value.as_str().unwrap_or("Property"))
|
|
}
|
|
}
|
|
}
|
|
fn annotation_title(project: &Project, key: &AnnotationKey) -> String {
|
|
key.path
|
|
.iter()
|
|
.chain(std::iter::once(&key.field))
|
|
.map(|id| choice(project, *id).label)
|
|
.collect::<Vec<_>>()
|
|
.join(" / ")
|
|
}
|
|
fn drift(drift: Drift) -> &'static str {
|
|
match drift {
|
|
Drift::Unreviewed => "Unreviewed",
|
|
Drift::Unchanged => "Unchanged since review",
|
|
Drift::OaChanged => "Operational changed",
|
|
Drift::SaChanged => "System changed",
|
|
Drift::BothChanged => "Both changed",
|
|
}
|
|
}
|
|
pub(crate) fn summary(project: &Project, trace: &Trace) -> String {
|
|
let references = BTreeMap::from([(trace.oa, trace.sa)]);
|
|
match trace_semantic_drift(project, trace, &references) {
|
|
Ok(value) => format!(
|
|
"Semantics: {}{} · Linked: {}{}",
|
|
drift(value.own),
|
|
if value.own_unreviewed && value.own != Drift::Unreviewed {
|
|
" · incomplete review"
|
|
} else {
|
|
""
|
|
},
|
|
drift(value.linked),
|
|
if value.linked_unreviewed && value.linked != Drift::Unreviewed {
|
|
" · incomplete review"
|
|
} else {
|
|
""
|
|
}
|
|
),
|
|
Err(error) => format!("Semantic review unavailable: {error}"),
|
|
}
|
|
}
|
|
fn formatted(project: &Project, value: &Value, references: &BTreeSet<Id>) -> String {
|
|
match value {
|
|
Value::Null => "Not set".into(),
|
|
Value::Bool(value) => if *value { "Yes" } else { "No" }.into(),
|
|
Value::Number(value) => value.to_string(),
|
|
Value::String(value) => {
|
|
if let Ok(id) = value.parse::<Id>()
|
|
&& references.contains(&id)
|
|
{
|
|
choice(project, id).label
|
|
} else if value.is_empty() {
|
|
"Empty text".into()
|
|
} else {
|
|
format!("“{value}”")
|
|
}
|
|
}
|
|
Value::Array(values) if values.is_empty() => "Empty list (0 members)".into(),
|
|
Value::Array(values) => values
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, v)| format!("{}. {}", i + 1, formatted(project, v, references)))
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
Value::Object(values) => values
|
|
.iter()
|
|
.map(|(key, value)| {
|
|
format!("{}: {}", words(key), formatted(project, value, references))
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
}
|
|
}
|
|
fn observed(project: &Project, fact: Option<&FacetReview>, layer: Layer) -> String {
|
|
let Some(fact) = fact else {
|
|
return "Unreviewed — no previous observation".into();
|
|
};
|
|
let value = scalar::observation(fact, layer);
|
|
if value.as_str() == Some("NotApplicable") {
|
|
"Not applicable".into()
|
|
} else {
|
|
formatted(project, &value["Value"], &scalar::references(fact))
|
|
}
|
|
}
|
|
|
|
impl State {
|
|
pub fn view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
|
let trace = self.trace.and_then(|id| project.traces.get(&id));
|
|
let mut traces: Vec<_> = project
|
|
.traces
|
|
.values()
|
|
.filter(|t| t.oa == self.anchor || t.sa == self.anchor)
|
|
.map(|t| Choice {
|
|
id: t.id,
|
|
label: format!(
|
|
"{} · {:?} · {} ↔ {} · @{}",
|
|
t.name,
|
|
t.kind,
|
|
project.named(t.oa),
|
|
project.named(t.sa),
|
|
t.alias
|
|
),
|
|
})
|
|
.collect();
|
|
traces.sort_by(|a, b| a.label.cmp(&b.label));
|
|
let selected_trace = traces.iter().find(|c| Some(c.id) == self.trace).cloned();
|
|
let direction = if self.direction == TransitionDirection::OaToSa {
|
|
"Use Operational changes in System"
|
|
} else {
|
|
"Use System changes in Operational"
|
|
};
|
|
let mut content = column![
|
|
text("Review semantics").size(22),
|
|
row![
|
|
pick_list(traces, selected_trace, |c| event(Event::Trace(c))).placeholder("Choose a trace / review history").text_size(12).padding(7).width(Fill),
|
|
action(direction, Event::Direction(if self.direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })),
|
|
].spacing(8),
|
|
text("Changing trace or direction starts a new review draft. Other pending edits remain separate.").size(10).color(style::MUTED),
|
|
row![action("Properties", Event::Tab(Tab::Facts)), action(format!("Reference pairs ({})", self.references.len()), Event::Tab(Tab::References)), action(format!("Additional changes ({})", self.plan.as_ref().map_or(0, |p| p.impacts.len())), Event::Tab(Tab::Impacts)), Space::new().width(Fill), action("Refresh preview", Event::Preview)].spacing(6),
|
|
].spacing(8);
|
|
let body = match self.tab {
|
|
Tab::Facts => self.facts_view(project),
|
|
Tab::References => self.references_view(project),
|
|
Tab::Impacts => self.impacts_view(project),
|
|
};
|
|
content = content.push(body);
|
|
if let Some(error) = &self.error {
|
|
content = content.push(
|
|
text(error)
|
|
.size(12)
|
|
.color(iced::Color::from_rgb8(178, 54, 58)),
|
|
);
|
|
}
|
|
if let Some(plan) = &self.plan {
|
|
for error in &plan.conflicts {
|
|
content = content.push(
|
|
text(error)
|
|
.size(12)
|
|
.color(iced::Color::from_rgb8(178, 54, 58)),
|
|
);
|
|
}
|
|
}
|
|
let unreviewed = self
|
|
.facts
|
|
.iter()
|
|
.filter(|f| f.drift == Drift::Unreviewed)
|
|
.count();
|
|
content = content.push(text(format!("{} selected decisions · {unreviewed} currently unreviewed facts · Constructors and structured collections require further review.", self.decisions.len())).size(10).color(style::MUTED));
|
|
if let Some(trace) = trace {
|
|
content = content.push(text(summary(project, trace)).size(10).color(style::MUTED));
|
|
}
|
|
content
|
|
.push(
|
|
row![
|
|
text(
|
|
"Apply updates the selected history and model together; Undo restores both."
|
|
)
|
|
.size(10)
|
|
.width(Fill),
|
|
button(text("Cancel").size(12))
|
|
.padding(8)
|
|
.style(style::nav)
|
|
.on_press(Message::Workflow(crate::workflow::Event::Cancel)),
|
|
button(text("Apply reviewed changes").size(12))
|
|
.padding(8)
|
|
.on_press_maybe(
|
|
self.can_apply()
|
|
.then_some(Message::Workflow(crate::workflow::Event::Apply))
|
|
),
|
|
]
|
|
.spacing(8)
|
|
.align_y(iced::Center),
|
|
)
|
|
.into()
|
|
}
|
|
|
|
fn facts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
|
let mut list = column![
|
|
checkbox(self.show_unchanged)
|
|
.label("Include unchanged facts")
|
|
.text_size(11)
|
|
.on_toggle(|v| event(Event::ShowUnchanged(v)))
|
|
]
|
|
.spacing(5);
|
|
let mut groups: BTreeMap<&str, Vec<&FacetDrift>> = BTreeMap::new();
|
|
for fact in &self.facts {
|
|
if self.show_unchanged
|
|
|| fact.drift != Drift::Unchanged
|
|
|| self.decisions.contains_key(&fact.selector)
|
|
|| self.selected.as_ref() == Some(&fact.selector)
|
|
{
|
|
groups.entry(group(&fact.selector)).or_default().push(fact);
|
|
}
|
|
}
|
|
for (group, facts) in groups {
|
|
list = list.push(text(group).size(12).color(style::TEAL));
|
|
for fact in facts {
|
|
let mark = match self.decisions.get(&fact.selector) {
|
|
Some(SemanticDecision::KeepTarget) => "Keep · ",
|
|
Some(SemanticDecision::TakeSource) => "Take · ",
|
|
Some(SemanticDecision::Candidate) => "Final value · ",
|
|
None => "",
|
|
};
|
|
list = list.push(
|
|
button(
|
|
column![
|
|
text(short(&title(project, &fact.selector), 52)).size(12),
|
|
text(format!("{mark}{}", drift(fact.drift))).size(10)
|
|
]
|
|
.spacing(3),
|
|
)
|
|
.padding(7)
|
|
.width(Fill)
|
|
.style(if self.selected.as_ref() == Some(&fact.selector) {
|
|
style::selected
|
|
} else {
|
|
style::nav
|
|
})
|
|
.on_press(event(Event::Select(fact.selector.clone()))),
|
|
);
|
|
}
|
|
}
|
|
row![
|
|
scrollable(list).height(305).width(260),
|
|
scrollable(self.detail(project)).height(305).width(Fill)
|
|
]
|
|
.spacing(12)
|
|
.into()
|
|
}
|
|
|
|
fn detail<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
|
let Some(fact) = self
|
|
.selected
|
|
.as_ref()
|
|
.and_then(|s| self.facts.iter().find(|f| &f.selector == s))
|
|
else {
|
|
return text("Choose a trace and property to compare.")
|
|
.size(13)
|
|
.into();
|
|
};
|
|
let trace = self.trace.and_then(|id| project.traces.get(&id));
|
|
let previous = trace
|
|
.and_then(|t| t.baseline.as_ref())
|
|
.and_then(|b| b.semantic.as_ref())
|
|
.and_then(|b| b.facets.iter().find(|f| f.selector() == fact.selector));
|
|
let side = |layer, label: &str| {
|
|
column![
|
|
text(format!(
|
|
"{label}{}",
|
|
if layer == self.direction.target_layer() {
|
|
" · destination"
|
|
} else {
|
|
" · source"
|
|
}
|
|
))
|
|
.size(12)
|
|
.color(style::TEAL),
|
|
text("Previously reviewed").size(10).color(style::MUTED),
|
|
text(observed(project, previous, layer)).size(12),
|
|
text("Current").size(10).color(style::MUTED),
|
|
text(observed(project, Some(&fact.current), layer)).size(12),
|
|
]
|
|
.spacing(5)
|
|
.width(Fill)
|
|
};
|
|
let mut detail = column![
|
|
text(title(project, &fact.selector)).size(17),
|
|
text(drift(fact.drift)).size(11),
|
|
row![side(Layer::Oa, "Operational"), side(Layer::Sa, "System")].spacing(14)
|
|
]
|
|
.spacing(10);
|
|
if let Some(proposed) = &self.proposed
|
|
&& let Some(trace) = trace
|
|
&& let Ok(after) = capture_facet(proposed, trace.oa, trace.sa, &fact.selector)
|
|
{
|
|
detail = detail
|
|
.push(
|
|
text("Proposed destination value · validated preview")
|
|
.size(11)
|
|
.color(style::TEAL),
|
|
)
|
|
.push(
|
|
text(observed(
|
|
proposed,
|
|
Some(&after),
|
|
self.direction.target_layer(),
|
|
))
|
|
.size(12),
|
|
);
|
|
} else {
|
|
detail = detail.push(
|
|
text("No validated final preview. Resolve the messages below before applying.")
|
|
.size(11)
|
|
.color(style::MUTED),
|
|
);
|
|
}
|
|
if self.editable(project, &fact.selector) {
|
|
let destination = if self.direction.target_layer() == Layer::Sa {
|
|
"System"
|
|
} else {
|
|
"Operational"
|
|
};
|
|
let source = if self.direction.source_layer() == Layer::Oa {
|
|
"Operational"
|
|
} else {
|
|
"System"
|
|
};
|
|
let mut buttons = row![
|
|
action(
|
|
format!("Keep {destination}"),
|
|
Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))
|
|
),
|
|
action(
|
|
format!("Take {source}"),
|
|
Event::Decide(fact.selector.clone(), Some(SemanticDecision::TakeSource))
|
|
)
|
|
]
|
|
.spacing(5);
|
|
buttons = buttons.push(action(
|
|
"Set final value…",
|
|
Event::Decide(fact.selector.clone(), Some(SemanticDecision::Candidate)),
|
|
));
|
|
detail = detail.push(buttons).push(action(
|
|
"Leave unselected",
|
|
Event::Decide(fact.selector.clone(), None),
|
|
));
|
|
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
|
&& let Some(draft) = &self.draft
|
|
&& let Some(path) = self
|
|
.target(project)
|
|
.and_then(|id| scalar::binding(project, id, &fact.selector))
|
|
{
|
|
detail = detail.push(text("Final destination value").size(12)).push(
|
|
crate::forms::scoped(draft, project, &path, &title(project, &fact.selector))
|
|
.map(form_event),
|
|
);
|
|
}
|
|
} else if self.can_acknowledge_constructor(project, &fact.selector) {
|
|
detail = detail.push(text("Both constructor kinds are equal and have no prior observation. Acknowledge only the kind; its properties remain independent.").size(12))
|
|
.push(action("Acknowledge equal kind only", Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))))
|
|
.push(action("Leave unselected", Event::Decide(fact.selector.clone(), None)));
|
|
} else {
|
|
detail = detail.push(text(if scalar::grouped(&fact.selector) { "Constructor groups are shown for comparison. Group replacement and its complete descendant review are a later milestone." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED));
|
|
}
|
|
if matches!(
|
|
fact.selector,
|
|
FacetSelector::Child(_)
|
|
| FacetSelector::Incident { .. }
|
|
| FacetSelector::Artifact { .. }
|
|
) {
|
|
detail = detail.push(text("Attached facts are separate from referenced definitions. Removing membership never implies deleting a semantic object.").size(11).color(style::MUTED));
|
|
}
|
|
detail.into()
|
|
}
|
|
|
|
fn references_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
|
let mut content = column![
|
|
text("Reference correspondence · always Operational → System").size(15),
|
|
text("Choose explicit counterpart identities before taking reference values. Names do not establish correspondence. Changing an already reviewed keyed pair requires reset in the structured workflow.").size(12),
|
|
text_input("Filter reference choices by name or alias", &self.reference_search).on_input(|v| event(Event::ReferenceSearch(v))).padding(7).size(12),
|
|
row![
|
|
pick_list(self.all_references(project, Layer::Oa), self.reference_oa.clone(), |c| event(Event::ReferenceOa(c))).placeholder("Operational reference").padding(7).text_size(12).width(Fill),
|
|
pick_list(self.all_references(project, Layer::Sa), self.reference_sa.clone(), |c| event(Event::ReferenceSa(c))).placeholder("System reference").padding(7).text_size(12).width(Fill),
|
|
action("Use pair", Event::AddReference),
|
|
].spacing(7),
|
|
].spacing(9);
|
|
for (oa, sa) in &self.references {
|
|
content = content.push(
|
|
row![
|
|
text(format!(
|
|
"{}\n↔ {}",
|
|
choice(project, *oa).label,
|
|
choice(project, *sa).label
|
|
))
|
|
.size(12)
|
|
.width(Fill),
|
|
action("Remove pair", Event::RemoveReference(*oa))
|
|
]
|
|
.spacing(6),
|
|
);
|
|
}
|
|
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
|
|
content = content
|
|
.push(text("Referenced definitions · reviewed on their own traces").size(14));
|
|
match linked_semantic_impacts(project, trace, &self.references) {
|
|
Ok(impacts) => {
|
|
for impact in impacts {
|
|
content = content.push(
|
|
text(format!(
|
|
"{} ↔ {} · {}{} · {} trace(s)",
|
|
impact
|
|
.objects
|
|
.oa
|
|
.map(|id| choice(project, id).label)
|
|
.unwrap_or_else(|| "Absent".into()),
|
|
impact
|
|
.objects
|
|
.sa
|
|
.map(|id| choice(project, id).label)
|
|
.unwrap_or_else(|| "Absent".into()),
|
|
drift(impact.drift),
|
|
if impact.unreviewed {
|
|
" · incomplete review"
|
|
} else {
|
|
""
|
|
},
|
|
impact.traces.len()
|
|
))
|
|
.size(12),
|
|
);
|
|
}
|
|
}
|
|
Err(error) => content = content.push(text(error).size(12)),
|
|
}
|
|
}
|
|
scrollable(content).height(305).into()
|
|
}
|
|
|
|
fn impacts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
|
let mut content = column![text("Additional prepared changes").size(16), text("Accept each exact change required by model preparation. Acceptance never overrides a selected Keep or Take. Editing a decision clears these approvals.").size(12)].spacing(10);
|
|
if let Some(plan) = &self.plan {
|
|
if plan.impacts.is_empty() {
|
|
content =
|
|
content.push(text("No additional prepared changes in this preview.").size(12));
|
|
}
|
|
for (index, impact) in plan.impacts.iter().enumerate() {
|
|
let before = impact
|
|
.before
|
|
.as_ref()
|
|
.map(|r| serde_json::to_value(r).expect("Typed impact"));
|
|
let after = impact
|
|
.after
|
|
.as_ref()
|
|
.map(|r| serde_json::to_value(r).expect("Typed impact"));
|
|
let ids = project.identities().into_iter().map(|(id, _)| id).collect();
|
|
content = content.push(
|
|
container(
|
|
column![
|
|
checkbox(self.accepted_impacts.contains(impact))
|
|
.label(format!("Accept · {}", project.named(impact.object)))
|
|
.text_size(12)
|
|
.on_toggle(move |accepted| event(Event::AcceptImpact(
|
|
index, accepted
|
|
))),
|
|
text("Before").size(11).color(style::MUTED),
|
|
text(
|
|
before
|
|
.as_ref()
|
|
.map(|v| formatted(project, v, &ids))
|
|
.unwrap_or_else(|| "Absent · record creation".into())
|
|
)
|
|
.size(12),
|
|
text("After").size(11).color(style::MUTED),
|
|
text(
|
|
after
|
|
.as_ref()
|
|
.map(|v| formatted(project, v, &ids))
|
|
.unwrap_or_else(|| "Absent · record deletion".into())
|
|
)
|
|
.size(12),
|
|
]
|
|
.spacing(6),
|
|
)
|
|
.padding(10)
|
|
.style(style::subtle),
|
|
);
|
|
}
|
|
}
|
|
scrollable(content).height(305).into()
|
|
}
|
|
}
|