//! Named, view-local creation. A draft never enters the accepted model. use super::*; use iced::widget::{button, column, pick_list, row, scrollable, text, text_input}; use serde::{Deserialize, Serialize}; mod palette; #[cfg(test)] mod tests; pub use palette::view as palette; #[derive(Debug, Clone)] pub enum Event { Start(Kind, Option), Allocate(Kind, Id, Option), Rename(crate::route_edit::Context, Id, Position), TogglePalette, Existing(Option), Name(String), Submit, Click(crate::route_edit::Context, Position), Pick(Id), Locate(Id), Search(String), Filter(Filter), ShowPlaced(bool), Page(usize), CapturedEscape, Cancel, } pub fn subscription(app: &App) -> Subscription { if app.architecture.as_ref().is_some_and(State::inline) { iced::event::listen_with(captured_escape) } else { Subscription::none() } } fn captured_escape( event: iced::Event, status: iced::event::Status, _: iced::window::Id, ) -> Option { // A focused Iced text input captures Escape while dropping its focus. Only // inline architecture naming opts into also cancelling that same keypress. (status == iced::event::Status::Captured && matches!( event, iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), .. }) )) .then_some(Message::Architecture(Event::CapturedEscape)) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum Filter { Context, AllCompatible, Kind(Kind), } impl std::fmt::Display for Filter { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Context => f.write_str("Suggested for this view"), Self::AllCompatible => f.write_str("All compatible types"), Self::Kind(k) => f.write_str(k.label()), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum Intent { Create { kind: Kind, name: String, #[serde(default, skip_serializing_if = "Option::is_none")] performer: Option, }, Rename { element: Id, name: String, }, Existing { selected: Option, }, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Saved { pub version: u32, pub base: String, pub diagram: Id, pub intent: Intent, pub point: Option, pub placing: bool, pub query: String, pub filter: Filter, pub show_placed: bool, pub page: usize, } #[derive(Debug, Clone)] pub struct State { pub saved: Saved, document: Id, revision: u64, pub error: Option, } impl State { pub fn restore(app: &App, saved: Saved) -> Result { validate_saved(app.project(), &saved)?; Ok(Self { saved, document: app.canvas_document, revision: app.document.revision(), error: None, }) } pub fn modal(&self) -> bool { !self.saved.placing && matches!(self.saved.intent, Intent::Existing { .. }) } pub fn inline(&self) -> bool { !self.saved.placing && self.saved.point.is_some() && !matches!(self.saved.intent, Intent::Existing { .. }) } pub fn unnamed_tool(&self) -> bool { self.saved.placing && self.saved.point.is_none() && matches!(&self.saved.intent, Intent::Create { name, .. } if name.is_empty()) } } pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> { if s.version != 1 || s.base != oa_core::transitions::project_fingerprint(p) { return Err("The architecture draft does not match its accepted model.".into()); } let d = p .diagrams .get(&s.diagram) .ok_or("The creation draft's diagram is missing.")?; if matches!(d.projection, ProjectionKind::StateMachine { .. }) || s.query.len() > 16384 || s.page > 1_000_000 || s.point .is_some_and(|p| !p.x.is_finite() || !p.y.is_finite()) { return Err("Invalid architecture creation draft context.".into()); } match &s.intent { Intent::Create { kind, name, performer, } if name.len() > 16384 || !kind.supports(d.layer) || !d.projection.includes(*kind) || *kind == Kind::Port || performer.is_some_and(|id| { !kind.is_work() || !p .elements .get(&id) .is_some_and(|e| e.kind().is_performer() && e.layer() == d.layer) }) => { Err("Invalid architecture creation draft type or name.".into()) } Intent::Existing { selected: Some(id) } if !compatible(p, d, *id) => { Err("The creation draft's existing element is incompatible.".into()) } Intent::Rename { element, name } if name.len() > 16384 || !p.elements.contains_key(element) || !d.nodes.values().any(|n| n.element == *element) => { Err("The rename draft's element is not shown in this view.".into()) } _ => Ok(()), } } fn context_kinds(p: &Project, d: &Diagram) -> Vec { let mut values = match (d.layer, d.projection) { (_, ProjectionKind::StateMachine { .. }) => vec![], (Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity], (Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function], (Layer::Oa, ProjectionKind::Capabilities) => vec![ Kind::OperationalMission, Kind::Capability, Kind::Entity, Kind::Actor, ], (Layer::Sa, ProjectionKind::Capabilities) => vec![ Kind::Mission, Kind::SystemCapability, Kind::System, Kind::SystemActor, ], (Layer::Oa, _) => vec![Kind::Entity, Kind::Actor, Kind::Activity], (Layer::Sa, _) => vec![Kind::System, Kind::SystemActor, Kind::Function], }; values.retain(|k| *k != Kind::System || !p.elements.values().any(|e| e.kind() == Kind::System)); values } pub fn kinds(app: &App) -> Vec { app.active_diagram .and_then(|id| app.project().diagrams.get(&id)) .map(|d| context_kinds(app.project(), d)) .unwrap_or_default() } fn compatible(p: &Project, d: &Diagram, id: Id) -> bool { p.elements.get(&id).is_some_and(|e| { e.kind() != Kind::Port && d.projection.includes(e.kind()) && (e.layer() == d.layer || d.projection == ProjectionKind::Traceability) }) } fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool { // Existing System remains a useful choice even when the singleton exists. context_kinds(p, d).contains(&kind) || (kind == Kind::System && d.layer == Layer::Sa && d.projection != ProjectionKind::FunctionalDataflow) } pub fn candidates(app: &App) -> Vec<(Id, String, bool)> { let Some(s) = &app.architecture else { return vec![]; }; let p = app.project(); let Some(d) = p.diagrams.get(&s.saved.diagram) else { return vec![]; }; let query = s.saved.query.to_lowercase(); let mut entries: Vec<_> = p .elements .values() .filter(|e| compatible(p, d, e.id)) .filter(|e| match s.saved.filter { Filter::Context => suggested(p, d, e.kind()), Filter::AllCompatible => true, Filter::Kind(k) => e.kind() == k, }) .filter_map(|e| { let shown = d.nodes.values().any(|n| n.element == e.id); let label = format!( "{} · @{} · {} · {}", e.name, e.alias, e.kind().label(), e.parent.map(|id| p.named(id)).unwrap_or("At root") ); ((!shown || s.saved.show_placed) && label.to_lowercase().contains(&query)) .then_some((e.id, label, shown)) }) .collect(); entries.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0))); entries } impl App { pub(super) fn architecture_event(&mut self, event: Event) -> Task { match event { Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => { if self.architecture.is_some() { if self.architecture.as_ref().is_some_and(State::unnamed_tool) { self.architecture = None; } else { self.status = "Finish or cancel the current name before choosing another tool." .into(); return Task::none(); } } if self.page != Page::Diagram || self.source_dirty() || self.draft_dirty() || self.behavior.has_pending() || self.workflow.is_some() { self.status = "Apply or cancel pending edits before creating diagram content.".into(); return Task::none(); } let Some(diagram) = self.active_diagram else { return Task::none(); }; let intent = match event { Event::Start(kind, _) if kinds(self).contains(&kind) => Intent::Create { kind, name: String::new(), performer: None, }, Event::Allocate(kind, performer, _) if kinds(self).contains(&kind) && kind.is_work() => { Intent::Create { kind, name: String::new(), performer: Some(performer), } } Event::Existing(_) => Intent::Existing { selected: None }, _ => return Task::none(), }; let placing = point.is_none() && matches!(intent, Intent::Create { .. }); let saved = Saved { version: 1, base: oa_core::transitions::project_fingerprint(self.project()), diagram, intent, point, placing, query: String::new(), filter: Filter::Context, show_placed: false, page: 0, }; if let Ok(state) = State::restore(self, saved) { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.architecture = Some(state); self.connection = None; self.fit_to_view = false; self.status = "Click in the diagram to position the new element · Esc cancels".into(); } return iced::widget::operation::focus("architecture-name"); } Event::TogglePalette => { self.architecture_palette_expanded = !self.architecture_palette_expanded; self.fit_to_view = false; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } Event::Rename(context, element, point) => { if self.architecture.is_some() || self.route_context() != Some(context) || self.page != Page::Diagram || self.draft_dirty() || self.source_dirty() || self.behavior.has_pending() { return Task::none(); } let Some(e) = self.project().elements.get(&element) else { return Task::none(); }; let saved = Saved { version: 1, base: oa_core::transitions::project_fingerprint(self.project()), diagram: context.diagram, intent: Intent::Rename { element, name: e.name.clone(), }, point: Some(point), placing: false, query: String::new(), filter: Filter::Context, show_placed: false, page: 0, }; if let Ok(state) = State::restore(self, saved) { self.architecture = Some(state); self.connection = None; self.fit_to_view = false; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } return iced::widget::operation::focus("architecture-name"); } Event::CapturedEscape => { if self.architecture.as_ref().is_some_and(State::inline) { return self.architecture_event(Event::Cancel); } } Event::Cancel => { self.architecture = None; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.status = "Cancelled · Select / move".into(); } Event::Name(value) => { if value.len() <= 16384 && let Some(s) = &mut self.architecture && let Intent::Create { name, .. } | Intent::Rename { name, .. } = &mut s.saved.intent { *name = value; s.error = None; } } Event::Search(query) => { if query.len() <= 16384 && let Some(s) = &mut self.architecture { s.saved.query = query; s.saved.page = 0; } } Event::Filter(filter) => { if let Some(s) = &mut self.architecture { s.saved.filter = filter; s.saved.page = 0; } } Event::ShowPlaced(value) => { if let Some(s) = &mut self.architecture { s.saved.show_placed = value; s.saved.page = 0; } } Event::Page(page) => { if let Some(s) = &mut self.architecture { s.saved.page = page.min(1_000_000); } } Event::Pick(id) => { if !candidates(self) .iter() .any(|(candidate, _, shown)| *candidate == id && !shown) { return Task::none(); } if let Some(s) = &mut self.architecture { s.saved.intent = Intent::Existing { selected: Some(id) }; } return self.architecture_event(Event::Submit); } Event::Locate(id) => { if candidates(self) .iter() .any(|(candidate, _, shown)| *candidate == id && *shown) && let Some(s) = self.architecture.take() { return self.update(Message::RevealInDiagram(s.saved.diagram, id)); } } Event::Submit => { let Some(s) = &mut self.architecture else { return Task::none(); }; if matches!(&s.saved.intent, Intent::Create { name, .. } | Intent::Rename { name, .. } if name.trim().is_empty()) { s.error = Some("Enter an engineering name before placing this element.".into()); return Task::none(); } if matches!(s.saved.intent, Intent::Existing { selected: None }) { return Task::none(); } s.saved.placing = true; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); if let Some(point) = s.saved.point { self.commit_architecture(point); } } Event::Click(context, point) => { if self.route_context() != Some(context) || !point.x.is_finite() || !point.y.is_finite() { return Task::none(); } if self.architecture.as_ref().is_some_and(|s| s.saved.placing) { if matches!( self.architecture.as_ref().unwrap().saved.intent, Intent::Existing { .. } ) { self.commit_architecture(point); } else { let s = self.architecture.as_mut().unwrap(); s.saved.point = Some(point); s.saved.placing = false; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); return iced::widget::operation::focus("architecture-name"); } } } } Task::none() } fn commit_architecture(&mut self, point: Position) { let Some(s) = self.architecture.as_ref() else { return; }; if s.document != self.canvas_document || s.revision != self.document.revision() || Some(s.saved.diagram) != self.active_diagram || self.page != Page::Diagram || self.source_dirty() || self.draft_dirty() || self.behavior.has_pending() || !point.x.is_finite() || !point.y.is_finite() { self.status = "The diagram changed. The name is retained; cancel and restart this placement." .into(); if let Some(s) = &mut self.architecture { s.saved.placing = false; s.error = Some(self.status.clone()); } return; } let diagram = s.saved.diagram; if let Intent::Rename { element, name } = &s.saved.intent { let element = *element; let name = name.clone(); if name.trim().is_empty() { return; } if self.execute(Command::Rename(element, name)) { self.architecture = None; self.selected = element; self.refresh_draft(); self.status = "Name updated · Undo restores the previous name".into(); } else if let Some(s) = &mut self.architecture { s.error = Some(self.status.clone()); s.saved.placing = false; } return; } let mut candidate = self.project().clone(); let mut commands = vec![]; let id = match &s.saved.intent { Intent::Create { kind, name, performer, } => { if name.trim().is_empty() || !kinds(self).contains(kind) { return; } let Some(element) = candidate.make_element(*kind, candidate.diagrams[&diagram].layer, name, None) else { return; }; let id = element.id; candidate.elements.insert(id, element.clone()); commands.push(Command::CreateElement(element)); if let Some(performer) = performer { commands.push(Command::Connect( RelationKind::Allocation, "Performs".into(), *performer, id, )); } id } Intent::Existing { selected: Some(id) } if compatible(&candidate, &candidate.diagrams[&diagram], *id) && !candidate.diagrams[&diagram] .nodes .values() .any(|n| n.element == *id) => { *id } _ => return, }; let node = candidate.place(diagram, id, point); commands.push(Command::CreateNode { diagram, node: candidate.diagrams[&diagram].nodes[&node].clone(), }); if self.execute(Command::Batch(commands)) { self.architecture = None; self.selected = id; self.selected_node = Some(node); self.selected_bend = None; self.inspector_tab = crate::navigation::InspectorTab::Properties; self.refresh_draft(); self.fit_to_view = false; self.status = "Element placed · Undo restores the whole change. Drag to move; Fit is available when needed.".into(); } else if let Some(s) = &mut self.architecture { s.error = Some(self.status.clone()); s.saved.placing = false; } } } pub fn inline_editor(app: &App) -> Option> { let s = app.architecture.as_ref().filter(|s| s.inline())?; let point = s.saved.point?; let size = app.canvas_size.unwrap_or(iced::Size::new(450., 350.)); let width = (size.width - 16.).clamp(120., 320.); let x = (point.x * app.zoom + app.pan.x).clamp(8., (size.width - width - 8.).max(8.)); let y = (point.y * app.zoom + app.pan.y).clamp(8., (size.height - 152.).max(8.)); Some( column![ iced::widget::Space::new().height(y), row![ iced::widget::Space::new().width(x), iced::widget::opaque( iced::widget::container(scrollable(name_editor(app))) .padding(10) .width(width) .max_height((size.height - y - 8.).max(0.)) .style(crate::style::card) ) ] ] .into(), ) } fn name_editor(app: &App) -> Element<'_, Message> { let s = app.architecture.as_ref().unwrap(); let (title, name, action) = match &s.saved.intent { Intent::Create { kind, name, performer, } => ( performer .map(|id| { format!( "{} · performed by {}", kind.label(), crate::views::short(app.project().named(id), 32) ) }) .unwrap_or_else(|| format!("New {}", kind.label())), name, "Create", ), Intent::Rename { name, .. } => ("Rename".into(), name, "Apply"), Intent::Existing { .. } => unreachable!(), }; let mut content = column![ text(title).size(12), text_input("Engineering name", name) .id("architecture-name") .on_input(|v| Message::Architecture(Event::Name(v))) .on_submit(Message::Architecture(Event::Submit)) .padding(7) .size(13), row![ button(text(action).size(12)) .padding([5, 9]) .style(crate::style::nav) .on_press(Message::Architecture(Event::Submit)), button(text("Cancel").size(12)) .padding([5, 9]) .style(crate::style::nav) .on_press(Message::Architecture(Event::Cancel)) ] .spacing(6) ] .spacing(7); if let Some(error) = &s.error { content = content.push(text(crate::views::short(error, 100)).size(11)); } content.into() } pub fn dialog(app: &App) -> Element<'_, Message> { let s = app.architecture.as_ref().unwrap(); let action = |label: String, event| { button(text(label).size(12)) .padding(8) .on_press(Message::Architecture(event)) }; let mut content = column![].spacing(12); match &s.saved.intent { Intent::Create { .. } | Intent::Rename { .. } => return name_editor(app), Intent::Existing { .. } => { let p = app.project(); let d = &p.diagrams[&s.saved.diagram]; let mut filters = vec![Filter::Context, Filter::AllCompatible]; filters.extend( Kind::ALL .into_iter() .filter(|k| { *k != Kind::Port && d.projection.includes(*k) && (k.supports(d.layer) || d.projection == ProjectionKind::Traceability) }) .map(Filter::Kind), ); let entries = candidates(app); let page = s.saved.page.min(entries.len().saturating_sub(1) / 30); let mut rows = column![].spacing(5); for (id, label, shown) in entries.iter().skip(page * 30).take(30) { rows = rows.push( action( format!("{}{}", if *shown { "Locate · " } else { "Add · " }, label), if *shown { Event::Locate(*id) } else { Event::Pick(*id) }, ) .width(iced::Fill), ); } content = content.push(text("Add existing to this diagram").size(22)) .push(text("Add a model element to this view. Its name and properties stay shared across views.").size(13)) .push(text_input("Optional name, alias or owner filter", &s.saved.query).id("architecture-name").on_input(|v| Message::Architecture(Event::Search(v))).padding(9)) .push(pick_list(filters, Some(s.saved.filter), |f| Message::Architecture(Event::Filter(f)))) .push(iced::widget::checkbox(s.saved.show_placed).label("Include already shown (Locate)").on_toggle(|v| Message::Architecture(Event::ShowPlaced(v)))) .push(text(format!("{} compatible matches · page {}", entries.len(), page + 1)).size(12)); if entries.is_empty() { content = content.push(text("No unplaced matches. Try All compatible types or include already shown elements.").size(13)); } content = content .push(scrollable(rows).height((app.window_size.height - 500.).clamp(120., 260.))) .push( row![ action("Previous".into(), Event::Page(page.saturating_sub(1))), action( "Next".into(), Event::Page((page + 1).min(entries.len().saturating_sub(1) / 30)) ), action("Cancel".into(), Event::Cancel) ] .spacing(8), ); } } if let Some(error) = &s.error { content = content.push(text(error).size(13)); } content.into() }