2100 lines
78 KiB
Rust
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());
|
|
}
|
|
}
|