489 lines
18 KiB
Rust
489 lines
18 KiB
Rust
//! A prerequisite belongs to its pending consumer, never the accepted catalog.
|
|
use iced::{
|
|
Element as UiElement, Fill,
|
|
widget::{button, column, container, row, scrollable, text, text_input},
|
|
};
|
|
use oa_core::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::BTreeMap;
|
|
|
|
pub const NAME_INPUT: &str = "behavior-source-name";
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Scope {
|
|
Lifeline {
|
|
owner: Id,
|
|
viewpoint: ScenarioViewpoint,
|
|
},
|
|
Work {
|
|
owner: Id,
|
|
},
|
|
Machine {
|
|
layer: Layer,
|
|
},
|
|
}
|
|
impl Scope {
|
|
fn owner(self) -> Option<Id> {
|
|
match self {
|
|
Self::Lifeline { owner, .. } | Self::Work { owner } => Some(owner),
|
|
Self::Machine { .. } => None,
|
|
}
|
|
}
|
|
fn layer(self, p: &Project) -> Result<Layer, String> {
|
|
match self {
|
|
Self::Machine { layer } => Ok(layer),
|
|
_ => p
|
|
.layer_of(self.owner().unwrap())
|
|
.ok_or("The consumer owner no longer exists.".into()),
|
|
}
|
|
}
|
|
fn kinds(self, layer: Layer) -> Vec<Kind> {
|
|
match self {
|
|
Self::Work { .. } => vec![if layer == Layer::Oa {
|
|
Kind::Activity
|
|
} else {
|
|
Kind::Function
|
|
}],
|
|
Self::Lifeline {
|
|
viewpoint: ScenarioViewpoint::OperationalActivity,
|
|
..
|
|
} => vec![Kind::Activity],
|
|
Self::Lifeline {
|
|
viewpoint: ScenarioViewpoint::Functional,
|
|
..
|
|
} => vec![Kind::Function],
|
|
Self::Lifeline {
|
|
viewpoint: ScenarioViewpoint::ParticipantInteraction,
|
|
..
|
|
} => vec![Kind::Entity, Kind::Actor, Kind::Role],
|
|
Self::Lifeline {
|
|
viewpoint: ScenarioViewpoint::BoundaryInterface,
|
|
..
|
|
} => vec![Kind::System, Kind::SystemActor],
|
|
Self::Machine { .. } if layer == Layer::Oa => vec![Kind::Entity, Kind::Actor],
|
|
Self::Machine { .. } => vec![Kind::System, Kind::SystemActor],
|
|
_ => vec![],
|
|
}
|
|
}
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub enum Event {
|
|
Open(Id),
|
|
Edit(Id, Edit),
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub enum Edit {
|
|
Name(String),
|
|
Kind(Kind),
|
|
Parent(Option<Id>),
|
|
Search(String),
|
|
Page(usize),
|
|
Back,
|
|
Use,
|
|
}
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct Draft {
|
|
token: Id,
|
|
document: Id,
|
|
scope: Scope,
|
|
layer: Layer,
|
|
owner: Option<Element>,
|
|
id: Id,
|
|
kind: Option<Kind>,
|
|
name: String,
|
|
parent: Option<Element>,
|
|
search: String,
|
|
page: usize,
|
|
identities: Vec<Id>,
|
|
}
|
|
#[derive(Clone, Default, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct State {
|
|
editing: Option<Draft>,
|
|
staged: Option<Draft>,
|
|
#[serde(default)]
|
|
engaged: bool,
|
|
}
|
|
impl State {
|
|
pub fn check_scope(&self, expected: Scope, selected: Option<Id>) -> Result<(), String> {
|
|
if self
|
|
.editing
|
|
.as_ref()
|
|
.or_else(|| self.staged.as_ref().filter(|d| Some(d.id) == selected))
|
|
.is_some_and(|d| d.scope != expected)
|
|
{
|
|
return Err("The recovered source request belongs to a different consumer context. Go Back or cancel this draft.".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
pub fn dismiss(&mut self, event: &Event) -> Result<bool, String> {
|
|
if let Event::Edit(token, Edit::Back) = event {
|
|
if self.editing.as_ref().is_none_or(|d| d.token != *token) {
|
|
return Err("This Back action belongs to a previous source form.".into());
|
|
}
|
|
self.editing = None;
|
|
self.engaged = true;
|
|
return Ok(true);
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
pub fn engaged(&self) -> bool {
|
|
self.engaged || self.editing.is_some() || self.staged.is_some()
|
|
}
|
|
pub fn is_open(&self) -> bool {
|
|
self.editing.is_some()
|
|
}
|
|
pub fn back(&mut self) -> bool {
|
|
self.editing.take().is_some()
|
|
}
|
|
pub fn label(&self, selected: Option<Id>) -> Option<String> {
|
|
self.staged
|
|
.as_ref()
|
|
.filter(|d| Some(d.id) == selected)
|
|
.map(|d| {
|
|
format!(
|
|
"New · {} · {}",
|
|
d.kind.map_or("Choose kind".into(), |k| k.to_string()),
|
|
d.name
|
|
)
|
|
})
|
|
}
|
|
pub fn update(
|
|
&mut self,
|
|
p: &Project,
|
|
scope: Scope,
|
|
invocation: Id,
|
|
event: Event,
|
|
) -> Result<Option<Id>, String> {
|
|
if self.dismiss(&event)? {
|
|
return Ok(None);
|
|
}
|
|
match event {
|
|
Event::Open(token) => {
|
|
if token != invocation || self.is_open() {
|
|
return Err("This source request is stale. Use the current form.".into());
|
|
}
|
|
let layer = scope.layer(p)?;
|
|
let kinds = scope.kinds(layer);
|
|
if kinds.is_empty() {
|
|
return Err("Create new sources requires a strict scenario viewpoint; use an existing source here.".into());
|
|
}
|
|
self.engaged = true;
|
|
self.editing = Some(Draft {
|
|
token: Id::new_v4(),
|
|
document: p.id,
|
|
scope,
|
|
layer,
|
|
owner: scope.owner().and_then(|id| p.elements.get(&id).cloned()),
|
|
id: Id::new_v4(),
|
|
kind: (kinds.len() == 1).then_some(kinds[0]),
|
|
name: String::new(),
|
|
parent: None,
|
|
search: String::new(),
|
|
page: 0,
|
|
identities: (0..match scope {
|
|
Scope::Work { owner } => p.children(owner).count() + 4,
|
|
Scope::Lifeline { .. } => 1,
|
|
Scope::Machine { .. } => 0,
|
|
})
|
|
.map(|_| Id::new_v4())
|
|
.collect(),
|
|
});
|
|
Ok(None)
|
|
}
|
|
Event::Edit(token, edit) => {
|
|
let d = self
|
|
.editing
|
|
.as_mut()
|
|
.filter(|d| d.token == token && d.scope == scope && d.document == p.id)
|
|
.ok_or("This source response is stale; return to the current form.")?;
|
|
match edit {
|
|
Edit::Back => {
|
|
self.editing = None;
|
|
Ok(None)
|
|
}
|
|
Edit::Use => {
|
|
d.command(p)?;
|
|
let id = d.id;
|
|
self.staged = self.editing.take();
|
|
Ok(Some(id))
|
|
}
|
|
Edit::Name(name) => {
|
|
d.name = name;
|
|
Ok(None)
|
|
}
|
|
Edit::Kind(kind) => {
|
|
if !scope.kinds(d.layer).contains(&kind) {
|
|
return Err("Choose an explicitly supported source kind.".into());
|
|
}
|
|
d.kind = Some(kind);
|
|
d.parent = None;
|
|
d.page = 0;
|
|
Ok(None)
|
|
}
|
|
Edit::Parent(parent) => {
|
|
let kind = d.kind.ok_or("Choose the source kind first.")?;
|
|
if !validation::can_parent(p, kind, d.layer, parent) {
|
|
return Err("Choose a valid semantic parent.".into());
|
|
}
|
|
d.parent = parent.map(|id| p.elements[&id].clone());
|
|
Ok(None)
|
|
}
|
|
Edit::Search(query) => {
|
|
d.search = query;
|
|
d.page = 0;
|
|
Ok(None)
|
|
}
|
|
Edit::Page(page) => {
|
|
d.page = page;
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
pub fn preview(&self, p: &Project, selected: Option<Id>) -> Result<Option<Project>, String> {
|
|
if self.is_open() {
|
|
return Err("Use this new source or go Back before creating the consumer.".into());
|
|
}
|
|
self.staged
|
|
.as_ref()
|
|
.filter(|d| Some(d.id) == selected)
|
|
.map(|d| execute_batch(p, &[d.command(p)?]).map_err(|e| e.to_string()))
|
|
.transpose()
|
|
}
|
|
pub fn combine(
|
|
&self,
|
|
p: &Project,
|
|
selected: Option<Id>,
|
|
command: Command,
|
|
) -> Result<Command, String> {
|
|
let Some(d) = self.staged.as_ref().filter(|d| Some(d.id) == selected) else {
|
|
return Ok(command);
|
|
};
|
|
let preview = execute_batch(p, &[d.command(p)?]).map_err(|e| e.to_string())?;
|
|
let mut commands = match command {
|
|
Command::Batch(commands) => commands,
|
|
other => vec![other],
|
|
};
|
|
let fresh: Vec<_> = commands
|
|
.iter()
|
|
.filter(|_| !matches!(d.scope, Scope::Machine { .. }))
|
|
.filter_map(|c| match c {
|
|
Command::CreateElement(e) => Some(e.id),
|
|
Command::CreateRelation(r) => Some(r.id),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
if fresh.len() > d.identities.len() {
|
|
return Err(
|
|
"The path grew beyond this draft. Return and create the source again.".into(),
|
|
);
|
|
}
|
|
let replacements: BTreeMap<_, _> = fresh
|
|
.into_iter()
|
|
.zip(d.identities.iter().copied())
|
|
.collect();
|
|
if replacements.values().any(|id| preview.contains(*id)) {
|
|
return Err(
|
|
"A reserved consumer identity is already used. Start a fresh source draft.".into(),
|
|
);
|
|
}
|
|
let remap = |id: &mut Id| {
|
|
if let Some(new) = replacements.get(id) {
|
|
*id = *new;
|
|
}
|
|
};
|
|
for c in &mut commands {
|
|
match c {
|
|
Command::CreateElement(e) => {
|
|
remap(&mut e.id);
|
|
references::visit_element_refs(e, remap);
|
|
e.alias = preview.fresh_alias(&e.name, "contribution", e.id);
|
|
}
|
|
Command::CreateRelation(r) => {
|
|
remap(&mut r.id);
|
|
references::visit_relation_refs(r, remap);
|
|
r.alias = preview.fresh_alias(&r.name, "path", r.id);
|
|
}
|
|
Command::EditRelation(_, r) => references::visit_relation_refs(r, remap),
|
|
Command::SetSteps(_, steps) => steps.iter_mut().for_each(remap),
|
|
// Machine commands already carry their reserved identities.
|
|
Command::CreateDiagram(_) | Command::Layout(_) => {}
|
|
_ => return Err("Unsupported prerequisite consumer operation.".into()),
|
|
}
|
|
}
|
|
commands.insert(0, d.command(p)?);
|
|
let command = super::topology::unique_batch(p, commands);
|
|
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
|
Ok(command)
|
|
}
|
|
pub fn view<'a, R: iced::advanced::text::Renderer + 'a>(
|
|
&'a self,
|
|
p: &'a Project,
|
|
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
|
|
let d = self.editing.as_ref()?;
|
|
let event = move |edit| Event::Edit(d.token, edit);
|
|
let mut fields = column![
|
|
text(format!("New source · {}", d.layer)).size(18),
|
|
text(match d.scope {
|
|
Scope::Lifeline { owner, viewpoint } =>
|
|
format!("For a lifeline in {} · {:?}", p.named(owner), viewpoint),
|
|
Scope::Work { owner } => format!("For a contribution in {}", p.named(owner)),
|
|
Scope::Machine { .. } => "Owner for the pending state machine".into(),
|
|
})
|
|
.size(12),
|
|
text("The parent form is retained. This source is created only with its consumer.")
|
|
.size(12)
|
|
]
|
|
.spacing(8);
|
|
for kind in d.scope.kinds(d.layer) {
|
|
fields = fields.push(
|
|
button(text(kind.to_string()))
|
|
.on_press(event(Edit::Kind(kind)))
|
|
.style(if d.kind == Some(kind) {
|
|
button::primary
|
|
} else {
|
|
button::secondary
|
|
}),
|
|
);
|
|
}
|
|
fields = fields.push(
|
|
text_input("Source name (independent of the consumer name)", &d.name)
|
|
.id(NAME_INPUT)
|
|
.on_input(move |v| event(Edit::Name(v)))
|
|
.padding(8),
|
|
);
|
|
fields = fields.push(
|
|
text(format!(
|
|
"Semantic parent: {}",
|
|
d.parent
|
|
.as_ref()
|
|
.map_or(format!("{} layer root", d.layer), |e| format!(
|
|
"{} · {} · @{}",
|
|
e.name,
|
|
e.kind(),
|
|
e.alias
|
|
))
|
|
))
|
|
.size(12),
|
|
);
|
|
if let Some(kind) = d.kind {
|
|
fields = fields.push(button("Use layer root").on_press_maybe(
|
|
validation::can_parent(p, kind, d.layer, None).then(|| event(Edit::Parent(None))),
|
|
));
|
|
fields = fields.push(
|
|
text_input("Find a semantic parent (optional)", &d.search)
|
|
.on_input(move |v| event(Edit::Search(v)))
|
|
.padding(6),
|
|
);
|
|
let query = d.search.to_lowercase();
|
|
let mut parents: Vec<_> = p
|
|
.elements
|
|
.values()
|
|
.filter(|e| validation::can_parent(p, kind, d.layer, Some(e.id)))
|
|
.map(|e| (e.id, super::creation::reference_label(p, e.id)))
|
|
.filter(|(_, label)| label.to_lowercase().contains(&query))
|
|
.collect();
|
|
parents.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
|
|
let page = d.page.min(parents.len().saturating_sub(1) / 6);
|
|
for (id, label) in parents.iter().skip(page * 6).take(6) {
|
|
fields = fields.push(
|
|
button(text(label.clone()).size(12))
|
|
.on_press(event(Edit::Parent(Some(*id))))
|
|
.width(Fill),
|
|
);
|
|
}
|
|
if parents.len() > 6 {
|
|
fields = fields.push(
|
|
row![
|
|
button("Previous").on_press_maybe(
|
|
(page > 0).then(|| event(Edit::Page(page.saturating_sub(1))))
|
|
),
|
|
text(format!("{} / {}", page + 1, parents.len().div_ceil(6))),
|
|
button("Next").on_press_maybe(
|
|
((page + 1) * 6 < parents.len()).then(|| event(Edit::Page(page + 1)))
|
|
)
|
|
]
|
|
.spacing(6),
|
|
);
|
|
}
|
|
}
|
|
let ready = d.command(p);
|
|
if let Err(error) = &ready {
|
|
fields = fields.push(text(error.clone()).size(12));
|
|
}
|
|
Some(
|
|
container(
|
|
column![
|
|
scrollable(fields).id("behavior-source-fields").height(Fill),
|
|
container(
|
|
row![
|
|
button("Back · Esc").on_press(event(Edit::Back)),
|
|
button("Use this new source")
|
|
.on_press_maybe(ready.is_ok().then(|| event(Edit::Use)))
|
|
]
|
|
.spacing(8)
|
|
)
|
|
.id("behavior-source-actions")
|
|
]
|
|
.spacing(10),
|
|
)
|
|
.padding(12)
|
|
.height(Fill)
|
|
.width(Fill)
|
|
.into(),
|
|
)
|
|
}
|
|
}
|
|
impl Draft {
|
|
fn command(&self, p: &Project) -> Result<Command, String> {
|
|
if self.document != p.id
|
|
|| self.scope.layer(p)? != self.layer
|
|
|| self
|
|
.owner
|
|
.as_ref()
|
|
.is_some_and(|e| p.elements.get(&e.id) != Some(e))
|
|
{
|
|
return Err(
|
|
"The source request's document or consumer changed. Go Back and reopen Create new."
|
|
.into(),
|
|
);
|
|
}
|
|
let kind = self
|
|
.kind
|
|
.filter(|kind| self.scope.kinds(self.layer).contains(kind))
|
|
.ok_or("Choose the source kind explicitly.")?;
|
|
if self.name.trim().is_empty() {
|
|
return Err("Enter the source name.".into());
|
|
}
|
|
if p.contains(self.id) {
|
|
return Err(
|
|
"The reserved source identity is already used. Go Back and start again.".into(),
|
|
);
|
|
}
|
|
let parent = self.parent.as_ref().map(|e| e.id);
|
|
if self
|
|
.parent
|
|
.as_ref()
|
|
.is_some_and(|e| p.elements.get(&e.id) != Some(e))
|
|
|| !validation::can_parent(p, kind, self.layer, parent)
|
|
{
|
|
return Err("The semantic parent changed. Choose its intended parent again.".into());
|
|
}
|
|
let e = Element {
|
|
id: self.id,
|
|
alias: p.fresh_alias(&self.name, "source", self.id),
|
|
name: self.name.clone(),
|
|
description: String::new(),
|
|
parent,
|
|
body: ElementBody::new(kind, self.layer)
|
|
.ok_or("This source kind is unavailable in this layer.")?,
|
|
};
|
|
let command = Command::CreateElement(e);
|
|
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
|
Ok(command)
|
|
}
|
|
}
|