Files
ARCADIA-Studio/crates/oa-desktop/src/app.rs
T

2100 lines
78 KiB
Rust

use crate::forms::Draft;
use iced::{Element, Subscription, Task, keyboard, widget::text_editor, window};
use oa_core::{projection::project_view, validation::validate, *};
use serde_json::{Value, json};
use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
type FileSnapshot = (PathBuf, Project, Arc<[u8]>);
type LoadedSnapshot = (PathBuf, persistence::DecodeResult, Arc<[u8]>);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Page {
Diagram,
Catalog,
Behavior,
Data,
Traces,
Text,
Validation,
Guide,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Choice {
pub id: Id,
pub label: String,
}
impl std::fmt::Display for Choice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label)
}
}
#[derive(Debug, Clone, Copy)]
pub enum Pending {
New,
Demo,
Open,
Close(window::Id),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileOperation {
Open,
Save,
Export,
}
pub struct FilePrompt {
pub operation: FileOperation,
pub path: String,
pub overwrite: bool,
}
#[derive(Debug, Clone)]
pub enum Message {
ExactStrings(bool),
Page(Page),
Guide(crate::guide::Event),
Behavior(crate::behavior::Event),
Layer(Layer),
Select(Id),
EditProperties(Id),
SelectNode(Id),
SelectProject,
Search(String),
StartTransition,
Workflow(crate::workflow::Event),
CollapseExplorer(Id),
CreateKind(Kind),
Add(bool),
AddRelation,
PortAction(crate::port_workflow::Action),
AddRelationKind(RelationKind, bool),
InspectorTab(crate::navigation::InspectorTab),
RevealInDiagram(Id, Id),
AddDiagram,
AddArtifact,
AddTrace,
Field(String, String),
FieldText(String, text_editor::Action),
FieldShape(String, Value),
FieldVariant(String, Value),
FieldArray(String, Option<usize>, i32),
Apply,
RevertProperties,
ElementKind(Kind),
RelationKind(RelationKind),
Command(Command),
Delete,
ConfirmDelete,
CancelDelete,
Undo,
Redo,
Diagram(Choice),
Projection(ProjectionKind),
PlaceSelected,
HideSelected,
ToggleCollapse,
MoveNode(Id, Position),
Zoom(f32),
Pan(iced::Vector),
Fit,
SourceEdit(text_editor::Action),
ApplySource,
RevertSource,
SourceUndo,
SourceRedo,
Lint(u64),
Linted(u64, Result<Vec<language::Diagnostic>, String>),
SourceDiagnostic(usize),
SourceViewport,
SourceForSelection,
SourceNavigation(crate::source_navigation::Event),
Save(bool),
Saved(Result<Option<FileSnapshot>, String>),
Loaded(Result<Option<LoadedSnapshot>, String>),
Request(Pending),
Discard,
Cancel,
SaveAndContinue,
FilePath(String),
SubmitFile,
CancelFile,
Export,
Exported(Result<bool, String>),
WindowResized(iced::Size),
CanvasResized(iced::Size),
ShortcutUndo(bool),
Escape,
}
pub struct App {
pub exact_strings: bool,
pub session: Session,
pub page: Page,
pub guide: crate::guide::State,
pub behavior: crate::behavior::State,
pub inspector_tab: crate::navigation::InspectorTab,
pub layer: Layer,
pub selected: Id,
pub selected_node: Option<Id>,
pub draft: Option<Draft>,
pub active_diagram: Option<Id>,
pub search: String,
pub create_kind: Kind,
pub status: String,
pub busy: bool,
pub pending: Option<Pending>,
pub delete_pending: bool,
pub path: Option<PathBuf>,
file_snapshot: Option<Arc<[u8]>>,
pub file_prompt: Option<FilePrompt>,
continue_after_save: bool,
pub zoom: f32,
pub pan: iced::Vector,
pub window_size: iced::Size,
canvas_size: Option<iced::Size>,
fit_to_view: bool,
pub source_editor: text_editor::Content,
source_baseline: String,
pub source_snapshot: Arc<str>,
pub source_diagnostics: Vec<language::Diagnostic>,
pub source_jump: Option<text_editor::Cursor>,
pub source_navigation: crate::source_navigation::State,
pub model_issues: Vec<validation::Issue>,
pub migration: Option<persistence::MigrationReport>,
source_past: Vec<String>,
source_future: Vec<String>,
source_version: u64,
lint_running: Option<u64>,
lint_requested: Option<u64>,
pub explorer_collapsed: BTreeSet<Id>,
pub workflow: Option<crate::workflow::Workflow>,
}
impl App {
pub fn new() -> Self {
let mut app = Self::with_project(Project::demo());
app.fit();
app
}
pub fn with_project(project: Project) -> Self {
let source = language::format(&project);
let model_issues = validate(&project);
let selected = project.id;
let populated = |layer| {
project.elements.values().any(|e| e.layer() == layer)
|| project.diagrams.values().any(|d| d.layer == layer)
};
let layer = if !populated(Layer::Oa) && populated(Layer::Sa) {
Layer::Sa
} else {
Layer::Oa
};
let active_diagram = project
.diagrams
.values()
.filter(|d| d.layer == layer)
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
.map(|d| d.id);
let draft = Draft::from_project(&project, selected);
Self {
exact_strings: false,
session: Session::new(project),
page: Page::Diagram,
guide: crate::guide::State::default(),
behavior: Default::default(),
inspector_tab: Default::default(),
layer,
selected,
selected_node: None,
draft,
active_diagram,
search: String::new(),
create_kind: if layer == Layer::Sa {
Kind::Function
} else {
Kind::Activity
},
status: "Coastal rescue example · explore both engineering perspectives".into(),
busy: false,
pending: None,
delete_pending: false,
path: None,
file_snapshot: None,
file_prompt: None,
continue_after_save: false,
zoom: 0.65,
pan: iced::Vector::new(24., 48.),
window_size: iced::Size::new(1440., 900.),
canvas_size: None,
fit_to_view: true,
source_editor: text_editor::Content::with_text(&source),
source_baseline: source.clone(),
source_snapshot: Arc::from(source),
source_diagnostics: vec![],
source_jump: None,
source_navigation: crate::source_navigation::State::default(),
model_issues,
migration: None,
source_past: vec![],
source_future: vec![],
source_version: 0,
lint_running: None,
lint_requested: None,
explorer_collapsed: BTreeSet::new(),
workflow: None,
}
}
pub fn project(&self) -> &Project {
self.session.project()
}
pub fn title(&self) -> String {
format!(
"{}{} — Arcadia Studio",
self.project()
.name
.split_whitespace()
.collect::<Vec<_>>()
.join(" "),
if self.dirty() { " *" } else { "" }
)
}
pub fn source_dirty(&self) -> bool {
self.source_editor.text() != self.source_baseline
}
pub fn draft_dirty(&self) -> bool {
self.draft.as_ref().is_some_and(Draft::dirty)
}
pub fn dirty(&self) -> bool {
self.session.dirty() || self.draft_dirty() || self.source_dirty()
}
fn sync_source(&mut self) {
self.source_jump = None;
let source = language::format(self.project());
self.source_navigation.refresh(&source);
self.source_editor = text_editor::Content::with_text(&source);
self.source_baseline = source.clone();
self.source_snapshot = Arc::from(source);
self.source_diagnostics.clear();
self.source_past.clear();
self.source_future.clear();
self.source_version += 1;
self.lint_requested = None;
}
fn refresh_draft(&mut self) {
self.selected_node = self
.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
.and_then(|d| {
d.nodes
.values()
.find(|n| n.id == self.selected || n.element == self.selected)
})
.map(|n| n.id);
self.draft = Draft::from_project(self.project(), self.selected);
if self.exact_strings
&& let Some(d) = &mut self.draft
{
let _ = d.set_exact(true);
}
}
fn sync(&mut self) {
self.model_issues = validate(self.project());
self.normalize_creation();
if !self.project().contains(self.selected) {
self.selected = self.project().id;
self.selected_node = None;
}
if self.active_diagram.is_none_or(|id| {
self.project()
.diagrams
.get(&id)
.is_none_or(|d| d.layer != self.layer)
}) {
self.active_diagram = self
.project()
.diagrams
.values()
.filter(|d| d.layer == self.layer)
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
.map(|d| d.id);
}
self.refresh_draft();
self.sync_source();
}
fn normalize_creation(&mut self) {
if !self.create_kind.supports(self.layer) {
self.create_kind = if self.layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
}
}
pub fn apply_source(&mut self) -> bool {
if !self.source_dirty() {
return true;
}
match language::parse_with_report(&self.source_editor.text()) {
Ok(decoded) => match self.session.execute(Command::Replace(decoded.project)) {
Ok(_) => {
if decoded.migration.is_some() {
self.migration = decoded.migration;
}
self.sync();
self.status = "Text applied as one model change".into();
true
}
Err(e) => {
self.status = e.to_string();
false
}
},
Err(issues) => {
self.source_diagnostics = issues;
self.status = "Source contains errors. Your draft is preserved.".into();
self.page = Page::Text;
false
}
}
}
pub fn apply(&mut self) -> bool {
if !self.apply_source() {
return false;
}
if !self.draft_dirty() {
return true;
}
let original = self.selected;
let result = self
.draft
.as_ref()
.ok_or("No selection.".to_string())
.and_then(|d| Ok((d.command()?, d.resulting_id()?)));
match result.and_then(|(command, id)| {
self.session
.execute(command)
.map(|_| id)
.map_err(|e| e.to_string())
}) {
Ok(id) => {
if self.active_diagram == Some(original) {
self.active_diagram = Some(id);
}
if self.selected_node == Some(original) {
self.selected_node = Some(id);
}
if self.explorer_collapsed.remove(&original) {
self.explorer_collapsed.insert(id);
}
self.selected = id;
if let Some(layer) = self.project().layer_of(id) {
self.layer = layer;
}
if let Some((diagram, layer, node)) = self
.project()
.diagrams
.values()
.find(|d| d.id == id || d.nodes.contains_key(&id))
.map(|d| (d.id, d.layer, d.nodes.contains_key(&id).then_some(id)))
{
self.layer = layer;
self.active_diagram = Some(diagram);
self.selected_node = node;
}
self.sync();
self.status = "Properties applied".into();
true
}
Err(e) => {
self.status = e;
false
}
}
}
fn execute(&mut self, command: Command) -> bool {
match self.session.execute(command) {
Ok(selected) => {
if let Some(id) = selected {
self.selected = id;
}
self.sync();
self.status = "Model updated".into();
true
}
Err(e) => {
self.status = e.to_string();
false
}
}
}
fn reset(&mut self, project: Project, path: Option<PathBuf>) {
let window_size = self.window_size;
let canvas_size = self.canvas_size;
let source_version = self.source_version + 1;
let lint_running = self.lint_running;
let guide = std::mem::take(&mut self.guide);
*self = Self::with_project(project);
self.window_size = window_size;
self.canvas_size = canvas_size;
self.source_version = source_version;
self.lint_running = lint_running;
self.guide = guide;
self.path = path;
self.status = "Project ready".into();
self.fit();
}
fn diagram_for(&self, layer: Layer, element: Id) -> Option<Id> {
preferred_diagram(self.project(), self.active_diagram, layer, element)
}
fn select(&mut self, id: Id) {
if self.selected == id {
return;
}
if !self.apply() {
return;
}
if !self.project().contains(id) {
return;
}
self.selected = id;
self.selected_node = None;
if let Some(layer) = self.project().layer_of(id)
&& layer != self.layer
{
self.layer = layer;
self.active_diagram = self.diagram_for(layer, id);
self.fit();
}
self.refresh_draft();
}
pub fn view(&self) -> Element<'_, Message> {
crate::views::view(self)
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
iced::event::listen_with(|event, _, _| {
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
key,
modifiers: mods,
..
}) = event
else {
return None;
};
if key == keyboard::Key::Named(keyboard::key::Named::Escape) {
return Some(Message::Escape);
}
if key == keyboard::Key::Named(keyboard::key::Named::F1) {
return Some(Message::Page(Page::Guide));
}
if key == keyboard::Key::Named(keyboard::key::Named::F3) {
return Some(Message::SourceNavigation(
crate::source_navigation::Event::Next(mods.shift()),
));
}
if !mods.command() {
return None;
}
match key.as_ref() {
keyboard::Key::Character("s") => Some(Message::Save(mods.shift())),
keyboard::Key::Character("o") => Some(Message::Request(Pending::Open)),
keyboard::Key::Character("n") => Some(Message::Request(Pending::New)),
keyboard::Key::Character("z") => Some(Message::ShortcutUndo(mods.shift())),
keyboard::Key::Character("y") => Some(Message::ShortcutUndo(true)),
keyboard::Key::Character("f") => Some(Message::SourceNavigation(
crate::source_navigation::Event::Open(false),
)),
keyboard::Key::Character("g") => Some(Message::SourceNavigation(
crate::source_navigation::Event::Open(true),
)),
_ => None,
}
}),
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
if self.busy
&& !matches!(
message,
Message::Saved(_)
| Message::Loaded(_)
| Message::Linted(_, _)
| Message::Exported(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport
)
{
return Task::none();
}
// A modal owns input until it is dismissed. This also protects against
// global shortcuts and controls that were focused before it opened.
if matches!(
message,
Message::Escape | Message::Request(Pending::Close(_))
) {
if self.workflow.take().is_some() {
return Task::none();
}
if self.file_prompt.take().is_some() {
self.continue_after_save = false;
return Task::none();
}
if self.delete_pending {
self.delete_pending = false;
return Task::none();
}
if self.pending.take().is_some() {
self.continue_after_save = false;
return Task::none();
}
}
let modal_event = matches!(
message,
Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport
| Message::Lint(_)
| Message::Linted(_, _)
| Message::Saved(_)
| Message::Loaded(_)
| Message::Exported(_)
);
if !modal_event
&& ((self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
|| (self.file_prompt.is_some()
&& !matches!(
message,
Message::FilePath(_) | Message::SubmitFile | Message::CancelFile
))
|| (self.delete_pending
&& !matches!(message, Message::ConfirmDelete | Message::CancelDelete))
|| (self.pending.is_some()
&& self.file_prompt.is_none()
&& !matches!(
message,
Message::Cancel | Message::Discard | Message::SaveAndContinue
)))
{
return Task::none();
}
let previous = self.selected;
let task = self.handle(message);
self.normalize_creation();
if self.selected != previous {
task.chain(iced::widget::operation::snap_to(
"inspector",
iced::widget::scrollable::RelativeOffset::START,
))
} else {
task
}
}
fn handle(&mut self, message: Message) -> Task<Message> {
match message {
Message::Escape => self.source_navigation.open = false,
Message::Guide(event) => self.guide.update(event),
Message::ExactStrings(enabled) => {
if let Some(d) = &mut self.draft {
match d.set_exact(enabled) {
Ok(()) => self.exact_strings = enabled,
Err(e) => self.status = e,
}
}
}
Message::Field(path, value) => {
if let Some(d) = &mut self.draft {
d.input(&path, value)
}
}
Message::FieldText(path, action) => {
if let Some(d) = &mut self.draft {
d.edit_text(&path, action)
}
}
Message::FieldShape(path, value) => {
if let Some(d) = &mut self.draft
&& let Err(e) = d.shape(&path, value)
{
self.status = e;
}
}
Message::FieldVariant(path, value) => {
if let Some(d) = &mut self.draft {
let result = d.materialize().and_then(|current| {
if current
.pointer(&path)
.is_some_and(|v| crate::forms::same_variant(v, &value))
{
Ok(())
} else {
d.shape(&path, value)
}
});
if let Err(e) = result {
self.status = e;
}
}
}
Message::FieldArray(path, index, direction) => {
if let Some(d) = &mut self.draft
&& let Err(e) = d.array(&path, index, direction)
{
self.status = e;
}
}
Message::Apply => {
self.apply();
}
Message::RevertProperties => {
self.refresh_draft();
self.status = "Property draft reverted".into();
}
Message::Select(id) => self.select(id),
Message::EditProperties(id) => {
self.select(id);
self.inspector_tab = crate::navigation::InspectorTab::Properties;
}
Message::SelectProject => self.select(self.project().id),
Message::SelectNode(id) => {
if (self.selected_node == Some(id)
&& self.active_diagram
.and_then(|diagram| self.project().diagrams.get(&diagram))
.and_then(|diagram| diagram.nodes.get(&id))
.is_some_and(|node| self.selected == node.element))
// Defer a placement draft until release: it may change the
// identity captured by the just-started canvas gesture.
|| (self.selected == id && self.draft.as_ref().is_some_and(Draft::dirty))
{
return Task::none();
}
let original = self.selected;
if !self.apply() {
return Task::none();
}
let id = if id == original { self.selected } else { id };
if let Some(d) = self
.active_diagram
.and_then(|d| self.project().diagrams.get(&d))
&& let Some(node) = d.nodes.get(&id)
{
self.selected = node.element;
self.selected_node = Some(id);
self.refresh_draft();
}
}
Message::Search(value) => self.search = value,
Message::Page(page) => {
if page == Page::Guide
|| (if page == Page::Text {
self.apply()
} else {
self.apply_source()
})
{
self.page = page;
if page == Page::Text {
return self.request_lint();
}
}
}
Message::CollapseExplorer(id) => {
if !self.explorer_collapsed.remove(&id) {
self.explorer_collapsed.insert(id);
}
}
Message::StartTransition => {
if self.apply() {
match crate::workflow::Workflow::transition(self.project(), self.selected) {
Ok(w) => self.workflow = Some(w),
Err(e) => self.status = e,
}
}
}
Message::Workflow(event) => {
if matches!(event, crate::workflow::Event::Cancel) {
self.workflow = None;
return Task::none();
}
if let Some(mut workflow) = self.workflow.take() {
let transition_focus = match &workflow {
crate::workflow::Workflow::Ports(state) => Some(state.focus()),
crate::workflow::Workflow::Transition(t) => {
t.plan.changes.first().map(|c| c.target)
}
_ => None,
};
match workflow.update(self.project(), event) {
Ok(Some(mut candidate)) => {
let focus = transition_focus
.or_else(|| {
candidate
.relations
.keys()
.find(|id| !self.project().relations.contains_key(id))
.copied()
})
.or_else(|| {
candidate
.traces
.keys()
.find(|id| !self.project().traces.contains_key(id))
.copied()
});
let destination = focus.and_then(|id| {
candidate.layer_of(id).and_then(|layer| {
preferred_diagram(&candidate, self.active_diagram, layer, id)
})
});
if let (Some(id), Some(diagram)) = (focus, destination) {
reveal_port_owner(&mut candidate, diagram, id);
}
if self.execute(Command::Replace(candidate)) {
if let Some(id) = focus {
self.selected = id;
if let Some(layer) = self.project().layer_of(id) {
self.layer = layer;
self.active_diagram =
destination.or_else(|| self.diagram_for(layer, id));
}
self.refresh_draft();
}
self.page = Page::Diagram;
self.fit();
self.status="Reviewed change applied · Undo restores the complete operation".into();
} else {
self.workflow = Some(workflow);
}
}
Ok(None) => self.workflow = Some(workflow),
Err(e) => {
self.status = e;
self.workflow = Some(workflow);
}
}
}
}
Message::Layer(layer) => {
if self.apply() {
self.layer = layer;
self.create_kind = if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
self.active_diagram = self
.project()
.diagrams
.values()
.filter(|d| d.layer == layer)
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
.map(|d| d.id);
self.selected = self.project().id;
self.selected_node = None;
self.refresh_draft();
self.fit();
}
}
Message::CreateKind(kind) => self.create_kind = kind,
Message::Add(child) => {
if self.apply() {
self.add_element(child);
}
}
Message::ElementKind(kind) => {
if let Some(d) = &mut self.draft {
let result = d
.materialize()
.and_then(|v| {
serde_json::from_value::<oa_core::Element>(v).map_err(|e| e.to_string())
})
.and_then(|element| {
if element.kind() == kind {
return Ok(());
}
let body = ElementBody::new(kind, element.layer())
.ok_or("Kind is unavailable in this layer.")?;
d.shape("/body", json!(body))
});
if let Err(e) = result {
self.status = e;
}
}
}
Message::RelationKind(kind) => {
if let Some(d) = &mut self.draft {
let result = d
.materialize()
.and_then(|v| {
serde_json::from_value::<Relation>(v).map_err(|e| e.to_string())
})
.and_then(|relation| {
if relation.kind() == kind {
return Ok(());
}
d.shape(
"/body",
json!(RelationBody::new(
kind,
relation.source(),
relation.target()
)),
)
});
if let Err(e) = result {
self.status = e;
}
}
}
Message::AddRelation => {
if self.apply() {
self.add_relation();
}
}
Message::PortAction(action) => {
if self.apply() {
match crate::port_workflow::State::new(self.project(), self.selected, action) {
Ok(state) => {
self.workflow = Some(crate::workflow::Workflow::Ports(Box::new(state)))
}
Err(error) => self.status = error,
}
}
}
Message::AddRelationKind(kind, incoming) => {
if self.apply() {
self.workflow = Some(crate::workflow::Workflow::relationship_kind(
self.project(),
self.selected,
self.layer,
kind,
incoming,
));
}
}
Message::InspectorTab(tab) => self.inspector_tab = tab,
Message::Behavior(event) => {
if event.is_submit() && !self.apply() {
return Task::none();
}
let mut state = std::mem::take(&mut self.behavior);
let result = state.update(self.project(), event);
self.behavior = state;
match result {
Ok(Some(command)) => return self.handle(Message::Command(command)),
Ok(None) => {}
Err(error) => self.status = error,
}
}
Message::RevealInDiagram(diagram, selected) => {
let original = self.selected;
if !self.apply() {
return Task::none();
}
let selected = if selected == original {
self.selected
} else {
selected
};
let Some(view) = self.project().diagrams.get(&diagram) else {
return Task::none();
};
let layer = view.layer;
let endpoints = self
.project()
.relations
.get(&selected)
.map(|r| vec![r.source(), r.target()])
.unwrap_or_else(|| vec![selected])
.into_iter()
.map(|id| {
self.project()
.elements
.get(&id)
.filter(|e| e.kind() == Kind::Port)
.and_then(|e| e.parent)
.unwrap_or(id)
})
.collect::<Vec<_>>();
let nodes = view
.nodes
.values()
.filter(|n| endpoints.contains(&n.element) || n.id == selected)
.map(|n| n.id)
.collect::<Vec<_>>();
let commands = view
.nodes
.values()
.filter(|n| {
n.collapsed && {
let children = oa_core::projection::descendant_nodes(
self.project(),
diagram,
n.id,
);
nodes.iter().any(|id| children.contains(id))
}
})
.map(|n| {
let mut node = n.clone();
node.collapsed = false;
Command::EditNode {
diagram,
original: node.id,
node,
}
})
.collect::<Vec<_>>();
if !commands.is_empty() && !self.execute(Command::Batch(commands)) {
return Task::none();
}
self.active_diagram = Some(diagram);
self.layer = layer;
self.selected = selected;
self.page = Page::Diagram;
self.refresh_draft();
self.fit();
}
Message::AddDiagram => {
if self.apply() {
let id = Id::new_v4();
let p = self.project();
let d = Diagram {
id,
alias: p.fresh_alias(&format!("{}_view", self.layer), "view", id),
name: format!("{} architecture", self.layer),
description: String::new(),
layer: self.layer,
projection: ProjectionKind::Allocation,
nodes: Default::default(),
};
if self.execute(Command::CreateDiagram(d)) {
self.active_diagram = Some(id);
self.page = Page::Diagram;
}
}
}
Message::AddArtifact => {
if self.apply() {
let id = Id::new_v4();
let p = self.project();
let a = ArtifactLink {
id,
alias: p.fresh_alias("artifact", "artifact", id),
name: "Development artifact".into(),
description: String::new(),
owner: self.selected,
relation: ArtifactRelation::References,
uri: "./artifacts/design.md".into(),
revision: None,
fragment: None,
};
self.execute(Command::CreateArtifact(a));
}
}
Message::AddTrace => {
if self.apply() {
self.add_trace();
}
}
Message::Command(command) => {
let original = self.selected;
if self.apply() {
let command = match command {
Command::Layout(id) if id == original => Command::Layout(self.selected),
Command::MoveStep(id, from, to) if id == original => {
Command::MoveStep(self.selected, from, to)
}
command => command,
};
self.execute(command);
}
}
Message::Delete => {
if self.selected != self.project().id && self.apply() {
self.delete_pending = true;
}
}
Message::ConfirmDelete => {
self.delete_pending = false;
self.execute(Command::Delete(self.selected));
}
Message::CancelDelete => self.delete_pending = false,
Message::Undo | Message::Redo => {
let redo = matches!(message, Message::Redo);
if self.apply() {
if redo {
self.session.redo();
} else {
self.session.undo();
}
self.sync();
self.status = if redo {
"Model change redone"
} else {
"Model change undone"
}
.into();
}
}
Message::ShortcutUndo(redo) => {
return self.handle(if self.page == Page::Text {
if redo {
Message::SourceRedo
} else {
Message::SourceUndo
}
} else if redo {
Message::Redo
} else {
Message::Undo
});
}
Message::Diagram(choice) => {
if self.apply() {
self.active_diagram = Some(choice.id);
if let Some(d) = self.project().diagrams.get(&choice.id) {
self.layer = d.layer;
}
self.selected_node = None;
self.refresh_draft();
self.fit();
}
}
Message::Projection(projection) => {
if self.apply()
&& let Some(mut d) = self
.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
.cloned()
{
d.projection = projection;
self.execute(Command::EditDiagram(d.id, d));
self.fit();
}
}
Message::PlaceSelected => {
if self.apply()
&& let Some(diagram) = self.active_diagram
{
if let Some(node) = self
.project()
.diagrams
.get(&diagram)
.and_then(|d| d.nodes.values().find(|n| n.element == self.selected))
.map(|n| n.id)
{
self.selected_node = Some(node);
self.status = "Element is already shown in this diagram".into();
return Task::none();
}
let mut p = self.project().clone();
let node = p.place(diagram, self.selected, Position { x: 60., y: 60. });
if let Some(n) = p
.diagrams
.get(&diagram)
.and_then(|d| d.nodes.get(&node))
.cloned()
&& self.execute(Command::CreateNode { diagram, node: n })
{
self.selected_node = Some(node);
}
}
}
Message::HideSelected => {
if self.apply()
&& let Some(node) = self.selected_node
{
self.execute(Command::Delete(node));
self.selected_node = None;
}
}
Message::ToggleCollapse => {
if self.apply()
&& let Some(diagram) = self.active_diagram
&& let Some(mut n) = self
.selected_node
.and_then(|id| self.project().diagrams.get(&diagram)?.nodes.get(&id))
.cloned()
{
n.collapsed = !n.collapsed;
let selected = self.selected;
if self.execute(Command::EditNode {
diagram,
original: n.id,
node: n,
}) {
self.selected = selected;
self.refresh_draft();
}
}
}
Message::MoveNode(node, position) => {
let original = self.selected;
if self.apply()
&& let Some(diagram) = self.active_diagram
{
let node = if node == original {
self.selected
} else {
node
};
if self.execute(Command::MoveNode {
diagram,
node,
position,
with_descendants: true,
}) && let Some(placement) =
self.project().diagrams[&diagram].nodes.get(&node)
{
// A drag edits a placement, but the canvas still selects
// the engineering element represented by that placement.
self.selected = placement.element;
self.refresh_draft();
}
}
}
Message::Zoom(factor) => {
self.fit_to_view = false;
self.zoom = (self.zoom * factor).clamp(0.0001, 3.);
}
Message::Pan(delta) => {
self.fit_to_view = false;
self.pan += delta;
}
Message::Fit => self.fit(),
Message::SourceEdit(action) => {
self.source_jump = None;
let before = self.source_editor.text();
self.source_editor.perform(action);
let after = self.source_editor.text();
if after != before {
self.source_navigation.refresh(&after);
self.source_past.push(before);
if self.source_past.len() > 100 {
self.source_past.remove(0);
}
self.source_future.clear();
self.source_snapshot = Arc::from(after);
self.source_version += 1;
self.source_diagnostics.clear();
let version = self.source_version;
return Task::perform(
async move {
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
version
},
Message::Lint,
);
}
}
Message::Lint(version) => {
if version == self.source_version {
return self.request_lint();
}
}
Message::Linted(version, result) => {
if self.lint_running != Some(version) {
return Task::none();
}
self.lint_running = None;
if version == self.source_version {
match result {
Ok(diagnostics) => self.source_diagnostics = diagnostics,
Err(error) => self.status = format!("Source checks failed: {error}"),
}
}
return self.start_pending_lint();
}
Message::SourceDiagnostic(index) => {
if let Some(diagnostic) = self.source_diagnostics.get(index) {
let line = diagnostic.line.saturating_sub(1);
let column = self.source_editor.line(line).map_or(0, |text| {
text.text
.char_indices()
.nth(diagnostic.column.saturating_sub(1))
.map_or(text.text.len(), |(offset, _)| offset)
});
self.source_jump = Some(text_editor::Cursor {
position: text_editor::Position { line, column },
selection: None,
});
}
}
Message::SourceViewport => {
if let Some(cursor) = self.source_jump.take() {
self.source_editor.move_to(cursor);
if cursor.selection.is_none() {
self.source_editor.perform(text_editor::Action::SelectLine);
}
return iced::widget::operation::focus("source-editor");
}
}
Message::ApplySource => {
self.apply_source();
}
Message::RevertSource => {
self.sync_source();
self.status = "Source draft reverted".into();
}
Message::SourceUndo | Message::SourceRedo => {
let redo = matches!(message, Message::SourceRedo);
let value = if redo {
self.source_future.pop()
} else {
self.source_past.pop()
};
if let Some(value) = value {
let current = self.source_editor.text();
if redo {
self.source_past.push(current);
} else {
self.source_future.push(current);
}
self.source_editor = text_editor::Content::with_text(&value);
self.source_navigation.refresh(&value);
self.source_jump = None;
self.source_snapshot = Arc::from(value);
self.source_version += 1;
self.source_diagnostics.clear();
return self.request_lint();
}
}
Message::SourceForSelection => {
if self.apply() {
self.page = Page::Text;
let alias = self
.project()
.identities()
.into_iter()
.find(|(id, _)| *id == self.selected)
.map(|(_, a)| a.to_string());
if let Some(alias) = alias {
let source = self.source_editor.text();
if let Some(declaration) = language::analyze(&source)
.declarations
.iter()
.find(|d| d.alias == alias)
{
let prefix = &source[..declaration.span.start.min(source.len())];
let line = prefix.bytes().filter(|b| *b == b'\n').count();
self.source_jump = Some(text_editor::Cursor {
position: text_editor::Position { line, column: 0 },
selection: None,
});
}
self.status = format!("Source declaration: @{alias}");
}
return self.request_lint();
}
}
Message::SourceNavigation(event) => {
use crate::source_navigation::Event;
match event {
Event::Open(go_to_line) => {
if self.page != Page::Text && !self.apply() {
return Task::none();
}
self.page = Page::Text;
self.source_navigation.open = true;
self.source_navigation.refresh(&self.source_editor.text());
return iced::widget::operation::focus(if go_to_line {
"source-line"
} else {
"source-find"
});
}
Event::Close => self.source_navigation.open = false,
Event::Query(query) => {
self.source_navigation.query = query;
self.source_navigation.refresh(&self.source_editor.text());
}
Event::Next(backwards) if self.page == Page::Text => {
self.source_jump = self.source_navigation.next(backwards);
if self.source_jump.is_none() {
self.status = "No matching source text.".into();
}
}
Event::Line(line) => self.source_navigation.line = line,
Event::Go => match self
.source_navigation
.destination(self.source_editor.line_count())
{
Ok(cursor) => self.source_jump = Some(cursor),
Err(error) => self.status = error,
},
_ => {}
}
}
Message::Request(pending) => {
if self.dirty() {
self.pending = Some(pending);
} else {
return self.proceed(pending);
}
}
Message::Cancel => {
self.pending = None;
self.continue_after_save = false;
}
Message::Discard => {
if let Some(p) = self.pending.take() {
return self.proceed(p);
}
}
Message::SaveAndContinue => {
self.continue_after_save = true;
return self.handle(Message::Save(false));
}
Message::Save(save_as) => {
if !self.apply() {
self.continue_after_save = false;
self.pending = None;
return Task::none();
}
if !save_as && let Some(path) = self.path.clone() {
return self.save_to(path);
}
return self.choose_file(FileOperation::Save);
}
Message::Saved(result) => {
self.busy = false;
match result {
Ok(Some((path, project, bytes))) => {
self.file_snapshot = Some(bytes);
self.path = Some(path);
if self.project() == &project {
self.session.mark_saved(project);
}
self.status = "Project saved".into();
if self.continue_after_save {
self.continue_after_save = false;
if let Some(p) = self.pending.take() {
return self.proceed(p);
}
}
}
Ok(None) => {
self.status = "Save cancelled".into();
self.continue_after_save = false;
}
Err(e) => {
self.status = e;
self.continue_after_save = false;
}
}
}
Message::Loaded(result) => {
self.busy = false;
match result {
Ok(Some((path, decoded, bytes))) => {
self.reset(decoded.project, Some(path));
self.migration = decoded.migration;
if self.migration.is_some() {
self.status = "Imported earlier schema · review migration details in Model checks before saving".into();
self.page = Page::Validation;
}
self.file_snapshot = Some(bytes);
}
Ok(None) => self.status = "Open cancelled".into(),
Err(e) => self.status = e,
}
}
Message::FilePath(value) => {
if let Some(p) = &mut self.file_prompt {
p.path = value;
p.overwrite = false;
}
}
Message::CancelFile => {
self.file_prompt = None;
self.continue_after_save = false;
}
Message::SubmitFile => {
if let Some(prompt) = &mut self.file_prompt {
if prompt.path.trim().is_empty() {
self.status = "Enter a file path.".into();
return Task::none();
}
let path = PathBuf::from(prompt.path.trim());
if prompt.operation != FileOperation::Open && path.exists() && !prompt.overwrite
{
prompt.overwrite = true;
return Task::none();
}
let operation = prompt.operation;
self.file_prompt = None;
return match operation {
FileOperation::Open => self.load_from(path),
FileOperation::Save => self.save_to(path),
FileOperation::Export => self.export_to(path),
};
}
}
Message::Export => {
if self.apply() {
return self.choose_file(FileOperation::Export);
}
}
Message::Exported(result) => {
self.busy = false;
self.status = match result {
Ok(true) => "Diagram exported as SVG".into(),
Ok(false) => "Export cancelled".into(),
Err(e) => e,
};
}
Message::WindowResized(size) => self.window_size = size,
Message::CanvasResized(size) => {
let first_measurement = self.canvas_size.is_none();
self.canvas_size = Some(size);
if first_measurement || self.fit_to_view {
self.fit();
}
}
}
Task::none()
}
fn add_element(&mut self, child: bool) {
if self.create_kind == Kind::Port {
self.status = "Select an SA function or participant, then choose a typed port action in Properties.".into();
return;
}
let allocated = child
&& self.create_kind.is_work()
&& self
.project()
.elements
.get(&self.selected)
.is_some_and(|e| e.layer() == self.layer && e.kind().is_performer());
let parent = if child && !allocated {
Some(self.selected)
} else {
None
};
let Some(e) = self.project().make_element(
self.create_kind,
self.layer,
&format!("New {}", self.create_kind.label().to_lowercase()),
parent,
) else {
return;
};
let id = e.id;
let mut commands = vec![Command::CreateElement(e)];
let mut diagram_target = self.active_diagram;
let preferred = if self.create_kind.is_work() || self.create_kind.is_performer() {
Some(ProjectionKind::Allocation)
} else if ProjectionKind::Capabilities.includes(self.create_kind) {
Some(ProjectionKind::Capabilities)
} else {
None
};
if self.page == Page::Diagram
&& self
.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
.is_some_and(|d| !d.projection.includes(self.create_kind))
&& let Some(preferred) = preferred
{
diagram_target = self
.project()
.diagrams
.values()
.find(|d| d.layer == self.layer && d.projection == preferred)
.map(|d| d.id);
if diagram_target.is_none() {
let view = Id::new_v4();
commands.push(Command::CreateDiagram(Diagram {
id: view,
alias: self.project().fresh_alias("engineering_view", "view", view),
name: format!("{} {preferred}", self.layer),
description: String::new(),
layer: self.layer,
projection: preferred,
nodes: Default::default(),
}));
diagram_target = Some(view);
}
}
if allocated {
commands.push(Command::Connect(
RelationKind::Allocation,
"Performs".into(),
self.selected,
id,
));
}
if self.create_kind == Kind::Occurrence
&& let Some(owner) = parent.and_then(|id| self.project().elements.get(&id))
&& let Some(steps) = owner.steps()
{
let mut steps = steps.clone();
steps.push(id);
commands.push(Command::SetSteps(owner.id, steps));
}
if self.page == Page::Diagram
&& self.create_kind != Kind::Port
&& let Some(diagram) = diagram_target
&& self
.project()
.diagrams
.get(&diagram)
.is_none_or(|d| d.projection.includes(self.create_kind))
{
let mut p = self.project().clone();
for command in &commands {
if let Command::CreateDiagram(d) = command {
p.diagrams.insert(d.id, d.clone());
}
}
p.elements.insert(
id,
match &commands[0] {
Command::CreateElement(e) => e.clone(),
_ => unreachable!(),
},
);
let owner = if allocated {
Some(self.selected)
} else {
parent
};
// Insertion measures an expanded owner path and commits that reveal
// with the new child, so folded contents cannot overlap the new row.
if let Some(owner) = owner {
let mut expanded = p.diagrams[&diagram].clone();
for node in expanded.nodes.values_mut() {
node.collapsed = false;
}
let expanded_view = oa_core::projection::project_diagram(&p, &expanded);
let mut current = expanded_view
.nodes
.iter()
.find(|n| n.element == owner)
.map(|n| n.id);
while let Some(id) = current {
let node = p
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&id)
.unwrap();
if node.collapsed {
node.collapsed = false;
commands.push(Command::EditNode {
diagram,
original: id,
node: node.clone(),
});
}
current = expanded_view
.nodes
.iter()
.find(|n| n.id == id)
.and_then(|n| n.visual_parent);
}
}
// Reserve the expanded size of folded child containers so inserting
// a sibling does not overlap their contents when they are unfolded.
let mut expanded = p.diagrams[&diagram].clone();
for node in expanded.nodes.values_mut() {
node.collapsed = false;
}
let view = oa_core::projection::project_diagram(&p, &expanded);
let position =
if let Some(container) = view.nodes.iter().find(|n| Some(n.element) == owner) {
let y = view
.nodes
.iter()
.filter(|n| n.visual_parent == Some(container.id))
.map(|n| n.position.y + n.size.height + 16.)
.fold(container.position.y + 64., f32::max);
Position {
x: container.position.x + 24.,
y,
}
} else {
Position {
x: view
.nodes
.iter()
.map(|n| n.position.x + n.size.width + 240.)
.fold(60., f32::max),
y: 60.,
}
};
let node = p.place(diagram, id, position);
commands.push(Command::CreateNode {
diagram,
node: p.diagrams[&diagram].nodes[&node].clone(),
});
}
if self.page == Page::Diagram
&& let Some(diagram) = diagram_target
&& let Ok(candidate) = execute_batch(self.project(), &commands)
{
commands.extend(crate::diagram::insertion_adjustments(
&candidate, diagram, id,
));
}
if self.execute(Command::Batch(commands)) {
self.active_diagram = diagram_target;
self.selected = id;
self.inspector_tab = crate::navigation::InspectorTab::Properties;
self.refresh_draft();
if self.page == Page::Diagram {
self.fit();
}
self.status = "Element created · enter its engineering name and properties".into();
}
}
fn add_relation(&mut self) {
self.workflow = Some(crate::workflow::Workflow::relationship(
self.project(),
self.selected,
self.layer,
));
}
fn add_trace(&mut self) {
match crate::workflow::Workflow::manual_trace(self.project(), self.selected) {
Ok(workflow) => self.workflow = Some(workflow),
Err(e) => self.status = e,
}
}
fn proceed(&mut self, pending: Pending) -> Task<Message> {
self.pending = None;
match pending {
Pending::New => {
self.reset(Project::empty(), None);
Task::none()
}
Pending::Demo => {
self.reset(Project::demo(), None);
Task::none()
}
Pending::Open => self.choose_file(FileOperation::Open),
Pending::Close(id) => window::close(id),
}
}
fn choose_file(&mut self, operation: FileOperation) -> Task<Message> {
if cfg!(target_os = "linux") || operation == FileOperation::Export {
self.file_prompt = Some(FilePrompt {
operation,
path: self
.path
.clone()
.unwrap_or_else(|| {
PathBuf::from(if operation == FileOperation::Export {
"architecture.svg"
} else {
"system.arcadia"
})
})
.with_extension(if operation == FileOperation::Export {
"svg"
} else {
"arcadia"
})
.to_string_lossy()
.into_owned(),
overwrite: false,
});
return Task::none();
}
self.busy = true;
if operation == FileOperation::Open {
return Task::perform(
async {
let file = rfd::AsyncFileDialog::new()
.add_filter("Arcadia model", &["arcadia", "json"])
.pick_file()
.await;
match file {
None => Ok(None),
Some(file) => {
let path = file.path().to_path_buf();
tokio::task::spawn_blocking(move || read_snapshot(path).map(Some))
.await
.map_err(|e| e.to_string())?
}
}
},
Message::Loaded,
);
}
let p = self.project().clone();
Task::perform(
async move {
let file = rfd::AsyncFileDialog::new()
.add_filter("Arcadia source", &["arcadia"])
.add_filter("Model JSON", &["json"])
.set_file_name("system.arcadia")
.save_file()
.await;
match file {
None => Ok(None),
Some(file) => {
let path = file.path().to_path_buf();
tokio::task::spawn_blocking(move || {
persistence::save_checked(&path, &p, None)
.map(|bytes| Some((path, p, Arc::from(bytes))))
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
}
},
Message::Saved,
)
}
fn load_from(&mut self, path: PathBuf) -> Task<Message> {
self.busy = true;
Task::perform(
async move {
tokio::task::spawn_blocking(move || read_snapshot(path).map(Some))
.await
.map_err(|e| e.to_string())?
},
Message::Loaded,
)
}
fn save_to(&mut self, path: PathBuf) -> Task<Message> {
let expected = if self.path.as_ref() == Some(&path) {
self.file_snapshot.clone()
} else {
None
};
self.busy = true;
let p = self.project().clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
persistence::save_checked(&path, &p, expected.as_deref())
.map(|bytes| Some((path, p, Arc::from(bytes))))
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
},
Message::Saved,
)
}
fn export_to(&mut self, path: PathBuf) -> Task<Message> {
let Some(diagram) = self.active_diagram else {
self.status = "Create a diagram first.".into();
return Task::none();
};
let svg = crate::diagram::export_svg(self.project(), diagram);
self.busy = true;
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
std::fs::write(path, svg)
.map(|()| true)
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
},
Message::Exported,
)
}
pub fn issue_count(&self) -> usize {
self.model_issues.len()
}
fn request_lint(&mut self) -> Task<Message> {
self.lint_requested = Some(self.source_version);
self.start_pending_lint()
}
fn start_pending_lint(&mut self) -> Task<Message> {
if self.lint_running.is_some() {
return Task::none();
}
let Some(version) = self.lint_requested.take() else {
return Task::none();
};
if version != self.source_version {
return Task::none();
}
self.lint_running = Some(version);
let source = self.source_snapshot.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || language::lint(&source))
.await
.map_err(|error| error.to_string())
},
move |result| Message::Linted(version, result),
)
}
pub fn fit(&mut self) {
self.fit_to_view = true;
let view = self
.active_diagram
.map(|id| project_view(self.project(), id))
.unwrap_or_default();
if view.nodes.is_empty() {
self.zoom = 1.;
self.pan = iced::Vector::new(30., 30.);
return;
}
let left = view
.nodes
.iter()
.map(|n| n.position.x)
.fold(f32::INFINITY, f32::min);
let top = view
.nodes
.iter()
.map(|n| n.position.y)
.fold(f32::INFINITY, f32::min);
let right = view
.nodes
.iter()
.map(|n| n.position.x + n.size.width)
.fold(f32::NEG_INFINITY, f32::max);
let bottom = view
.nodes
.iter()
.map(|n| n.position.y + n.size.height)
.fold(f32::NEG_INFINITY, f32::max);
let size = self.canvas_size.unwrap_or_else(|| {
iced::Size::new(
self.window_size.width - 230. - 320.,
self.window_size.height - 284.,
)
});
let width = (size.width - 48.).max(1.);
let height = (size.height - 48.).max(1.);
self.zoom = (width / (right - left).max(1.))
.min(height / (bottom - top).max(1.))
.clamp(0.0001, 1.5);
self.pan = iced::Vector::new(
24. + (width - (right - left) * self.zoom) / 2. - left * self.zoom,
24. + (height - (bottom - top) * self.zoom) / 2. - top * self.zoom,
);
}
}
fn preferred_diagram(p: &Project, active: Option<Id>, layer: Layer, element: Id) -> Option<Id> {
if let Some(owner) = p
.elements
.get(&element)
.filter(|e| e.kind() == Kind::Port)
.and_then(|e| e.parent)
{
return p
.diagrams
.values()
.filter(|d| d.layer == layer)
.min_by_key(|d| {
let view = project_view(p, d.id);
(
!d.projection.includes(p.elements[&owner].kind()),
!view.ports.iter().any(|port| port.element == element),
!d.nodes.values().any(|node| node.element == owner),
active != Some(d.id),
)
})
.map(|d| d.id);
}
p.diagrams
.values()
.filter(|d| d.layer == layer)
.min_by_key(|d| {
(
!p.elements
.get(&element)
.is_none_or(|e| d.projection.includes(e.kind())),
!d.nodes.values().any(|n| n.element == element),
d.projection != ProjectionKind::Allocation,
)
})
.map(|d| d.id)
}
fn reveal_port_owner(p: &mut Project, diagram: Id, element: Id) {
let Some(owner) = p
.elements
.get(&element)
.filter(|e| e.kind() == Kind::Port)
.and_then(|e| e.parent)
else {
return;
};
let Some(owner_node) = p.diagrams[&diagram]
.nodes
.values()
.find(|n| n.element == owner)
.map(|n| n.id)
else {
return;
};
let unfold: Vec<_> = p.diagrams[&diagram]
.nodes
.values()
.filter(|n| {
n.collapsed && projection::descendant_nodes(p, diagram, n.id).contains(&owner_node)
})
.map(|n| n.id)
.collect();
for id in unfold {
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&id)
.unwrap()
.collapsed = false;
}
}
fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
let decoded = persistence::decode_with_report(&bytes).map_err(|e| e.to_string())?;
Ok((path, decoded, Arc::from(bytes)))
}
#[cfg(test)]
mod port_view_tests {
use super::*;
#[test]
fn filtered_active_view_cannot_hide_a_created_port_when_eligible_owner_view_is_folded() {
let mut p = Project::empty();
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let function = p
.add_element(Kind::Function, Layer::Sa, "Function", None)
.unwrap();
p.relate(RelationKind::Allocation, "Performs", system, function);
let architecture = p.ensure_diagram(Layer::Sa);
p.diagrams.get_mut(&architecture).unwrap().projection = ProjectionKind::Allocation;
let container = p.place(architecture, system, Position { x: 40., y: 40. });
p.place(architecture, function, Position { x: 70., y: 130. });
let capabilities = Id::new_v4();
p.diagrams.insert(
capabilities,
Diagram {
id: capabilities,
alias: "filtered_view".into(),
name: "Filtered view".into(),
description: String::new(),
layer: Layer::Sa,
projection: ProjectionKind::Capabilities,
nodes: Default::default(),
},
);
p.place(capabilities, system, Position { x: 40., y: 40. });
p.place(capabilities, function, Position { x: 70., y: 130. });
p.diagrams
.get_mut(&architecture)
.unwrap()
.nodes
.get_mut(&container)
.unwrap()
.collapsed = true;
let mut app = App::with_project(p.clone());
let _ = app.update(Message::Select(function));
app.active_diagram = Some(capabilities);
app.refresh_draft();
let _ = app.update(Message::PortAction(crate::port_workflow::Action::Output));
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
assert!(app.workflow.is_none(), "{}", app.status);
assert_eq!(app.active_diagram, Some(architecture));
assert!(
project_view(app.project(), architecture)
.ports
.iter()
.any(|port| port.element == app.selected)
);
assert!(!app.project().diagrams[&architecture].nodes[&container].collapsed);
let _ = app.update(Message::Undo);
assert_eq!(
app.project(),
&p,
"Creation and the eligible owner reveal remain one transaction"
);
}
}
#[cfg(test)]
mod lint_tests {
use super::*;
fn edit(app: &mut App, value: &str) {
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(value.into())),
)));
}
#[test]
fn lint_coalesces_work_and_rejects_obsolete_results() {
let mut app = App::with_project(Project::empty());
let _ = app.request_lint();
let first = app.source_version;
edit(&mut app, "?");
let latest = app.source_version;
let _ = app.update(Message::Lint(latest));
assert_eq!(app.lint_running, Some(first));
assert_eq!(app.lint_requested, Some(latest));
let errors = language::lint("?");
let _ = app.update(Message::Linted(first, Ok(errors.clone())));
assert!(app.source_diagnostics.is_empty());
assert_eq!(app.lint_running, Some(latest));
let _ = app.update(Message::Linted(latest, Ok(errors.clone())));
assert_eq!(app.source_diagnostics, errors);
assert_eq!(app.lint_running, None);
assert_eq!(app.source_editor.text(), "?");
}
#[test]
fn prior_document_lint_cannot_enter_reopened_model_or_block_next_checks() {
let mut app = App::with_project(Project::empty());
edit(&mut app, "?");
let _ = app.request_lint();
let old_version = app.source_version;
app.reset(Project::empty(), None);
assert!(app.source_version > old_version);
let _ = app.request_lint();
app.busy = true;
let _ = app.update(Message::Linted(old_version, Ok(language::lint("?"))));
assert!(app.source_diagnostics.is_empty());
assert_eq!(app.lint_running, Some(app.source_version));
let _ = app.update(Message::Linted(app.source_version, Ok(vec![])));
assert_eq!(app.lint_running, None);
}
#[test]
fn committed_check_cache_follows_edit_and_undo_while_drafts_stay_separate() {
let mut app = App::with_project(Project::empty());
let original = app.issue_count();
let element = app
.project()
.make_element(Kind::Function, Layer::Sa, "Assess request", None)
.unwrap();
let _ = app.update(Message::Command(Command::CreateElement(element)));
assert!(
app.model_issues
.iter()
.any(|issue| issue.code == "missing_system")
);
assert_eq!(app.issue_count(), validate(app.project()).len());
let after = app.issue_count();
edit(&mut app, "?");
assert_eq!(app.issue_count(), after);
let _ = app.update(Message::RevertSource);
let _ = app.update(Message::Undo);
assert_eq!(app.issue_count(), original);
assert!(app.project().elements.is_empty());
}
}