1035 lines
39 KiB
Rust
1035 lines
39 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 kinds = d.scope.kinds(d.layer);
|
|
let determined = kinds.len() == 1 && d.kind == kinds.first().copied();
|
|
let mut fields = column![
|
|
text(if determined {
|
|
format!("New {} · {}", kinds[0], d.layer)
|
|
} else {
|
|
format!("New source · {}", d.layer)
|
|
})
|
|
.size(14),
|
|
text_input("Source name (independent of the consumer name)", &d.name)
|
|
.id(NAME_INPUT)
|
|
.on_input(move |v| event(Edit::Name(v)))
|
|
.size(14)
|
|
.padding(6),
|
|
]
|
|
.spacing(4);
|
|
if !determined {
|
|
let mut choices = row![].spacing(6);
|
|
for kind in kinds {
|
|
choices = choices.push(
|
|
container(
|
|
button(text(kind.to_string()).size(14))
|
|
.padding(4)
|
|
.on_press(event(Edit::Kind(kind)))
|
|
.style(if d.kind == Some(kind) {
|
|
button::primary
|
|
} else {
|
|
button::secondary
|
|
}),
|
|
)
|
|
.id(format!("behavior-source-kind-{kind:?}")),
|
|
);
|
|
}
|
|
fields = fields.push(choices.wrap());
|
|
}
|
|
fields = fields.push(
|
|
container(
|
|
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),
|
|
)
|
|
.id("behavior-source-parent"),
|
|
);
|
|
fields = fields
|
|
.push(
|
|
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),
|
|
)
|
|
.push(
|
|
text("The parent form is retained. This source is created only with its consumer.")
|
|
.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)
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, debug_assertions))]
|
|
mod layout_tests {
|
|
use super::*;
|
|
use iced::advanced::{Layout, Shell, graphics, layout, renderer, text as shaped, widget};
|
|
use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector};
|
|
|
|
// Use Iced's real paragraph shaping/measurement without a window or GPU.
|
|
// The unit renderer's zero-size paragraphs cannot detect this regression.
|
|
struct FontLayout;
|
|
impl renderer::Renderer for FontLayout {
|
|
fn start_layer(&mut self, _: Rectangle) {}
|
|
fn end_layer(&mut self) {}
|
|
fn start_transformation(&mut self, _: Transformation) {}
|
|
fn end_transformation(&mut self) {}
|
|
fn reset(&mut self, _: Rectangle) {}
|
|
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
|
|
fn allocate_image(
|
|
&mut self,
|
|
handle: &iced::advanced::image::Handle,
|
|
callback: impl FnOnce(
|
|
Result<iced::advanced::image::Allocation, iced::advanced::image::Error>,
|
|
) + Send
|
|
+ 'static,
|
|
) {
|
|
renderer::Renderer::allocate_image(&mut (), handle, callback);
|
|
}
|
|
}
|
|
impl shaped::Renderer for FontLayout {
|
|
type Font = Font;
|
|
type Paragraph = graphics::text::Paragraph;
|
|
type Editor = ();
|
|
const ICON_FONT: Font = Font::DEFAULT;
|
|
const CHECKMARK_ICON: char = '0';
|
|
const ARROW_DOWN_ICON: char = '0';
|
|
const SCROLL_UP_ICON: char = '0';
|
|
const SCROLL_DOWN_ICON: char = '0';
|
|
const SCROLL_LEFT_ICON: char = '0';
|
|
const SCROLL_RIGHT_ICON: char = '0';
|
|
const ICED_LOGO: char = '0';
|
|
fn default_font(&self) -> Font {
|
|
Font::DEFAULT
|
|
}
|
|
fn default_size(&self) -> Pixels {
|
|
Pixels(16.)
|
|
}
|
|
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
|
|
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
|
|
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
|
|
}
|
|
#[derive(Default)]
|
|
struct Geometry {
|
|
fields: Option<Rectangle>,
|
|
content: Option<Rectangle>,
|
|
name: Option<Rectangle>,
|
|
parent: Option<Rectangle>,
|
|
actions: Option<Rectangle>,
|
|
kinds: BTreeMap<String, Rectangle>,
|
|
text: Vec<(String, Rectangle)>,
|
|
focus_name: bool,
|
|
}
|
|
impl widget::Operation for Geometry {
|
|
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
|
visit(self);
|
|
}
|
|
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
|
if id == Some(&widget::Id::new("behavior-source-actions")) {
|
|
self.actions = Some(bounds);
|
|
}
|
|
if id == Some(&widget::Id::new("behavior-source-parent")) {
|
|
self.parent = Some(bounds);
|
|
}
|
|
for kind in [
|
|
Kind::Entity,
|
|
Kind::Actor,
|
|
Kind::Role,
|
|
Kind::System,
|
|
Kind::SystemActor,
|
|
Kind::Activity,
|
|
Kind::Function,
|
|
] {
|
|
if id == Some(&widget::Id::from(format!("behavior-source-kind-{kind:?}"))) {
|
|
self.kinds.insert(kind.to_string(), bounds);
|
|
}
|
|
}
|
|
}
|
|
fn scrollable(
|
|
&mut self,
|
|
id: Option<&widget::Id>,
|
|
bounds: Rectangle,
|
|
content: Rectangle,
|
|
offset: Vector,
|
|
_: &mut dyn widget::operation::Scrollable,
|
|
) {
|
|
if id == Some(&widget::Id::new("behavior-source-fields")) {
|
|
assert_eq!(
|
|
offset,
|
|
Vector::ZERO,
|
|
"Initial fields must be visible without scrolling"
|
|
);
|
|
self.fields = Some(bounds);
|
|
self.content = Some(content);
|
|
}
|
|
}
|
|
fn text_input(
|
|
&mut self,
|
|
id: Option<&widget::Id>,
|
|
bounds: Rectangle,
|
|
_: &mut dyn widget::operation::TextInput,
|
|
) {
|
|
if id == Some(&widget::Id::new(NAME_INPUT)) {
|
|
self.name = Some(bounds);
|
|
}
|
|
}
|
|
fn focusable(
|
|
&mut self,
|
|
id: Option<&widget::Id>,
|
|
_: Rectangle,
|
|
state: &mut dyn widget::operation::Focusable,
|
|
) {
|
|
if self.focus_name && id == Some(&widget::Id::new(NAME_INPUT)) {
|
|
state.focus();
|
|
}
|
|
}
|
|
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
|
|
self.text.push((text.into(), bounds));
|
|
}
|
|
}
|
|
fn contains(outer: Rectangle, inner: Rectangle) -> bool {
|
|
inner.width > 0.
|
|
&& inner.height > 0.
|
|
&& inner.x >= outer.x - 0.1
|
|
&& inner.y >= outer.y - 0.1
|
|
&& inner.x + inner.width <= outer.x + outer.width + 0.1
|
|
&& inner.y + inner.height <= outer.y + outer.height + 0.1
|
|
}
|
|
fn host<'a>(
|
|
state: &'a State,
|
|
p: &'a Project,
|
|
caption: &str,
|
|
) -> UiElement<'a, Event, iced::Theme, FontLayout> {
|
|
iced::widget::column![text(caption.to_owned()).size(12), state.view(p).unwrap()]
|
|
.spacing(6)
|
|
.height(Fill)
|
|
.width(Fill)
|
|
.into()
|
|
}
|
|
fn geometry(
|
|
view: &mut UiElement<'_, Event, iced::Theme, FontLayout>,
|
|
tree: &mut widget::Tree,
|
|
size: Size,
|
|
) -> (layout::Node, Geometry) {
|
|
let node =
|
|
view.as_widget_mut()
|
|
.layout(tree, &FontLayout, &layout::Limits::new(Size::ZERO, size));
|
|
let mut geometry = Geometry::default();
|
|
view.as_widget_mut()
|
|
.operate(tree, Layout::new(&node), &FontLayout, &mut geometry);
|
|
(node, geometry)
|
|
}
|
|
fn fixture(layer: Layer, viewpoint: Option<ScenarioViewpoint>, work: bool) -> (Project, State) {
|
|
let mut p = Project::empty();
|
|
let scope = if work {
|
|
let owner = p
|
|
.add_element(
|
|
if layer == Layer::Oa {
|
|
Kind::Process
|
|
} else {
|
|
Kind::FunctionalChain
|
|
},
|
|
layer,
|
|
"Consumer",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
Scope::Work { owner }
|
|
} else if let Some(viewpoint) = viewpoint {
|
|
let owner = p
|
|
.add_element(
|
|
Kind::Scenario,
|
|
layer,
|
|
"Incident coordination with a long contextual heading 雨 Ω",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
if let Some(SharedBody::Scenario { viewpoint: v, .. }) =
|
|
p.elements.get_mut(&owner).unwrap().shared_mut()
|
|
{
|
|
*v = viewpoint;
|
|
}
|
|
Scope::Lifeline { owner, viewpoint }
|
|
} else {
|
|
Scope::Machine { layer }
|
|
};
|
|
let mut state = State::default();
|
|
let invocation = Id::new_v4();
|
|
state
|
|
.update(&p, scope, invocation, Event::Open(invocation))
|
|
.unwrap();
|
|
(p, state)
|
|
}
|
|
#[test]
|
|
fn contextual_source_layout_initial_essentials_fit_short_composed_host_with_real_paragraphs() {
|
|
for (layer, viewpoint, work) in [
|
|
(
|
|
Layer::Oa,
|
|
Some(ScenarioViewpoint::OperationalActivity),
|
|
false,
|
|
),
|
|
(Layer::Sa, Some(ScenarioViewpoint::Functional), false),
|
|
(
|
|
Layer::Oa,
|
|
Some(ScenarioViewpoint::ParticipantInteraction),
|
|
false,
|
|
),
|
|
(Layer::Sa, Some(ScenarioViewpoint::BoundaryInterface), false),
|
|
(Layer::Oa, None, false),
|
|
(Layer::Sa, None, false),
|
|
(Layer::Oa, None, true),
|
|
(Layer::Sa, None, true),
|
|
] {
|
|
let (p, state) = fixture(layer, viewpoint, work);
|
|
let raw = serde_json::to_value(&state).unwrap();
|
|
for width in [340., 500.] {
|
|
// The retained caption + 6px gap leaves approximately the observed 194px card.
|
|
let size = Size::new(width, 215.);
|
|
let mut view = host(&state, &p, "Consumer name: Assessment lifeline");
|
|
let mut tree = widget::Tree::new(view.as_widget());
|
|
let (_, g) = geometry(&mut view, &mut tree, size);
|
|
let fields = g.fields.unwrap();
|
|
assert!(
|
|
(125. ..=132.).contains(&fields.height),
|
|
"Measured field viewport: {fields:?}"
|
|
);
|
|
assert!(
|
|
contains(fields, g.name.unwrap()),
|
|
"Name clipped: {layer:?}/{viewpoint:?}/{work}/{width}"
|
|
);
|
|
assert!(
|
|
contains(fields, g.parent.unwrap()),
|
|
"Parent clipped: {layer:?}/{viewpoint:?}/{work}/{width}"
|
|
);
|
|
assert!(contains(
|
|
Rectangle::new(Point::ORIGIN, size),
|
|
g.actions.unwrap()
|
|
));
|
|
assert!(fields.y + fields.height <= g.actions.unwrap().y);
|
|
let expected = state.editing.as_ref().unwrap().scope.kinds(layer);
|
|
assert_eq!(
|
|
g.kinds.len(),
|
|
if expected.len() == 1 {
|
|
0
|
|
} else {
|
|
expected.len()
|
|
}
|
|
);
|
|
for bounds in g.kinds.values() {
|
|
assert!(
|
|
contains(fields, *bounds),
|
|
"Kind clipped: {bounds:?} in {fields:?}"
|
|
);
|
|
}
|
|
let parent_text = g
|
|
.text
|
|
.iter()
|
|
.find(|(s, _)| s.starts_with("Semantic parent:"))
|
|
.unwrap();
|
|
assert_eq!(
|
|
parent_text.0,
|
|
format!("Semantic parent: {layer} layer root")
|
|
);
|
|
assert!(
|
|
parent_text.1.height > 0.,
|
|
"Paragraph measurement must not be null"
|
|
);
|
|
let explanation = g
|
|
.text
|
|
.iter()
|
|
.find(|(s, _)| s.starts_with("The parent form"))
|
|
.unwrap();
|
|
assert!(explanation.1.y >= g.parent.unwrap().y + g.parent.unwrap().height);
|
|
}
|
|
assert_eq!(
|
|
serde_json::to_value(&state).unwrap(),
|
|
raw,
|
|
"Layout must not choose a kind or mutate the draft"
|
|
);
|
|
if state.editing.as_ref().unwrap().scope.kinds(layer).len() == 1 {
|
|
let mut incomplete = state.clone();
|
|
incomplete.editing.as_mut().unwrap().kind = None;
|
|
let mut view = host(&incomplete, &p, "Consumer name: Assessment lifeline");
|
|
let mut tree = widget::Tree::new(view.as_widget());
|
|
let (_, g) = geometry(&mut view, &mut tree, Size::new(340., 215.));
|
|
assert_eq!(
|
|
g.kinds.len(),
|
|
1,
|
|
"An incomplete recovery still needs an explicit repair choice"
|
|
);
|
|
assert!(contains(
|
|
g.fields.unwrap(),
|
|
*g.kinds.values().next().unwrap()
|
|
));
|
|
assert!(contains(g.fields.unwrap(), g.name.unwrap()));
|
|
assert_eq!(incomplete.editing.as_ref().unwrap().kind, None);
|
|
}
|
|
}
|
|
}
|
|
#[test]
|
|
fn contextual_source_layout_long_parent_wraps_after_visible_name_without_losing_raw_data() {
|
|
let (mut p, mut state) = fixture(Layer::Oa, None, true);
|
|
let parent = p
|
|
.add_element(
|
|
Kind::Activity,
|
|
Layer::Oa,
|
|
"Northern operations coordination — exact parent 雨",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let d = state.editing.as_ref().unwrap();
|
|
let token = d.token;
|
|
let scope = d.scope;
|
|
let invocation = Id::new_v4();
|
|
state
|
|
.update(
|
|
&p,
|
|
scope,
|
|
invocation,
|
|
Event::Edit(token, Edit::Parent(Some(parent))),
|
|
)
|
|
.unwrap();
|
|
state
|
|
.update(
|
|
&p,
|
|
scope,
|
|
invocation,
|
|
Event::Edit(token, Edit::Name(" Distinct source 雨 ".into())),
|
|
)
|
|
.unwrap();
|
|
let raw = serde_json::to_value(&state).unwrap();
|
|
for width in [340., 500.] {
|
|
let size = Size::new(width, 235.);
|
|
let mut view = host(
|
|
&state,
|
|
&p,
|
|
"Consumer name: Coordination with a long independent label 雨 Ω",
|
|
);
|
|
let mut tree = widget::Tree::new(view.as_widget());
|
|
let (_, g) = geometry(&mut view, &mut tree, size);
|
|
assert!(contains(g.fields.unwrap(), g.name.unwrap()));
|
|
assert!(contains(g.fields.unwrap(), g.parent.unwrap()));
|
|
assert!(contains(
|
|
Rectangle::new(Point::ORIGIN, size),
|
|
g.actions.unwrap()
|
|
));
|
|
let summary = g
|
|
.text
|
|
.iter()
|
|
.find(|(s, _)| s.starts_with("Semantic parent:"))
|
|
.unwrap();
|
|
assert!(summary.0.contains(&p.elements[&parent].name));
|
|
assert!(summary.0.contains(&p.elements[&parent].alias));
|
|
assert!(
|
|
summary.1.height > 15.,
|
|
"Long parent should exercise real wrapping"
|
|
);
|
|
}
|
|
assert_eq!(serde_json::to_value(&state).unwrap(), raw);
|
|
assert_eq!(
|
|
state.editing.as_ref().unwrap().name,
|
|
" Distinct source 雨 "
|
|
);
|
|
|
|
let (mut p, mut state) = fixture(
|
|
Layer::Oa,
|
|
Some(ScenarioViewpoint::ParticipantInteraction),
|
|
false,
|
|
);
|
|
let parent = p
|
|
.add_element(
|
|
Kind::Entity,
|
|
Layer::Oa,
|
|
// Eight repetitions need not exceed a whole viewport with the
|
|
// platform's measured font. Exercise substantial real overflow.
|
|
&"Northern operational responsibility 雨 ".repeat(32),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let d = state.editing.as_ref().unwrap();
|
|
let token = d.token;
|
|
let scope = d.scope;
|
|
state
|
|
.update(
|
|
&p,
|
|
scope,
|
|
Id::new_v4(),
|
|
Event::Edit(token, Edit::Kind(Kind::Actor)),
|
|
)
|
|
.unwrap();
|
|
state
|
|
.update(
|
|
&p,
|
|
scope,
|
|
Id::new_v4(),
|
|
Event::Edit(token, Edit::Parent(Some(parent))),
|
|
)
|
|
.unwrap();
|
|
let raw = serde_json::to_value(&state).unwrap();
|
|
let recovered: State = serde_json::from_value(raw.clone()).unwrap();
|
|
let mut view = host(&recovered, &p, "Consumer name: Assessment lifeline");
|
|
let mut tree = widget::Tree::new(view.as_widget());
|
|
let (_, g) = geometry(&mut view, &mut tree, Size::new(340., 215.));
|
|
let fields = g.fields.unwrap();
|
|
assert!(contains(fields, g.name.unwrap()));
|
|
assert_eq!(g.kinds.len(), 3);
|
|
for bounds in g.kinds.values() {
|
|
assert!(
|
|
contains(fields, *bounds),
|
|
"Long parent must not hide a semantic choice"
|
|
);
|
|
assert!(bounds.y + bounds.height <= g.parent.unwrap().y);
|
|
}
|
|
// Arbitrary authored detail may scroll, but its label and first line are
|
|
// visible after the required kind controls, never in front of them.
|
|
let parent_bounds = g.parent.unwrap();
|
|
assert!(parent_bounds.y + 16. <= fields.y + fields.height);
|
|
assert!(
|
|
parent_bounds.height > fields.height,
|
|
"Exercise genuinely overflowing parent text: {parent_bounds:?} in {fields:?}"
|
|
);
|
|
let content = g.content.unwrap();
|
|
assert!(
|
|
content.height > fields.height && contains(content, parent_bounds),
|
|
"Full parent must remain in scrollable content: {parent_bounds:?}, {content:?}, {fields:?}"
|
|
);
|
|
let summary = g
|
|
.text
|
|
.iter()
|
|
.find(|(s, _)| s.starts_with("Semantic parent:"))
|
|
.unwrap();
|
|
assert!(summary.0.contains(&p.elements[&parent].name));
|
|
assert!(summary.0.contains(&p.elements[&parent].alias));
|
|
assert!(contains(
|
|
Rectangle::new(Point::ORIGIN, Size::new(340., 215.)),
|
|
g.actions.unwrap()
|
|
));
|
|
assert_eq!(serde_json::to_value(&recovered).unwrap(), raw);
|
|
}
|
|
#[test]
|
|
fn contextual_source_layout_visible_kind_click_keeps_choice_explicit_and_name_input_works() {
|
|
let (p, mut state) = fixture(
|
|
Layer::Oa,
|
|
Some(ScenarioViewpoint::ParticipantInteraction),
|
|
false,
|
|
);
|
|
let original = p.clone();
|
|
let token = state.editing.as_ref().unwrap().token;
|
|
let scope = state.editing.as_ref().unwrap().scope;
|
|
assert_eq!(state.editing.as_ref().unwrap().kind, None);
|
|
let size = Size::new(340., 215.);
|
|
let mut view = host(&state, &p, "Consumer name: Assessment lifeline");
|
|
let mut tree = widget::Tree::new(view.as_widget());
|
|
let (node, g) = geometry(&mut view, &mut tree, size);
|
|
let mut messages = Vec::new();
|
|
for kind in [Kind::Entity, Kind::Actor, Kind::Role] {
|
|
let bounds = g.kinds[&kind.to_string()];
|
|
assert!(contains(g.fields.unwrap(), bounds));
|
|
for action in [
|
|
iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left),
|
|
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left),
|
|
] {
|
|
view.as_widget_mut().update(
|
|
&mut tree,
|
|
&iced::Event::Mouse(action),
|
|
Layout::new(&node),
|
|
iced::mouse::Cursor::Available(bounds.center()),
|
|
&FontLayout,
|
|
&mut iced::advanced::clipboard::Null,
|
|
&mut Shell::new(&mut messages),
|
|
&Rectangle::new(Point::ORIGIN, size),
|
|
);
|
|
}
|
|
assert!(
|
|
matches!(messages.pop(), Some(Event::Edit(id, Edit::Kind(k))) if id == token && k == kind)
|
|
);
|
|
}
|
|
let mut focus = Geometry {
|
|
focus_name: true,
|
|
..Geometry::default()
|
|
};
|
|
view.as_widget_mut()
|
|
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut focus);
|
|
let event = iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
|
key: iced::keyboard::Key::Character("Ω".into()),
|
|
modified_key: iced::keyboard::Key::Character("Ω".into()),
|
|
physical_key: iced::keyboard::key::Physical::Code(iced::keyboard::key::Code::KeyA),
|
|
location: iced::keyboard::Location::Standard,
|
|
modifiers: iced::keyboard::Modifiers::empty(),
|
|
text: Some("Ω".into()),
|
|
repeat: false,
|
|
});
|
|
view.as_widget_mut().update(
|
|
&mut tree,
|
|
&event,
|
|
Layout::new(&node),
|
|
iced::mouse::Cursor::Unavailable,
|
|
&FontLayout,
|
|
&mut iced::advanced::clipboard::Null,
|
|
&mut Shell::new(&mut messages),
|
|
&Rectangle::new(Point::ORIGIN, size),
|
|
);
|
|
let edit = messages.pop().unwrap();
|
|
assert!(matches!(&edit, Event::Edit(id, Edit::Name(name)) if *id == token && name == "Ω"));
|
|
drop(view);
|
|
state.update(&p, scope, Id::new_v4(), edit).unwrap();
|
|
assert_eq!(
|
|
state.editing.as_ref().unwrap().kind,
|
|
None,
|
|
"Rendering/click event production must not silently adopt a kind"
|
|
);
|
|
assert_eq!(state.editing.as_ref().unwrap().name, "Ω");
|
|
assert_eq!(p, original);
|
|
}
|
|
}
|