//! 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 { match self { Self::Lifeline { owner, .. } | Self::Work { owner } => Some(owner), Self::Machine { .. } => None, } } fn layer(self, p: &Project) -> Result { 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 { 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), 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, id: Id, kind: Option, name: String, parent: Option, search: String, page: usize, identities: Vec, } #[derive(Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct State { editing: Option, staged: Option, #[serde(default)] engaged: bool, } impl State { pub fn check_scope(&self, expected: Scope, selected: Option) -> 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 { 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) -> Option { 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, 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) -> Result, 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, command: Command, ) -> Result { 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> { 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 { 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) {} fn allocate_image( &mut self, handle: &iced::advanced::image::Handle, callback: impl FnOnce( Result, ) + 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, content: Option, name: Option, parent: Option, actions: Option, kinds: BTreeMap, 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, 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); } }