4379 lines
173 KiB
Rust
4379 lines
173 KiB
Rust
use crate::forms::Draft;
|
||
use crate::{
|
||
document::Document,
|
||
source_workspace::{self, LIMITS, Workspace},
|
||
};
|
||
use iced::{Element, Subscription, Task, keyboard, widget::text_editor, window};
|
||
use oa_core::{projection::project_view, validation::validate, *};
|
||
use oa_core::{
|
||
source_files::{self, DiskProject},
|
||
source_project::{CompiledSourceProject, SourceDiagnostic, SourceLocation},
|
||
};
|
||
use serde_json::{Value, json};
|
||
use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
|
||
|
||
#[cfg(test)]
|
||
mod direct_scenario_tests {
|
||
use super::*;
|
||
use crate::behavior::{
|
||
self,
|
||
scenario::{self, CanvasEvent, Event, MessageStyle, Slot},
|
||
};
|
||
fn setup() -> (App, scenario::direct_tests::Fixture) {
|
||
let f = scenario::direct_tests::fixture();
|
||
let mut app = App::with_project(f.project.clone());
|
||
let _ = app.update(Message::Select(f.owner));
|
||
let _ = app.update(Message::Page(Page::Behavior));
|
||
assert_eq!(app.scenario_context().unwrap().scenario, f.owner);
|
||
(app, f)
|
||
}
|
||
fn form(app: &mut App, event: Event) {
|
||
let _ = app.update(Message::Behavior(behavior::Event::Scenario(event)));
|
||
}
|
||
fn begin(app: &App, f: &scenario::direct_tests::Fixture) -> CanvasEvent {
|
||
CanvasEvent::Begin {
|
||
context: app.scenario_context().unwrap(),
|
||
slot: Slot {
|
||
parent: f.owner,
|
||
before: None,
|
||
},
|
||
sender: f.sender,
|
||
receiver: f.receiver,
|
||
style: MessageStyle::Asynchronous,
|
||
}
|
||
}
|
||
#[test]
|
||
fn direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context() {
|
||
let (mut app, f) = setup();
|
||
let original = app.document.bundle().clone();
|
||
let event = begin(&app, &f);
|
||
let context = app.scenario_context().unwrap();
|
||
let size = app.window_size;
|
||
let _ = app.update(Message::WindowResized(size));
|
||
assert_eq!(app.scenario_context(), Some(context));
|
||
let _ = app.update(Message::WindowResized(iced::Size::new(
|
||
size.width + 20.,
|
||
size.height,
|
||
)));
|
||
assert_ne!(app.scenario_context(), Some(context));
|
||
let _ = app.update(Message::ScenarioCanvas(event));
|
||
assert!(!app.behavior.has_pending());
|
||
let context = app.scenario_context().unwrap();
|
||
for (area, offset) in [
|
||
(scenario::ScrollArea::Canvas, iced::Vector::new(30., 150.)),
|
||
(scenario::ScrollArea::Page, iced::Vector::new(0., 90.)),
|
||
] {
|
||
let before = app.scenario_context().unwrap();
|
||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||
context, area, offset,
|
||
)));
|
||
assert_ne!(app.scenario_context(), Some(before));
|
||
let current = app.scenario_context().unwrap();
|
||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||
context, area, offset,
|
||
)));
|
||
assert_eq!(
|
||
app.scenario_context(),
|
||
Some(current),
|
||
"unchanged redraw notification retains context"
|
||
);
|
||
}
|
||
let event = begin(&app, &f);
|
||
let _ = app.update(Message::ScenarioCanvas(event));
|
||
form(&mut app, Event::Name("Keep my draft".into()));
|
||
let context = app.scenario_context().unwrap();
|
||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||
context,
|
||
scenario::ScrollArea::Page,
|
||
iced::Vector::new(0., 200.),
|
||
)));
|
||
assert!(app.behavior.has_pending());
|
||
assert_eq!(app.document.bundle(), &original);
|
||
let current = app.scenario_context().unwrap();
|
||
for dimension in 0..3 {
|
||
let mut stale = current;
|
||
match dimension {
|
||
0 => stale.document = Id::new_v4(),
|
||
1 => stale.scenario = Id::new_v4(),
|
||
_ => stale.revision += 1,
|
||
}
|
||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||
stale,
|
||
scenario::ScrollArea::Canvas,
|
||
iced::Vector::new(0., 900.),
|
||
)));
|
||
assert_eq!(app.scenario_context(), Some(current));
|
||
}
|
||
assert!(!app.document.can_undo());
|
||
}
|
||
#[test]
|
||
fn direct_scenario_rejects_every_stale_context_and_preserves_property_drafts() {
|
||
let (mut app, f) = setup();
|
||
let before = app.document.snapshot();
|
||
for dimension in 0..4 {
|
||
let mut event = begin(&app, &f);
|
||
let CanvasEvent::Begin { context, .. } = &mut event else {
|
||
unreachable!()
|
||
};
|
||
match dimension {
|
||
0 => context.document = Id::new_v4(),
|
||
1 => context.scenario = Id::new_v4(),
|
||
2 => context.revision += 1,
|
||
_ => context.epoch += 1,
|
||
}
|
||
let _ = app.update(Message::ScenarioCanvas(event));
|
||
assert!(!app.behavior.has_pending());
|
||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||
}
|
||
let _ = app.update(Message::Field(
|
||
"/name".into(),
|
||
"Pending scenario name".into(),
|
||
));
|
||
assert!(app.draft_dirty());
|
||
let event = begin(&app, &f);
|
||
let _ = app.update(Message::ScenarioCanvas(event));
|
||
assert!(!app.behavior.has_pending());
|
||
assert!(app.draft_dirty());
|
||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||
}
|
||
#[test]
|
||
fn direct_scenario_creation_and_reorder_preserve_selection_and_each_undo_once() {
|
||
let (mut app, f) = setup();
|
||
let original = app.document.bundle().clone();
|
||
let event = begin(&app, &f);
|
||
let _ = app.update(Message::ScenarioCanvas(event));
|
||
form(
|
||
&mut app,
|
||
Event::Exchange(Choice {
|
||
id: f.exchange,
|
||
label: "Contract".into(),
|
||
}),
|
||
);
|
||
form(&mut app, Event::Submit);
|
||
assert!(!app.behavior.has_pending());
|
||
let created = app.selected;
|
||
assert_ne!(created, f.owner);
|
||
assert_eq!(
|
||
app.project().elements[&f.owner].steps().unwrap(),
|
||
&[f.call, f.reply, created]
|
||
);
|
||
let before_reorder = app.document.bundle().clone();
|
||
let context = app.scenario_context().unwrap();
|
||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select(
|
||
context, f.sender,
|
||
)));
|
||
assert_eq!(
|
||
app.scenario_context(),
|
||
Some(context),
|
||
"same-owner selection retains gesture context"
|
||
);
|
||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder {
|
||
context,
|
||
occurrence: created,
|
||
slot: Slot {
|
||
parent: f.owner,
|
||
before: Some(f.call),
|
||
},
|
||
}));
|
||
assert_eq!(app.selected, created);
|
||
assert_eq!(
|
||
app.project().elements[&f.owner].steps().unwrap(),
|
||
&[created, f.call, f.reply]
|
||
);
|
||
let _ = app.update(Message::Undo);
|
||
assert_eq!(app.document.bundle(), &before_reorder);
|
||
let _ = app.update(Message::Undo);
|
||
assert_eq!(app.document.bundle(), &original);
|
||
assert_eq!(app.project(), &f.project);
|
||
assert!(!app.document.can_undo());
|
||
}
|
||
#[test]
|
||
fn direct_scenario_source_failure_and_navigation_keep_form_until_success() {
|
||
let (mut app, f) = setup();
|
||
let bundle = app.document.bundle().clone();
|
||
let mut limits = oa_core::source_project::ProjectLimits::default();
|
||
limits.model.max_source_bytes =
|
||
bundle.files.values().map(String::len).sum::<usize>() + 4000;
|
||
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||
app.sync();
|
||
let event = begin(&app, &f);
|
||
let _ = app.update(Message::ScenarioCanvas(event));
|
||
form(
|
||
&mut app,
|
||
Event::Exchange(Choice {
|
||
id: f.exchange,
|
||
label: "Contract".into(),
|
||
}),
|
||
);
|
||
form(&mut app, Event::Name("Long message".repeat(1024)));
|
||
let _ = app.update(Message::Page(Page::Catalog));
|
||
assert_eq!(app.page, Page::Behavior);
|
||
form(&mut app, Event::Submit);
|
||
assert!(app.behavior.has_pending());
|
||
assert_eq!(app.document.bundle(), &bundle);
|
||
assert!(!app.document.can_undo());
|
||
form(&mut app, Event::Name("Recovered message".into()));
|
||
form(&mut app, Event::Submit);
|
||
assert!(!app.behavior.has_pending());
|
||
assert_eq!(app.project().named(app.selected), "Recovered message");
|
||
let _ = app.update(Message::Undo);
|
||
assert_eq!(app.document.bundle(), &bundle);
|
||
assert!(!app.document.can_undo());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod behavior_lifecycle_tests;
|
||
mod state_diagram;
|
||
#[cfg(test)]
|
||
mod state_diagram_tests;
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub enum LoadedSnapshot {
|
||
Source(DiskProject),
|
||
Legacy(PathBuf, Box<persistence::DecodeResult>, Arc<[u8]>),
|
||
}
|
||
#[derive(Debug, Clone)]
|
||
pub struct FileSnapshot {
|
||
pub document: Id,
|
||
pub revision: u64,
|
||
pub snapshot: Arc<CompiledSourceProject>,
|
||
pub disk: Option<DiskProject>,
|
||
pub path: PathBuf,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct SaveFailure {
|
||
pub document: Id,
|
||
pub message: String,
|
||
pub recovery: Option<PathBuf>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct RecoverySnapshot {
|
||
pub target: PathBuf,
|
||
/// Recovery already succeeded even when reloading its files fails.
|
||
pub disk: Result<DiskProject, String>,
|
||
}
|
||
|
||
#[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 {
|
||
PortAnchor(crate::port_edit::Event),
|
||
Connection(crate::connection_edit::Event),
|
||
Menu(crate::model_menu::Event),
|
||
CreateContextElement(Kind, bool),
|
||
SourceTree(crate::source_tree::Event),
|
||
SourceTreeCollapse,
|
||
SourceTreeLocate,
|
||
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),
|
||
ExplorerGroup(crate::explorer::Group),
|
||
ExplorerCollapseAll,
|
||
ExplorerLocate,
|
||
Explore(Option<Id>),
|
||
BrowseGroup(crate::explorer::Group),
|
||
ExplorerPage(usize),
|
||
CreateKind(Kind),
|
||
Data(crate::data_view::Event),
|
||
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),
|
||
FieldAppend(String, Value),
|
||
FieldValuePath(String, crate::data_values::FieldPathKey),
|
||
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||
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),
|
||
BehaviorOwner(Choice),
|
||
BehaviorDiagram(Choice),
|
||
StateDiagram(crate::behavior::state_edit::Event),
|
||
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
|
||
PlaceSelected,
|
||
HideSelected,
|
||
ToggleCollapse,
|
||
MoveNode(Id, Position),
|
||
Route(crate::route_edit::Event),
|
||
Zoom(f32),
|
||
Pan(iced::Vector),
|
||
Fit,
|
||
SourceEdit(text_editor::Action),
|
||
ApplySource,
|
||
RevertSource,
|
||
SourceUndo,
|
||
SourceRedo,
|
||
SourceFile(String),
|
||
SourceNewPath(String),
|
||
SourceCreateFile,
|
||
SourceCloseFile(String),
|
||
SourceLoadIncludes,
|
||
SourceRecover,
|
||
SourceRecovered(Result<RecoverySnapshot, String>),
|
||
IncludesLoaded(u64, Result<DiskProject, String>),
|
||
Lint(u64),
|
||
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
|
||
SourceDiagnostic(usize),
|
||
SourceRelated(SourceLocation),
|
||
SourceViewport,
|
||
SourceForSelection,
|
||
SourceNavigation(crate::source_navigation::Event),
|
||
Save(bool),
|
||
SavePath(Option<PathBuf>),
|
||
Saved(Result<Option<FileSnapshot>, SaveFailure>),
|
||
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 connection: Option<crate::connection_edit::Session>,
|
||
pub model_menu: Option<crate::model_menu::Opened>,
|
||
pub source_tree: crate::source_tree::State,
|
||
menu_sequence: u64,
|
||
pub canvas_document: Id,
|
||
pub canvas_epoch: u64,
|
||
pub selected_bend: Option<(Id, usize)>,
|
||
pub exact_strings: bool,
|
||
pub document: Document,
|
||
pub page: Page,
|
||
pub guide: crate::guide::State,
|
||
pub behavior: crate::behavior::State,
|
||
pub data_state: crate::data_view::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>,
|
||
/// A disk transaction requiring an explicit action, independent of lint.
|
||
pub pending_recovery: Option<PathBuf>,
|
||
disk_project: Option<DiskProject>,
|
||
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,
|
||
pub source_workspace: Workspace,
|
||
pub source_snapshot: Arc<str>,
|
||
pub source_diagnostics: Vec<SourceDiagnostic>,
|
||
pub source_jump: Option<text_editor::Cursor>,
|
||
source_select_line: bool,
|
||
pub source_navigation: crate::source_navigation::State,
|
||
pub model_issues: Vec<validation::Issue>,
|
||
pub migration: Option<persistence::MigrationReport>,
|
||
source_version: u64,
|
||
lint_running: Option<u64>,
|
||
lint_requested: Option<u64>,
|
||
pub explorer_collapsed: BTreeSet<Id>,
|
||
pub explorer_groups_collapsed: BTreeSet<crate::explorer::Group>,
|
||
pub explorer_index: crate::explorer::Index,
|
||
pub explorer_focus: Option<Id>,
|
||
pub explorer_group: Option<crate::explorer::Group>,
|
||
pub explorer_page: usize,
|
||
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 document = Document::from_project(project.clone(), LIMITS)
|
||
.expect("Application models are valid source projects");
|
||
let source_workspace = Workspace::new(document.bundle(), true);
|
||
let source = source_workspace.text().to_owned();
|
||
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);
|
||
let explorer_index = crate::explorer::Index::new(&project, layer);
|
||
let explorer_collapsed = project.elements.values().filter_map(|e| e.parent).collect();
|
||
Self {
|
||
connection: None,
|
||
model_menu: None,
|
||
source_tree: Default::default(),
|
||
menu_sequence: 0,
|
||
canvas_document: Id::new_v4(),
|
||
canvas_epoch: 0,
|
||
selected_bend: None,
|
||
exact_strings: false,
|
||
document,
|
||
page: Page::Diagram,
|
||
guide: crate::guide::State::default(),
|
||
behavior: Default::default(),
|
||
data_state: 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,
|
||
pending_recovery: None,
|
||
disk_project: 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_workspace,
|
||
source_snapshot: Arc::from(source),
|
||
source_diagnostics: vec![],
|
||
source_jump: None,
|
||
source_select_line: false,
|
||
source_navigation: crate::source_navigation::State::default(),
|
||
model_issues,
|
||
migration: None,
|
||
source_version: 0,
|
||
lint_running: None,
|
||
lint_requested: None,
|
||
explorer_collapsed,
|
||
explorer_groups_collapsed: BTreeSet::new(),
|
||
explorer_index,
|
||
explorer_focus: None,
|
||
explorer_group: None,
|
||
explorer_page: 0,
|
||
workflow: None,
|
||
}
|
||
}
|
||
pub fn project(&self) -> &Project {
|
||
self.document.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_workspace.dirty()
|
||
}
|
||
pub fn draft_dirty(&self) -> bool {
|
||
self.draft.as_ref().is_some_and(Draft::dirty)
|
||
}
|
||
pub fn dirty(&self) -> bool {
|
||
self.document.dirty()
|
||
|| self.draft_dirty()
|
||
|| self.source_dirty()
|
||
|| self.behavior.has_pending()
|
||
}
|
||
fn sync_source(&mut self) {
|
||
self.source_workspace.accept(self.document.bundle());
|
||
self.show_source_file(false);
|
||
self.source_diagnostics = self.document.compiled().diagnostics.clone();
|
||
self.source_version += 1;
|
||
self.lint_requested = None;
|
||
}
|
||
fn show_source_file(&mut self, restore_cursor: bool) {
|
||
self.source_tree.reveal(&self.source_workspace.active);
|
||
self.source_select_line = false;
|
||
let source = self.source_workspace.text().to_owned();
|
||
self.source_navigation.refresh(&source);
|
||
self.source_editor = text_editor::Content::with_text(&source);
|
||
self.source_snapshot = Arc::from(source);
|
||
self.source_jump = restore_cursor
|
||
.then(|| {
|
||
self.source_workspace
|
||
.files
|
||
.get(&self.source_workspace.active)
|
||
.map(|b| b.cursor)
|
||
})
|
||
.flatten();
|
||
}
|
||
fn selected_architecture_placement(&self) -> Option<Id> {
|
||
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)
|
||
}
|
||
fn refresh_draft(&mut self) {
|
||
self.selected_node = if self.page == Page::Behavior {
|
||
None
|
||
} else {
|
||
self.selected_architecture_placement()
|
||
};
|
||
if let Some(context) = self.state_context() {
|
||
self.behavior.diagram.placement = self.project().diagrams[&context.diagram]
|
||
.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.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||
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 let Some(focus) = self.explorer_focus {
|
||
self.explorer_group = self.explorer_index.group(focus);
|
||
if self.explorer_group.is_none() {
|
||
self.explorer_focus = None;
|
||
}
|
||
}
|
||
self.reveal_explorer_selection();
|
||
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.behavior.has_pending() {
|
||
self.status =
|
||
"Apply or cancel the behavior form before applying source changes.".into();
|
||
return false;
|
||
}
|
||
if !self.source_dirty() {
|
||
return true;
|
||
}
|
||
if self.draft_dirty() {
|
||
self.status = "Property and source drafts both exist. Revert one draft before applying the other; both are retained.".into();
|
||
return false;
|
||
}
|
||
match source_workspace::compile(&self.source_workspace.bundle()) {
|
||
Ok(compiled) => {
|
||
match self.document.apply_compiled(compiled) {
|
||
Ok(_) => {
|
||
self.sync();
|
||
self.status = if self.source_workspace.detached_dirty() { "Reachable files applied. Reattach or revert detached drafts before saving." } else { "All source files 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.behavior.has_pending() {
|
||
self.status = "Apply or cancel the behavior form before changing the model.".into();
|
||
return false;
|
||
}
|
||
if self.source_dirty() {
|
||
self.status =
|
||
"Apply all source drafts or revert them before changing the model.".into();
|
||
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.document
|
||
.execute(crate::port_edit::repair_owner_change(
|
||
self.document.project(),
|
||
command,
|
||
))
|
||
.map(|_| id)
|
||
.map_err(|e| e.to_string())
|
||
}) {
|
||
Ok(id) => {
|
||
if self.active_diagram == Some(original) {
|
||
self.active_diagram = Some(id);
|
||
}
|
||
if self.behavior.active_owner == Some(original) {
|
||
self.behavior.active_owner = Some(id);
|
||
}
|
||
if self.behavior.diagram.diagram == Some(original) {
|
||
self.behavior.diagram.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;
|
||
if self.page == Page::Behavior
|
||
&& let Some(machine) =
|
||
self.project().diagrams[&diagram].projection.state_machine()
|
||
{
|
||
self.behavior.active_owner = Some(machine);
|
||
self.behavior.diagram.diagram = Some(diagram);
|
||
self.behavior.diagram.placement = node;
|
||
} else {
|
||
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 {
|
||
let command = crate::port_edit::repair_owner_change(self.project(), command);
|
||
let scenario_selection = self.behavior.scenario.created_selection(&command);
|
||
match self.document.execute(command) {
|
||
Ok(selected) => {
|
||
if let Some(id) = scenario_selection.or(selected) {
|
||
self.selected = id;
|
||
}
|
||
self.sync();
|
||
self.status = "Model updated".into();
|
||
true
|
||
}
|
||
Err(e) => {
|
||
self.status = e.to_string();
|
||
false
|
||
}
|
||
}
|
||
}
|
||
fn apply_behavior(&mut self) -> bool {
|
||
if !self.behavior.has_pending() {
|
||
return true;
|
||
}
|
||
if self.source_dirty() || self.draft_dirty() {
|
||
self.status = "Property, source and behavior drafts are retained. Revert the other drafts before applying the behavior form.".into();
|
||
return false;
|
||
}
|
||
match self.behavior.prepare_pending(self.project()) {
|
||
Ok(Some(command)) => {
|
||
if self.execute(command) {
|
||
self.behavior.acknowledge_commit();
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
Ok(None) => true,
|
||
Err(error) => {
|
||
self.status = error;
|
||
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;
|
||
}
|
||
let previous_state_view = self.state_view_identity();
|
||
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();
|
||
}
|
||
if self.page == Page::Behavior
|
||
&& let Some(owner) = crate::behavior::selected_behavior(self.project(), id)
|
||
{
|
||
self.behavior.active_owner = Some(owner);
|
||
}
|
||
if self.reset_state_view_after_navigation(previous_state_view)
|
||
&& self.page == Page::Behavior
|
||
{
|
||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||
}
|
||
self.refresh_draft();
|
||
}
|
||
fn reveal_explorer_selection(&mut self) {
|
||
if let Some(group) = self.explorer_index.group(self.selected) {
|
||
self.explorer_groups_collapsed.remove(&group);
|
||
for id in self.explorer_index.ancestry(self.selected) {
|
||
if id != self.selected {
|
||
self.explorer_collapsed.remove(&id);
|
||
}
|
||
}
|
||
}
|
||
if self
|
||
.explorer_focus
|
||
.is_some_and(|id| !self.project().elements.contains_key(&id))
|
||
{
|
||
self.explorer_focus = None;
|
||
}
|
||
}
|
||
pub fn view(&self) -> Element<'_, Message> {
|
||
crate::context_menu::Popup::new(crate::views::view(self), crate::model_menu::popup(self))
|
||
.into()
|
||
}
|
||
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, status, _| {
|
||
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||
key,
|
||
modifiers: mods,
|
||
..
|
||
}) = event
|
||
else {
|
||
return None;
|
||
};
|
||
if key == keyboard::Key::Named(keyboard::key::Named::Escape) {
|
||
if status == iced::event::Status::Captured {
|
||
return None;
|
||
}
|
||
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> {
|
||
// Scrollable geometry can change while async work or an overlay blocks
|
||
// commands. Keep the observed translation current; its passive handler
|
||
// still validates document, scenario and revision before accepting it.
|
||
if matches!(
|
||
&message,
|
||
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Scrolled(..))
|
||
) {
|
||
return self.handle(message);
|
||
}
|
||
// Global subscriptions also receive keys consumed by an overlay.
|
||
if self.model_menu.is_some() && matches!(message, Message::Escape) {
|
||
self.model_menu = None;
|
||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||
return Task::none();
|
||
}
|
||
if self.model_menu.is_some()
|
||
&& !matches!(
|
||
message,
|
||
Message::Menu(_)
|
||
| Message::WindowResized(_)
|
||
| Message::CanvasResized(_)
|
||
| Message::Lint(_)
|
||
| Message::Linted(_, _)
|
||
| Message::SourceViewport
|
||
| Message::Request(Pending::Close(_))
|
||
)
|
||
{
|
||
return Task::none();
|
||
}
|
||
if matches!(
|
||
message,
|
||
Message::WindowResized(_) | Message::Request(Pending::Close(_))
|
||
) {
|
||
self.model_menu = None;
|
||
}
|
||
if (self.source_dirty() || self.behavior.has_pending())
|
||
&& matches!(
|
||
message,
|
||
Message::Field(..)
|
||
| Message::FieldText(..)
|
||
| Message::FieldShape(..)
|
||
| Message::FieldAppend(..)
|
||
| Message::FieldValuePath(..)
|
||
| Message::FieldVariant(..)
|
||
| Message::FieldArray(..)
|
||
| Message::ElementKind(_)
|
||
| Message::RelationKind(_)
|
||
)
|
||
{
|
||
self.status =
|
||
"Apply or cancel pending source and behavior drafts before editing properties."
|
||
.into();
|
||
return Task::none();
|
||
}
|
||
if self.busy
|
||
&& !matches!(
|
||
message,
|
||
Message::Saved(_)
|
||
| Message::Loaded(_)
|
||
| Message::IncludesLoaded(_, _)
|
||
| Message::SavePath(_)
|
||
| Message::SourceRecovered(_)
|
||
| 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::IncludesLoaded(_, _)
|
||
| Message::SavePath(_)
|
||
| Message::SourceRecovered(_)
|
||
| 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();
|
||
}
|
||
if matches!(
|
||
message,
|
||
Message::Page(_) | Message::Layer(_) | Message::Diagram(_)
|
||
) {
|
||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||
self.selected_bend = None;
|
||
}
|
||
if self.behavior.has_pending()
|
||
&& matches!(
|
||
message,
|
||
Message::Page(page) if page != Page::Behavior
|
||
)
|
||
{
|
||
self.status = "Apply or cancel the behavior form before leaving this view.".into();
|
||
return Task::none();
|
||
}
|
||
if self.behavior.has_pending()
|
||
&& matches!(
|
||
message,
|
||
Message::BrowseGroup(_)
|
||
| Message::SourceEdit(_)
|
||
| Message::SourceCreateFile
|
||
| Message::SourceCloseFile(_)
|
||
| Message::SourceUndo
|
||
| Message::SourceRedo
|
||
| Message::SourceRecover
|
||
| Message::SourceLoadIncludes
|
||
| Message::Menu(_)
|
||
| Message::Connection(_)
|
||
| Message::PortAnchor(_)
|
||
| Message::Route(_)
|
||
)
|
||
{
|
||
self.status = "Apply or cancel the behavior form before changing another view.".into();
|
||
return Task::none();
|
||
}
|
||
let previous = self.selected;
|
||
let previous_layer = self.layer;
|
||
let entering_catalog =
|
||
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
|
||
let mut task = self.handle(message);
|
||
if self.connection.as_ref().is_some_and(|session| {
|
||
Some(session.context) != self.route_context()
|
||
|| self.page != Page::Diagram
|
||
|| self.draft_dirty()
|
||
|| self.source_dirty()
|
||
}) {
|
||
self.connection = None;
|
||
}
|
||
if previous_layer != self.layer {
|
||
self.data_state = Default::default();
|
||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||
self.explorer_focus = None;
|
||
self.explorer_group = None;
|
||
self.explorer_page = 0;
|
||
task = task.chain(Task::batch(["model-explorer", "model-browser"].map(|id| {
|
||
iced::widget::operation::snap_to(
|
||
id,
|
||
iced::widget::scrollable::RelativeOffset::START,
|
||
)
|
||
})));
|
||
}
|
||
self.normalize_creation();
|
||
if self.page == Page::Catalog && (self.selected != previous || entering_catalog) {
|
||
self.explorer_focus = self
|
||
.project()
|
||
.elements
|
||
.get(&self.selected)
|
||
.filter(|element| element.layer() == self.layer)
|
||
.map(|element| element.id);
|
||
self.explorer_group = self.explorer_index.group(self.selected);
|
||
self.explorer_page = 0;
|
||
}
|
||
if self.selected != previous {
|
||
self.reveal_explorer_selection();
|
||
task.chain(iced::widget::operation::snap_to(
|
||
"inspector",
|
||
iced::widget::scrollable::RelativeOffset::START,
|
||
))
|
||
} else {
|
||
task
|
||
}
|
||
}
|
||
pub(crate) fn scenario_context(&self) -> Option<crate::behavior::scenario::Context> {
|
||
let scenario = crate::behavior::selected_owner(self)?;
|
||
(self.page == Page::Behavior
|
||
&& self.project().elements.get(&scenario)?.kind() == Kind::Scenario)
|
||
.then_some(crate::behavior::scenario::Context {
|
||
document: self.canvas_document,
|
||
scenario,
|
||
revision: self.document.revision(),
|
||
epoch: self.canvas_epoch,
|
||
})
|
||
}
|
||
fn handle(&mut self, message: Message) -> Task<Message> {
|
||
match message {
|
||
Message::ScenarioCanvas(event) => {
|
||
use crate::behavior::scenario::{self, CanvasEvent};
|
||
let context = event.context();
|
||
if let CanvasEvent::Scrolled(_, area, offset) = event {
|
||
// Inner and outer scroll notifications may come from the
|
||
// same frame. Accept both across the first epoch bump,
|
||
// while still rejecting another owner/document/revision.
|
||
if self.scenario_context().is_some_and(|current| {
|
||
current.document == context.document
|
||
&& current.scenario == context.scenario
|
||
&& current.revision == context.revision
|
||
}) && self.behavior.scenario.scrolled(area, offset)
|
||
{
|
||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||
}
|
||
return Task::none();
|
||
}
|
||
if self.scenario_context() != Some(context) {
|
||
return Task::none();
|
||
}
|
||
match event {
|
||
CanvasEvent::Select(_, id) => {
|
||
if !self.behavior.has_pending()
|
||
&& crate::behavior::selected_behavior(self.project(), id)
|
||
== Some(context.scenario)
|
||
{
|
||
self.select(id);
|
||
self.selected_node = None;
|
||
}
|
||
}
|
||
CanvasEvent::Choose(_, tool) => {
|
||
if self.behavior.has_pending() || !self.apply() {
|
||
return Task::none();
|
||
}
|
||
self.behavior.scenario.choose_tool(context.scenario, tool);
|
||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||
}
|
||
mutation => {
|
||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
|
||
{
|
||
self.status =
|
||
"Apply or cancel pending edits before changing the scenario."
|
||
.into();
|
||
return Task::none();
|
||
}
|
||
match mutation {
|
||
CanvasEvent::Begin {
|
||
slot,
|
||
sender,
|
||
receiver,
|
||
style,
|
||
..
|
||
} => {
|
||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||
let result = state.begin_message(
|
||
self.project(),
|
||
context,
|
||
slot,
|
||
sender,
|
||
receiver,
|
||
style,
|
||
);
|
||
self.behavior.scenario = state;
|
||
match result {
|
||
Ok(()) => {
|
||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||
self.status = "Choose the message exchange and apply at the highlighted sequence position.".into();
|
||
}
|
||
Err(error) => self.status = error,
|
||
}
|
||
}
|
||
CanvasEvent::Reorder {
|
||
occurrence, slot, ..
|
||
} => {
|
||
match scenario::reorder(
|
||
self.project(),
|
||
context.scenario,
|
||
occurrence,
|
||
slot,
|
||
) {
|
||
Ok(command) => {
|
||
if self.execute(command) {
|
||
self.selected = occurrence;
|
||
self.selected_node = None;
|
||
self.refresh_draft();
|
||
self.status = "Occurrence reordered within its sequence · Undo restores the previous order".into();
|
||
}
|
||
}
|
||
Err(error) => self.status = error,
|
||
}
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Message::StateDiagram(event) => return self.edit_state_diagram(event),
|
||
Message::BehaviorOwner(choice) => {
|
||
if self.apply()
|
||
&& self.project().elements.get(&choice.id).is_some_and(|e| {
|
||
e.layer() == self.layer
|
||
&& (e.kind().is_sequence() || e.kind() == Kind::StateMachine)
|
||
})
|
||
{
|
||
self.behavior.active_owner = Some(choice.id);
|
||
self.behavior.diagram = Default::default();
|
||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||
self.select(choice.id);
|
||
self.page = Page::Behavior;
|
||
}
|
||
}
|
||
Message::BehaviorDiagram(choice) => {
|
||
if self.apply()
|
||
&& let Some(owner) = crate::behavior::selected_owner(self)
|
||
&& self
|
||
.project()
|
||
.diagrams
|
||
.get(&choice.id)
|
||
.is_some_and(|d| d.projection.state_machine() == Some(owner))
|
||
{
|
||
self.behavior.diagram.diagram = Some(choice.id);
|
||
self.behavior.diagram.tool = crate::behavior::state_edit::Tool::Select;
|
||
self.behavior.diagram.fit = true;
|
||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||
self.fit_state_diagram();
|
||
}
|
||
}
|
||
Message::PortAnchor(event) => {
|
||
use crate::port_edit::Event;
|
||
let (captured, node, port, command) = match event {
|
||
Event::Commit(c, node, anchor) => (
|
||
c,
|
||
node,
|
||
anchor.port,
|
||
Command::SetPortAnchor {
|
||
diagram: c.diagram,
|
||
node,
|
||
anchor,
|
||
},
|
||
),
|
||
Event::Reset(c, node, port) => (
|
||
c,
|
||
node,
|
||
port,
|
||
Command::RemovePortAnchor {
|
||
diagram: c.diagram,
|
||
node,
|
||
port,
|
||
},
|
||
),
|
||
};
|
||
if self.route_context() != Some(captured)
|
||
|| self.page != Page::Diagram
|
||
|| self.draft_dirty()
|
||
|| self.source_dirty()
|
||
|| self.connection.is_some()
|
||
{
|
||
return Task::none();
|
||
}
|
||
let view = project_view(self.project(), captured.diagram);
|
||
if !view
|
||
.ports
|
||
.iter()
|
||
.any(|p| p.element == port && p.owner == node)
|
||
{
|
||
return Task::none();
|
||
}
|
||
if self.execute(command) {
|
||
self.selected_node = None;
|
||
self.status = "Port position updated in this diagram · Undo restores its previous position".into();
|
||
}
|
||
}
|
||
Message::Connection(event) => self.edit_connection(event),
|
||
Message::SourceTree(event) => match event {
|
||
crate::source_tree::Event::ToggleFolder(path) => self.source_tree.toggle(&path),
|
||
crate::source_tree::Event::OpenFile(path) => {
|
||
return self.update(Message::SourceFile(path));
|
||
}
|
||
crate::source_tree::Event::FileMenu(path, anchor) => {
|
||
let mut request = crate::model_menu::Request::object(self.project().id, anchor);
|
||
request.source_file = Some(path);
|
||
return self.update(Message::Menu(crate::model_menu::Event::Open(request)));
|
||
}
|
||
},
|
||
Message::SourceTreeCollapse => self.source_tree.collapse_all(),
|
||
Message::SourceTreeLocate => {
|
||
self.source_tree.reveal(&self.source_workspace.active);
|
||
return iced::widget::operation::scroll_to(
|
||
"source-files",
|
||
crate::source_tree::active_offset(
|
||
&self.source_workspace,
|
||
&self.source_diagnostics,
|
||
&self.source_tree,
|
||
),
|
||
);
|
||
}
|
||
Message::Menu(crate::model_menu::Event::Open(request)) => {
|
||
// Validate the originating canvas before advancing its input
|
||
// epoch. Actions are then generated with the refreshed context,
|
||
// while pointer history hidden behind the menu becomes stale.
|
||
let Some(opened) = crate::model_menu::Opened::new(
|
||
self,
|
||
request,
|
||
self.menu_sequence.wrapping_add(1),
|
||
) else {
|
||
return Task::none();
|
||
};
|
||
self.connection = None;
|
||
self.menu_sequence = opened.token;
|
||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||
let mut request = opened.request;
|
||
if request.canvas.is_some() {
|
||
request.canvas = if self.page == Page::Behavior {
|
||
self.state_context()
|
||
} else {
|
||
self.route_context()
|
||
};
|
||
}
|
||
self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence);
|
||
}
|
||
Message::Menu(crate::model_menu::Event::Close) => {
|
||
if self.model_menu.take().is_some() {
|
||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||
}
|
||
}
|
||
Message::Menu(crate::model_menu::Event::Invoke(token, action)) => {
|
||
if self
|
||
.model_menu
|
||
.as_ref()
|
||
.is_none_or(|menu| menu.token != token)
|
||
{
|
||
return Task::none();
|
||
}
|
||
let Some(menu) = self.model_menu.take() else {
|
||
return Task::none();
|
||
};
|
||
if menu.token != token || !menu.valid(self) {
|
||
return Task::none();
|
||
}
|
||
if menu.request.source_file.is_some() {
|
||
return self.update(*action);
|
||
}
|
||
if self.draft_dirty() || self.source_dirty() {
|
||
self.status =
|
||
"Apply or revert pending edits before using modeling actions.".into();
|
||
return Task::none();
|
||
}
|
||
self.select(menu.request.object);
|
||
self.selected_node = menu.request.placement;
|
||
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
|
||
return self.update(*action);
|
||
}
|
||
Message::CreateContextElement(kind, child) => {
|
||
self.create_kind = kind;
|
||
return self.update(Message::Add(child));
|
||
}
|
||
Message::Escape => {
|
||
if self.connection.take().is_some() {
|
||
self.status = "Drawing cancelled · Select / move is active".into();
|
||
}
|
||
self.source_navigation.open = false;
|
||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||
self.selected_bend = None;
|
||
}
|
||
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::FieldAppend(path, value) => {
|
||
if let Some(draft) = &mut self.draft
|
||
&& let Err(error) = draft.append(&path, value)
|
||
{
|
||
self.status = error;
|
||
}
|
||
}
|
||
Message::FieldValueBrowse(path, scope, page) => {
|
||
if let Some(draft) = &mut self.draft
|
||
&& draft
|
||
.value
|
||
.pointer(&path)
|
||
.is_some_and(|row| row.get("expression").is_some())
|
||
{
|
||
draft
|
||
.value_browsers
|
||
.insert(path, crate::forms::ValueBrowser { scope, page });
|
||
}
|
||
}
|
||
Message::FieldValuePath(path, key) => {
|
||
if let Some(draft) = &mut self.draft
|
||
&& let Err(error) = draft.value_path(&path, key)
|
||
{
|
||
self.status = error;
|
||
}
|
||
}
|
||
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;
|
||
self.explorer_page = 0;
|
||
return Task::batch(["model-browser", "model-explorer"].map(|id| {
|
||
iced::widget::operation::snap_to(
|
||
id,
|
||
iced::widget::scrollable::RelativeOffset::START,
|
||
)
|
||
}));
|
||
}
|
||
Message::Page(page) => {
|
||
if page != Page::Text || !self.draft_dirty() || self.apply() {
|
||
if page == Page::Behavior && self.page != Page::Behavior {
|
||
let previous_state_view = self.state_view_identity();
|
||
if let Some(owner) =
|
||
crate::behavior::selected_behavior(self.project(), self.selected)
|
||
{
|
||
self.behavior.active_owner = Some(owner);
|
||
}
|
||
self.behavior.active_owner = crate::behavior::selected_owner(self);
|
||
self.reset_state_view_after_navigation(previous_state_view);
|
||
}
|
||
self.page = page;
|
||
self.selected_node = if page == Page::Behavior {
|
||
None
|
||
} else {
|
||
self.selected_architecture_placement()
|
||
};
|
||
if page == Page::Text {
|
||
return self.request_lint();
|
||
}
|
||
}
|
||
}
|
||
Message::CollapseExplorer(id) => {
|
||
if !self.explorer_collapsed.remove(&id) {
|
||
self.explorer_collapsed.insert(id);
|
||
}
|
||
}
|
||
Message::ExplorerGroup(group) => {
|
||
if !self.explorer_groups_collapsed.remove(&group) {
|
||
self.explorer_groups_collapsed.insert(group);
|
||
}
|
||
}
|
||
Message::ExplorerCollapseAll => {
|
||
self.explorer_collapsed = self
|
||
.project()
|
||
.elements
|
||
.values()
|
||
.filter_map(|e| e.parent)
|
||
.collect();
|
||
self.explorer_groups_collapsed = crate::explorer::Group::ALL.into_iter().collect();
|
||
self.search.clear();
|
||
return iced::widget::operation::snap_to(
|
||
"model-explorer",
|
||
iced::widget::scrollable::RelativeOffset::START,
|
||
);
|
||
}
|
||
Message::ExplorerLocate => {
|
||
self.search.clear();
|
||
self.reveal_explorer_selection();
|
||
let offset = crate::explorer_view::selection_offset(self);
|
||
return iced::widget::operation::scroll_to("model-explorer", offset);
|
||
}
|
||
Message::Explore(id) => {
|
||
if self.apply() {
|
||
self.explorer_focus = id.filter(|id| self.project().elements.contains_key(id));
|
||
self.explorer_group = self
|
||
.explorer_focus
|
||
.and_then(|id| self.explorer_index.group(id));
|
||
self.search.clear();
|
||
self.explorer_page = 0;
|
||
self.page = Page::Catalog;
|
||
self.select(self.explorer_focus.unwrap_or(self.project().id));
|
||
return iced::widget::operation::snap_to(
|
||
"model-browser",
|
||
iced::widget::scrollable::RelativeOffset::START,
|
||
);
|
||
}
|
||
}
|
||
Message::BrowseGroup(group) => {
|
||
self.explorer_focus = None;
|
||
self.explorer_group = Some(group);
|
||
self.search.clear();
|
||
self.explorer_page = 0;
|
||
self.page = Page::Catalog;
|
||
return iced::widget::operation::snap_to(
|
||
"model-browser",
|
||
iced::widget::scrollable::RelativeOffset::START,
|
||
);
|
||
}
|
||
Message::ExplorerPage(page) => {
|
||
self.explorer_page = page;
|
||
return iced::widget::operation::snap_to(
|
||
"model-browser",
|
||
iced::widget::scrollable::RelativeOffset::START,
|
||
);
|
||
}
|
||
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 stay_in_catalog = self.page == Page::Catalog
|
||
&& matches!(&workflow, crate::workflow::Workflow::Ports(_));
|
||
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();
|
||
}
|
||
if !stay_in_catalog {
|
||
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() {
|
||
let previous_state_view = self.state_view_identity();
|
||
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.behavior.active_owner = crate::behavior::selected_owner(self);
|
||
self.reset_state_view_after_navigation(previous_state_view);
|
||
self.refresh_draft();
|
||
self.fit();
|
||
}
|
||
}
|
||
Message::Data(event) => {
|
||
if self.data_state.update(&event) {
|
||
return Task::none();
|
||
}
|
||
if self.apply() {
|
||
match crate::data_view::command(self.project(), self.layer, event) {
|
||
Ok(command) => {
|
||
self.execute(command);
|
||
}
|
||
Err(error) => self.status = error,
|
||
}
|
||
}
|
||
}
|
||
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.inspector_tab = crate::navigation::InspectorTab::Properties;
|
||
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.starts_form() && !self.behavior.has_pending() && !self.apply() {
|
||
return Task::none();
|
||
}
|
||
if event.is_submit() && (self.source_dirty() || self.draft_dirty()) {
|
||
self.status = "Revert the other drafts before applying the behavior form. Your inputs are retained.".into();
|
||
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)) => {
|
||
if self.execute(command) {
|
||
self.behavior.acknowledge_commit();
|
||
}
|
||
}
|
||
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,
|
||
routes: Vec::new(),
|
||
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 semantic_selection = match &command {
|
||
Command::EditNode { diagram, node, .. }
|
||
if self.state_context().is_some_and(|c| c.diagram == *diagram) =>
|
||
{
|
||
Some(node.element)
|
||
}
|
||
_ => None,
|
||
};
|
||
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,
|
||
};
|
||
if self.execute(command)
|
||
&& let Some(element) = semantic_selection
|
||
{
|
||
self.selected = element;
|
||
self.refresh_draft();
|
||
}
|
||
}
|
||
}
|
||
Message::Delete => {
|
||
if self.selected != self.project().id && self.apply() {
|
||
self.delete_pending = true;
|
||
}
|
||
}
|
||
Message::ConfirmDelete => {
|
||
if !self.delete_pending || self.behavior.has_pending() {
|
||
return Task::none();
|
||
}
|
||
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.document.redo();
|
||
} else {
|
||
self.document.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
|
||
{
|
||
let selected = self.selected;
|
||
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 self.handle(Message::RevealInDiagram(diagram, selected));
|
||
}
|
||
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()
|
||
{
|
||
let mut commands = vec![Command::CreateNode { diagram, node: n }];
|
||
for owner in p.diagrams[&diagram].nodes.values().filter(|n| {
|
||
n.collapsed
|
||
&& projection::descendant_nodes(&p, diagram, n.id).contains(&node)
|
||
}) {
|
||
let mut expanded = owner.clone();
|
||
expanded.collapsed = false;
|
||
commands.push(Command::EditNode {
|
||
diagram,
|
||
original: owner.id,
|
||
node: expanded,
|
||
});
|
||
}
|
||
if self.execute(Command::Batch(commands)) {
|
||
self.selected_node = Some(node);
|
||
return self.handle(Message::RevealInDiagram(diagram, selected));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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::Route(event) => self.edit_route(event),
|
||
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_workspace
|
||
.edit(after.clone(), self.source_editor.cursor());
|
||
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::SourceFile(path) => {
|
||
if self
|
||
.source_workspace
|
||
.switch(&path, self.source_editor.cursor())
|
||
{
|
||
self.show_source_file(true);
|
||
}
|
||
}
|
||
Message::SourceNewPath(path) => self.source_workspace.new_path = path,
|
||
Message::SourceCreateFile => {
|
||
if let Err(error) = self.create_source_file() {
|
||
self.status = error;
|
||
} else {
|
||
return self.request_lint();
|
||
}
|
||
}
|
||
Message::SourceCloseFile(path) => {
|
||
if self.source_workspace.discard_detached(&path) {
|
||
self.source_version += 1;
|
||
self.prune_closed_files(!self.document.dirty());
|
||
self.show_source_file(true);
|
||
}
|
||
}
|
||
Message::SourceLoadIncludes => return self.load_includes(),
|
||
Message::SourceRecover => {
|
||
if !self.source_workspace.applied && self.source_workspace.has_edits() {
|
||
self.status =
|
||
"Revert or preserve your source drafts before reloading recovered files."
|
||
.into();
|
||
return Task::none();
|
||
}
|
||
if let Some(path) = self.pending_recovery.clone() {
|
||
self.busy = true;
|
||
return Task::perform(
|
||
async move {
|
||
tokio::task::spawn_blocking(move || recover_snapshot(path))
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
},
|
||
Message::SourceRecovered,
|
||
);
|
||
}
|
||
}
|
||
Message::SourceRecovered(result) => {
|
||
self.busy = false;
|
||
match result {
|
||
Ok(receipt) => {
|
||
if self.pending_recovery.as_ref() != Some(&receipt.target) {
|
||
return Task::none();
|
||
}
|
||
self.pending_recovery = None;
|
||
self.source_diagnostics
|
||
.retain(|issue| issue.diagnostic.code != "pending_save");
|
||
match receipt.disk {
|
||
Ok(disk) if !self.source_workspace.applied => {
|
||
self.open_source_project(disk)
|
||
}
|
||
Ok(disk) => {
|
||
// Recovering an old Save As target must not redirect
|
||
// the source project that was subsequently saved elsewhere.
|
||
if self.path.is_none()
|
||
|| self.path.as_ref() == Some(&receipt.target)
|
||
{
|
||
self.path = Some(receipt.target);
|
||
self.disk_project = Some(disk);
|
||
}
|
||
self.status = "Interrupted disk save recovered. Current model and drafts are retained; Save all can be retried.".into();
|
||
}
|
||
Err(error) => {
|
||
self.status = format!(
|
||
"Disk recovery completed; files could not be reloaded: {error}"
|
||
)
|
||
}
|
||
}
|
||
}
|
||
Err(error) => self.status = error,
|
||
}
|
||
}
|
||
Message::IncludesLoaded(version, result) => {
|
||
self.busy = false;
|
||
if version == self.source_version {
|
||
match result {
|
||
Ok(disk) => {
|
||
if let Some(target) = recovery_target(&disk) {
|
||
self.pending_recovery = Some(target);
|
||
}
|
||
self.source_workspace
|
||
.discovered(&disk.bundle, disk.reachable.clone());
|
||
self.source_version += 1;
|
||
self.source_diagnostics = disk.diagnostics.clone();
|
||
self.disk_project = Some(disk);
|
||
self.status = "Included files loaded; existing drafts retained.".into();
|
||
return self.request_lint();
|
||
}
|
||
Err(error) => self.status = error,
|
||
}
|
||
}
|
||
}
|
||
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(issue) = self.source_diagnostics.get(index) {
|
||
self.jump_to_source(SourceLocation {
|
||
path: issue.path.clone(),
|
||
span: issue.diagnostic.span,
|
||
});
|
||
}
|
||
}
|
||
Message::SourceRelated(location) => self.jump_to_source(location),
|
||
Message::SourceViewport => {
|
||
if let Some(cursor) = self.source_jump.take() {
|
||
self.source_editor.move_to(cursor);
|
||
if self.source_select_line && 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.source_workspace.revert_active();
|
||
self.show_source_file(true);
|
||
self.source_version += 1;
|
||
self.status = "This file's draft reverted; other files retained.".into();
|
||
return self.request_lint();
|
||
}
|
||
Message::SourceUndo | Message::SourceRedo => {
|
||
let redo = matches!(message, Message::SourceRedo);
|
||
if self.source_workspace.undo(redo) {
|
||
self.show_source_file(true);
|
||
self.source_version += 1;
|
||
self.source_diagnostics.clear();
|
||
return self.request_lint();
|
||
}
|
||
}
|
||
Message::SourceForSelection => {
|
||
if self.apply() {
|
||
self.page = Page::Text;
|
||
if let Some(location) = self
|
||
.document
|
||
.compiled()
|
||
.index
|
||
.declarations
|
||
.get(&self.selected)
|
||
.cloned()
|
||
{
|
||
self.jump_to_source(location);
|
||
self.status = "Source declaration selected.".into();
|
||
}
|
||
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);
|
||
self.source_select_line = true;
|
||
}
|
||
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_behavior() || !self.apply_source() || !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::SavePath(path) => {
|
||
self.busy = false;
|
||
if let Some(path) = path {
|
||
return self.save_to(path);
|
||
}
|
||
self.continue_after_save = false;
|
||
self.status = "Save cancelled".into();
|
||
}
|
||
Message::Saved(result) => {
|
||
self.busy = false;
|
||
match result {
|
||
Ok(Some(receipt)) => {
|
||
if receipt.document != self.canvas_document {
|
||
return Task::none();
|
||
}
|
||
if let Some(disk) = receipt.disk {
|
||
if self.pending_recovery.as_ref()
|
||
== Some(&disk.root.join(&disk.bundle.entry))
|
||
{
|
||
self.pending_recovery = None;
|
||
}
|
||
if receipt.snapshot.bundle.entry != self.document.bundle().entry {
|
||
if receipt.revision != self.document.revision() {
|
||
self.status = format!(
|
||
"Snapshot saved to {}. Newer edits retain the current source location.",
|
||
receipt.path.display()
|
||
);
|
||
self.continue_after_save = false;
|
||
return Task::none();
|
||
}
|
||
if let Err(error) =
|
||
self.document.apply_bundle(receipt.snapshot.bundle.clone())
|
||
{
|
||
self.status = error.to_string();
|
||
self.continue_after_save = false;
|
||
return Task::none();
|
||
}
|
||
self.sync_source();
|
||
}
|
||
self.disk_project = Some(disk);
|
||
self.path = Some(receipt.path);
|
||
self.document.mark_saved(receipt.snapshot);
|
||
self.prune_closed_files(!self.document.dirty());
|
||
self.status = "All source files saved".into();
|
||
} else {
|
||
self.status = format!(
|
||
"JSON exported to {}. Source files and their save state are retained.",
|
||
receipt.path.display()
|
||
);
|
||
self.continue_after_save = false;
|
||
}
|
||
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) => {
|
||
if e.document != self.canvas_document {
|
||
return Task::none();
|
||
}
|
||
if let Some(target) = e.recovery {
|
||
self.pending_recovery = Some(target);
|
||
}
|
||
self.status = e.message;
|
||
if self.pending_recovery.is_some() {
|
||
self.page = Page::Text;
|
||
}
|
||
self.continue_after_save = false;
|
||
}
|
||
}
|
||
}
|
||
Message::Loaded(result) => {
|
||
self.busy = false;
|
||
match result {
|
||
Ok(Some(LoadedSnapshot::Legacy(path, decoded, _bytes))) => {
|
||
self.reset(decoded.project, None);
|
||
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;
|
||
} else {
|
||
self.status = format!(
|
||
"Imported {}. Save as an Arcadia source project to retain file structure and comments.",
|
||
path.display()
|
||
);
|
||
}
|
||
}
|
||
Ok(Some(LoadedSnapshot::Source(disk))) => self.open_source_project(disk),
|
||
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::Export
|
||
&& 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) => {
|
||
// The canvas can retain its capped size during a real window
|
||
// resize. Invalidate any scenario pointer/slot gesture anyway.
|
||
if self.window_size != size && self.scenario_context().is_some() {
|
||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||
}
|
||
self.window_size = size;
|
||
}
|
||
Message::CanvasResized(size) => {
|
||
let first_measurement = self.canvas_size.is_none();
|
||
let size_changed = self.canvas_size != Some(size);
|
||
self.canvas_size = Some(size);
|
||
// Modal overlays can remount the sensor and repeat on_show at
|
||
// the same size. Keep the viewport stable after model edits.
|
||
if first_measurement || (size_changed && 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,
|
||
routes: Vec::new(),
|
||
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 name = self.source_workspace.entry.clone();
|
||
Task::perform(
|
||
async move {
|
||
let file = rfd::AsyncFileDialog::new()
|
||
.add_filter("Arcadia source", &["arcadia"])
|
||
.add_filter("Model JSON", &["json"])
|
||
.set_file_name(name)
|
||
.save_file()
|
||
.await;
|
||
file.map(|file| file.path().to_path_buf())
|
||
},
|
||
Message::SavePath,
|
||
)
|
||
}
|
||
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> {
|
||
self.busy = true;
|
||
let snapshot = self.document.snapshot();
|
||
let document = self.canvas_document;
|
||
let revision = self.document.revision();
|
||
let previous = self.disk_project.clone();
|
||
let failed_target = path.clone();
|
||
Task::perform(
|
||
async move {
|
||
tokio::task::spawn_blocking(move || {
|
||
write_snapshot(document, revision, snapshot, path, previous).map(Some)
|
||
})
|
||
.await
|
||
.map_err(|e| save_failure(document, &failed_target, 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 open_source_project(&mut self, disk: DiskProject) {
|
||
let pending_recovery = recovery_target(&disk);
|
||
let path = disk.root.join(&disk.bundle.entry);
|
||
let candidate = if !disk
|
||
.diagnostics
|
||
.iter()
|
||
.any(|issue| issue.diagnostic.severity == oa_core::validation::Severity::Error)
|
||
{
|
||
Document::from_bundle(disk.reachable_bundle(), LIMITS)
|
||
} else {
|
||
Err(crate::document::DocumentError::Source(
|
||
disk.diagnostics.clone(),
|
||
))
|
||
};
|
||
match candidate {
|
||
Ok(document) => {
|
||
self.reset(document.project().clone(), Some(path));
|
||
self.document = document;
|
||
self.source_workspace = Workspace::new(self.document.bundle(), true);
|
||
self.source_workspace
|
||
.discovered(&disk.bundle, disk.reachable.clone());
|
||
self.show_source_file(false);
|
||
self.source_diagnostics = self.document.compiled().diagnostics.clone();
|
||
self.migration = self.document.compiled().decoded.migration.clone();
|
||
if self.migration.is_some() {
|
||
self.status = "Imported earlier source schema. Review Model checks; Save all explicitly writes the upgraded source while retaining comments and includes.".into();
|
||
self.page = Page::Validation;
|
||
} else {
|
||
self.status =
|
||
"Source project opened. Included files and comments are preserved.".into();
|
||
}
|
||
}
|
||
Err(error) => {
|
||
self.canvas_document = Id::new_v4();
|
||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||
self.path = Some(path);
|
||
self.source_workspace = Workspace::new(&disk.bundle, false);
|
||
self.source_workspace.reachable = disk.reachable.clone();
|
||
self.show_source_file(false);
|
||
self.source_diagnostics = error.diagnostics().to_vec();
|
||
self.source_version += 1;
|
||
self.lint_requested = None;
|
||
self.page = Page::Text;
|
||
self.refresh_draft();
|
||
self.status = "Source needs repair. Loaded drafts are retained; the graphical model is unchanged.".into();
|
||
}
|
||
}
|
||
self.pending_recovery = pending_recovery;
|
||
self.disk_project = Some(disk);
|
||
}
|
||
|
||
fn prune_closed_files(&mut self, prune_expectations: bool) {
|
||
if let Some(disk) = &mut self.disk_project {
|
||
let retain = |path: &String| {
|
||
self.document.bundle().files.contains_key(path)
|
||
|| self.source_workspace.files.contains_key(path)
|
||
};
|
||
disk.bundle.files.retain(|path, _| retain(path));
|
||
if prune_expectations {
|
||
disk.expectations.retain(|path, _| retain(path));
|
||
disk.reachable.retain(retain);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn jump_to_source(&mut self, location: SourceLocation) {
|
||
self.source_workspace
|
||
.switch(&location.path, self.source_editor.cursor());
|
||
if self.source_workspace.active != location.path {
|
||
self.status = format!(
|
||
"{} has not been loaded. Use Load includes or create the missing file.",
|
||
location.path
|
||
);
|
||
return;
|
||
}
|
||
self.show_source_file(false);
|
||
let source = self.source_workspace.text();
|
||
let mut offset = location.span.start.min(source.len());
|
||
while !source.is_char_boundary(offset) {
|
||
offset -= 1;
|
||
}
|
||
let prefix = &source[..offset];
|
||
let line = prefix.bytes().filter(|b| *b == b'\n').count();
|
||
let column = prefix
|
||
.rfind('\n')
|
||
.map_or(offset, |start| offset - start - 1);
|
||
self.source_jump = Some(text_editor::Cursor {
|
||
position: text_editor::Position { line, column },
|
||
selection: None,
|
||
});
|
||
self.source_select_line = true;
|
||
}
|
||
|
||
fn create_source_file(&mut self) -> Result<(), String> {
|
||
let path = self.source_workspace.new_path.trim().to_owned();
|
||
if source_project::resolve_include("", &path).as_deref() != Ok(path.as_str()) {
|
||
return Err("Use a relative portable path such as sa/functions.arcadia.".into());
|
||
}
|
||
if self.source_workspace.files.contains_key(&path) {
|
||
return Err("This file is already open.".into());
|
||
}
|
||
if self.source_workspace.files.len() >= LIMITS.max_files {
|
||
return Err("The project file limit has been reached.".into());
|
||
}
|
||
let bundle = self.source_workspace.bundle();
|
||
let already_included = bundle.files.iter().any(|(p, t)| {
|
||
source_project::inspect_includes(p, t, p == &bundle.entry, LIMITS)
|
||
.is_ok_and(|includes| includes.iter().any(|include| include.target == path))
|
||
});
|
||
let mut changed = bundle.clone();
|
||
if !already_included {
|
||
if self.source_dirty() {
|
||
return Err(
|
||
"Apply current drafts first, or add an include for the new file in the entry."
|
||
.into(),
|
||
);
|
||
}
|
||
let at = self.document.compiled().index.project_body.span.end;
|
||
let entry = changed
|
||
.files
|
||
.get_mut(&changed.entry)
|
||
.ok_or("The entry file is missing.")?;
|
||
entry.insert_str(
|
||
at,
|
||
&format!(
|
||
"\n include {};\n",
|
||
serde_json::to_string(&path).map_err(|e| e.to_string())?
|
||
),
|
||
);
|
||
self.source_workspace
|
||
.switch(&bundle.entry, self.source_editor.cursor());
|
||
self.source_workspace
|
||
.edit(entry.clone(), self.source_editor.cursor());
|
||
}
|
||
changed.files.insert(path.clone(), String::new());
|
||
self.source_workspace
|
||
.discovered(&changed, changed.files.keys().cloned().collect());
|
||
self.source_workspace
|
||
.switch(&path, self.source_editor.cursor());
|
||
self.source_workspace.new_path.clear();
|
||
self.show_source_file(true);
|
||
self.source_version += 1;
|
||
self.status = "Source file created. Add declarations, then Apply all.".into();
|
||
Ok(())
|
||
}
|
||
|
||
fn load_includes(&mut self) -> Task<Message> {
|
||
let Some(disk) = self.disk_project.clone() else {
|
||
self.status = "Save the source project first to establish its include directory. New files can be created here.".into();
|
||
return Task::none();
|
||
};
|
||
let overlays = self.source_workspace.bundle().files;
|
||
let version = self.source_version;
|
||
self.busy = true;
|
||
Task::perform(
|
||
async move {
|
||
tokio::task::spawn_blocking(move || {
|
||
source_files::discover_missing(&disk, &overlays, LIMITS)
|
||
.map_err(|e| e.to_string())
|
||
})
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
},
|
||
move |result| Message::IncludesLoaded(version, result),
|
||
)
|
||
}
|
||
|
||
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_workspace.bundle();
|
||
Task::perform(
|
||
async move {
|
||
tokio::task::spawn_blocking(move || source_workspace::lint(&source))
|
||
.await
|
||
.map_err(|error| error.to_string())
|
||
},
|
||
move |result| Message::Linted(version, result),
|
||
)
|
||
}
|
||
pub fn route_context(&self) -> Option<crate::route_edit::Context> {
|
||
Some(crate::route_edit::Context {
|
||
document: self.canvas_document,
|
||
diagram: self.active_diagram?,
|
||
revision: self.document.revision(),
|
||
epoch: self.canvas_epoch,
|
||
})
|
||
}
|
||
fn edit_connection(&mut self, event: crate::connection_edit::Event) {
|
||
use crate::connection_edit::{Event, Session};
|
||
let Some(context) = self.route_context() else {
|
||
return;
|
||
};
|
||
if matches!(event, Event::Choose(None)) {
|
||
self.connection = None;
|
||
self.status =
|
||
"Select · drag elements, ports and connection routes to arrange the diagram".into();
|
||
return;
|
||
}
|
||
if self.page != Page::Diagram || self.source_dirty() || self.draft_dirty() {
|
||
self.status = "Apply or revert pending edits before drawing a connection.".into();
|
||
return;
|
||
}
|
||
match event {
|
||
Event::Begin(tool, source) => {
|
||
self.edit_connection(Event::Choose(Some(tool)));
|
||
if let Some(context) = self.route_context() {
|
||
self.edit_connection(Event::Endpoint(context, source));
|
||
}
|
||
}
|
||
Event::Choose(Some(tool)) => {
|
||
if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) {
|
||
return;
|
||
}
|
||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||
let context = self.route_context().unwrap();
|
||
self.connection = Some(Session::new(
|
||
self.project(),
|
||
&project_view(self.project(), context.diagram),
|
||
context,
|
||
tool,
|
||
));
|
||
self.selected_bend = None;
|
||
self.status = tool.hint().into();
|
||
}
|
||
Event::Endpoint(captured, id) => {
|
||
if captured != context {
|
||
return;
|
||
}
|
||
let Some(mut session) = self.connection.take() else {
|
||
return;
|
||
};
|
||
if session.context != context {
|
||
return;
|
||
}
|
||
if !session.candidates.contains(&id) {
|
||
self.status =
|
||
"Choose a highlighted compatible endpoint · Esc cancels drawing".into();
|
||
self.connection = Some(session);
|
||
return;
|
||
}
|
||
if let Some(source) = session.source {
|
||
match session.tool.command(self.project(), source, id) {
|
||
Ok(command) => {
|
||
if self.execute(command) {
|
||
self.selected_node = None;
|
||
self.inspector_tab = Default::default();
|
||
self.status = format!(
|
||
"{} created · edit its name and carried items in Properties · Undo restores the complete operation",
|
||
session.tool.label()
|
||
);
|
||
} else {
|
||
self.connection = Some(session);
|
||
}
|
||
}
|
||
Err(error) => {
|
||
self.status = error.to_string();
|
||
self.connection = Some(session);
|
||
}
|
||
}
|
||
} else {
|
||
session.start(
|
||
self.project(),
|
||
&project_view(self.project(), context.diagram),
|
||
id,
|
||
);
|
||
self.select(id);
|
||
self.status = if session.candidates.is_empty() {
|
||
"No compatible targets are visible. Expand containers or show a compatible endpoint in this view; Esc cancels drawing.".into()
|
||
} else {
|
||
format!(
|
||
"From {} · choose a highlighted target · Esc cancels",
|
||
self.project().named(id)
|
||
)
|
||
};
|
||
self.connection = Some(session);
|
||
}
|
||
}
|
||
Event::Restart(captured) => {
|
||
if captured == context
|
||
&& let Some(session) = &self.connection
|
||
{
|
||
self.edit_connection(Event::Choose(Some(session.tool)));
|
||
}
|
||
}
|
||
Event::Choose(None) => {}
|
||
}
|
||
}
|
||
fn edit_route(&mut self, event: crate::route_edit::Event) {
|
||
use crate::route_edit::{Event, insert_pin, stored_route};
|
||
if self.connection.is_some() {
|
||
return;
|
||
}
|
||
let Some(context) = self.route_context() else {
|
||
return;
|
||
};
|
||
if self.source_dirty() || self.draft_dirty() {
|
||
self.status = "Apply or revert your draft before editing a connection route.".into();
|
||
return;
|
||
}
|
||
let diagram = &self.project().diagrams[&context.diagram];
|
||
let view = project_view(self.project(), context.diagram);
|
||
let Some(projected_edge) = view.edges.iter().find(|e| e.id == self.selected) else {
|
||
return;
|
||
};
|
||
if projection::edge_endpoint_placements(self.project(), diagram, self.selected).is_some_and(
|
||
|(source, target)| source != projected_edge.source || target != projected_edge.target,
|
||
) {
|
||
self.status =
|
||
"Expand the endpoint containers to edit this connection’s saved route.".into();
|
||
return;
|
||
}
|
||
let mut route = stored_route(diagram, self.selected);
|
||
let mut selected_bend = self
|
||
.selected_bend
|
||
.filter(|(id, _)| *id == self.selected)
|
||
.map(|(_, i)| i);
|
||
let command = match event {
|
||
Event::SelectBend(id, index) => {
|
||
if id == self.selected && index < route.waypoints.len() {
|
||
self.selected_bend = Some((id, index));
|
||
}
|
||
return;
|
||
}
|
||
Event::Commit(expected, changed, bend) => {
|
||
if expected != context || changed.edge != self.selected {
|
||
self.status =
|
||
"Route gesture cancelled because the model or view changed.".into();
|
||
return;
|
||
}
|
||
route = changed;
|
||
selected_bend = bend;
|
||
Command::SetEdgeRoute {
|
||
diagram: context.diagram,
|
||
route,
|
||
}
|
||
}
|
||
Event::AddBend | Event::AddBendAt(_) => {
|
||
if route.waypoints.len() >= 128 {
|
||
return;
|
||
}
|
||
let geometry = oa_core::routing::route_diagram(self.project(), diagram, &view);
|
||
let edge = &geometry.edges[&self.selected];
|
||
// Mandatory port leads are inside the card's clearance. A pin
|
||
// starts beyond those leads, including coincident short stubs.
|
||
let segment = edge
|
||
.points
|
||
.windows(2)
|
||
.enumerate()
|
||
.filter(|(index, _)| *index > 0 && index + 2 < edge.points.len())
|
||
.map(|(_, span)| span)
|
||
.max_by(|a, b| {
|
||
let len =
|
||
|s: &[Position]| (s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs();
|
||
len(a).total_cmp(&len(b))
|
||
});
|
||
let point = if let Event::AddBendAt(point) = event {
|
||
if !point.x.is_finite() || !point.y.is_finite() {
|
||
return;
|
||
}
|
||
point
|
||
} else if let Some(segment) = segment {
|
||
Position {
|
||
x: (segment[0].x + segment[1].x) / 2.,
|
||
y: (segment[0].y + segment[1].y) / 2.,
|
||
}
|
||
} else if let Some(stub) = edge.points.get(1) {
|
||
*stub
|
||
} else {
|
||
return;
|
||
};
|
||
selected_bend = Some(insert_pin(&mut route, edge, point));
|
||
Command::SetEdgeRoute {
|
||
diagram: context.diagram,
|
||
route,
|
||
}
|
||
}
|
||
Event::RemoveBend => {
|
||
let Some(index) = selected_bend.filter(|i| *i < route.waypoints.len()) else {
|
||
return;
|
||
};
|
||
route.waypoints.remove(index);
|
||
selected_bend = None;
|
||
Command::SetEdgeRoute {
|
||
diagram: context.diagram,
|
||
route,
|
||
}
|
||
}
|
||
Event::Reset => {
|
||
selected_bend = None;
|
||
Command::RemoveEdgeRoute {
|
||
diagram: context.diagram,
|
||
edge: self.selected,
|
||
}
|
||
}
|
||
};
|
||
let selected = self.selected;
|
||
if self.execute(command) {
|
||
self.selected = selected;
|
||
self.selected_bend = selected_bend.map(|index| (selected, index));
|
||
self.refresh_draft();
|
||
self.status = "Connection route updated · Undo restores the previous route".into();
|
||
}
|
||
}
|
||
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 geometry = oa_core::routing::route_diagram(
|
||
self.project(),
|
||
&self.project().diagrams[&self.active_diagram.unwrap()],
|
||
&view,
|
||
);
|
||
let left = geometry.bounds.position.x;
|
||
let top = geometry.bounds.position.y;
|
||
let right = left + geometry.bounds.size.width;
|
||
let bottom = top + geometry.bounds.size.height;
|
||
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 write_snapshot(
|
||
document: Id,
|
||
revision: u64,
|
||
snapshot: Arc<CompiledSourceProject>,
|
||
path: PathBuf,
|
||
previous: Option<DiskProject>,
|
||
) -> Result<FileSnapshot, SaveFailure> {
|
||
let target = path.clone();
|
||
write_snapshot_inner(document, revision, snapshot, path, previous)
|
||
.map_err(|message| save_failure(document, &target, message))
|
||
}
|
||
|
||
fn save_failure(document: Id, path: &std::path::Path, message: String) -> SaveFailure {
|
||
// Discovery is read-only, bounded and checks physical source ownership. Do
|
||
// not infer journal names from an error string or recover inside a save.
|
||
let recovery = path
|
||
.extension()
|
||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
|
||
.then(|| source_files::load_project(path, LIMITS).ok())
|
||
.flatten()
|
||
.and_then(|disk| recovery_target(&disk));
|
||
SaveFailure {
|
||
document,
|
||
message,
|
||
recovery,
|
||
}
|
||
}
|
||
|
||
fn recovery_target(disk: &DiskProject) -> Option<PathBuf> {
|
||
disk.diagnostics
|
||
.iter()
|
||
.any(|issue| issue.diagnostic.code == "pending_save")
|
||
.then(|| disk.root.join(&disk.bundle.entry))
|
||
}
|
||
|
||
fn recover_snapshot(target: PathBuf) -> Result<RecoverySnapshot, String> {
|
||
if !source_files::recover_project(&target, LIMITS).map_err(|e| e.to_string())? {
|
||
return Err(format!(
|
||
"No pending save was found at {}. Your model and drafts are retained.",
|
||
target.display()
|
||
));
|
||
}
|
||
let disk = source_files::load_project(&target, LIMITS).map_err(|e| e.to_string());
|
||
Ok(RecoverySnapshot { target, disk })
|
||
}
|
||
|
||
fn write_snapshot_inner(
|
||
document: Id,
|
||
revision: u64,
|
||
mut snapshot: Arc<CompiledSourceProject>,
|
||
path: PathBuf,
|
||
previous: Option<DiskProject>,
|
||
) -> Result<FileSnapshot, String> {
|
||
let is_json = path
|
||
.extension()
|
||
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"));
|
||
let disk = if is_json {
|
||
if path.exists() {
|
||
return Err(
|
||
"The JSON destination already exists. Choose a new file to preserve it.".into(),
|
||
);
|
||
}
|
||
persistence::save_checked(&path, &snapshot.decoded.project, None)
|
||
.map_err(|e| e.to_string())?;
|
||
None
|
||
} else {
|
||
let entry = path
|
||
.file_name()
|
||
.and_then(|p| p.to_str())
|
||
.ok_or("Choose a portable .arcadia entry file name.")?;
|
||
if source_project::resolve_include("", entry).as_deref() != Ok(entry) {
|
||
return Err("Choose a portable .arcadia entry file name.".into());
|
||
}
|
||
if entry != snapshot.bundle.entry {
|
||
let mut bundle = snapshot.bundle.clone();
|
||
if bundle.files.contains_key(entry) {
|
||
return Err("That entry name is already used by an included file.".into());
|
||
}
|
||
let text = bundle
|
||
.files
|
||
.remove(&bundle.entry)
|
||
.ok_or("The accepted entry is missing.")?;
|
||
bundle.entry = entry.into();
|
||
bundle.files.insert(entry.into(), text);
|
||
snapshot = Arc::new(
|
||
source_project::compile_bundle(&bundle, LIMITS)
|
||
.map_err(|errors| crate::document::DocumentError::Source(errors).to_string())?,
|
||
);
|
||
}
|
||
let previous = previous.filter(|disk| {
|
||
path.parent()
|
||
.filter(|p| !p.as_os_str().is_empty())
|
||
.unwrap_or(std::path::Path::new("."))
|
||
.canonicalize()
|
||
.ok()
|
||
.as_ref()
|
||
== Some(&disk.root)
|
||
&& entry == disk.bundle.entry
|
||
});
|
||
let mut disk = match previous {
|
||
Some(disk) => disk,
|
||
None => source_files::new_project(&path, LIMITS).map_err(|e| e.to_string())?,
|
||
};
|
||
for path in snapshot.bundle.files.keys() {
|
||
if !disk.expectations.contains_key(path) {
|
||
source_files::expect_new_file(&mut disk, path, LIMITS)
|
||
.map_err(|e| e.to_string())?;
|
||
}
|
||
}
|
||
Some(source_files::save_project(&disk, &snapshot, LIMITS).map_err(|e| e.to_string())?)
|
||
};
|
||
Ok(FileSnapshot {
|
||
document,
|
||
revision,
|
||
snapshot,
|
||
disk,
|
||
path,
|
||
})
|
||
}
|
||
|
||
fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
|
||
use std::io::Read;
|
||
let is_source = path
|
||
.extension()
|
||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"));
|
||
if is_source {
|
||
// The entry is written last. An interrupted first save can therefore
|
||
// have a valid journal without an entry file to open yet.
|
||
let disk = source_files::load_project(&path, LIMITS).map_err(|e| e.to_string())?;
|
||
if recovery_target(&disk).is_none()
|
||
&& let Some(source_files::DiskExpectation::Bytes(bytes)) =
|
||
disk.expectations.get(&disk.bundle.entry)
|
||
&& let Ok(decoded) = persistence::decode_with_report_limited(bytes, LIMITS.model)
|
||
// Source1/2 require the historical single-document adapters.
|
||
// Source3+ must retain its authored bundle and token migration.
|
||
&& decoded.migration.as_ref().is_some_and(|report| report.from_schema <= 2)
|
||
{
|
||
return Ok(LoadedSnapshot::Legacy(
|
||
path,
|
||
Box::new(decoded),
|
||
Arc::from(bytes.clone()),
|
||
));
|
||
}
|
||
return Ok(LoadedSnapshot::Source(disk));
|
||
}
|
||
if !std::fs::symlink_metadata(&path)
|
||
.map_err(|e| e.to_string())?
|
||
.is_file()
|
||
{
|
||
return Err("Open a regular model file; links and special files are not supported.".into());
|
||
}
|
||
let mut bytes = Vec::new();
|
||
std::fs::File::open(&path)
|
||
.map_err(|e| e.to_string())?
|
||
.take(LIMITS.model.max_source_bytes as u64 + 1)
|
||
.read_to_end(&mut bytes)
|
||
.map_err(|e| e.to_string())?;
|
||
if bytes.len() > LIMITS.model.max_source_bytes {
|
||
return Err("The model exceeds the source byte limit.".into());
|
||
}
|
||
let decoded =
|
||
persistence::decode_with_report_limited(&bytes, LIMITS.model).map_err(|e| e.to_string())?;
|
||
Ok(LoadedSnapshot::Legacy(
|
||
path,
|
||
Box::new(decoded),
|
||
Arc::from(bytes),
|
||
))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod source_project_tests {
|
||
use super::*;
|
||
|
||
struct Directory(PathBuf);
|
||
impl Directory {
|
||
fn new() -> Self {
|
||
let path = std::env::temp_dir().join(format!("arcadia-source-ui-{}", Id::new_v4()));
|
||
std::fs::create_dir(&path).unwrap();
|
||
Self(path.canonicalize().unwrap())
|
||
}
|
||
}
|
||
impl Drop for Directory {
|
||
fn drop(&mut self) {
|
||
let temp = std::env::temp_dir().canonicalize().unwrap();
|
||
assert_eq!(self.0.parent(), Some(temp.as_path()));
|
||
assert!(
|
||
self.0
|
||
.file_name()
|
||
.unwrap()
|
||
.to_string_lossy()
|
||
.starts_with("arcadia-source-ui-")
|
||
);
|
||
std::fs::remove_dir_all(&self.0).unwrap();
|
||
}
|
||
}
|
||
fn send(app: &mut App, message: Message) {
|
||
let _ = app.update(message);
|
||
}
|
||
fn edit(app: &mut App, text: &str) {
|
||
send(app, Message::SourceEdit(text_editor::Action::SelectAll));
|
||
send(
|
||
app,
|
||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||
Arc::new(text.into()),
|
||
))),
|
||
);
|
||
}
|
||
fn save(app: &mut App, path: PathBuf) -> Result<(), String> {
|
||
let receipt = write_snapshot(
|
||
app.canvas_document,
|
||
app.document.revision(),
|
||
app.document.snapshot(),
|
||
path,
|
||
app.disk_project.clone(),
|
||
)
|
||
.map_err(|error| error.message)?;
|
||
send(app, Message::Saved(Ok(Some(receipt))));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn source3_native_open_preserves_report_comments_nesting_and_save_reopen() {
|
||
let original = include_str!("../../oa-core/tests/fixtures/v3-data-contract.arcadia");
|
||
for split in [false, true] {
|
||
let directory = Directory::new();
|
||
let path = directory.0.join("project.arcadia");
|
||
let mut authored = std::collections::BTreeMap::new();
|
||
if split {
|
||
let start = original.find("{\n").unwrap() + 2;
|
||
let end = original.rfind('}').unwrap();
|
||
authored.insert(
|
||
"project.arcadia",
|
||
format!(
|
||
"{} include \"dictionary.arcadia\";\n}}\n",
|
||
&original[..start]
|
||
),
|
||
);
|
||
authored.insert("dictionary.arcadia", original[start..end].to_owned());
|
||
} else {
|
||
authored.insert("project.arcadia", original.to_owned());
|
||
}
|
||
for (file, source) in &authored {
|
||
std::fs::write(directory.0.join(file), source).unwrap();
|
||
}
|
||
let mut app = App::with_project(Project::empty());
|
||
send(
|
||
&mut app,
|
||
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
|
||
);
|
||
assert_eq!(app.migration.as_ref().unwrap().from_schema, 3);
|
||
assert_eq!(app.page, Page::Validation);
|
||
assert_eq!(app.path, Some(path.clone()));
|
||
assert_eq!(app.document.bundle().files.len(), authored.len());
|
||
assert!(
|
||
app.dirty(),
|
||
"accepted source migration needs explicit saving"
|
||
);
|
||
for (file, source) in &authored {
|
||
assert_eq!(
|
||
std::fs::read_to_string(directory.0.join(file)).unwrap(),
|
||
*source
|
||
);
|
||
}
|
||
let text = app
|
||
.document
|
||
.bundle()
|
||
.files
|
||
.values()
|
||
.cloned()
|
||
.collect::<String>();
|
||
assert!(text.contains("// Reused record parameters retain their own identities."));
|
||
assert!(text.contains("// Frozen authored schema3 compatibility input."));
|
||
let packet = app
|
||
.project()
|
||
.elements
|
||
.values()
|
||
.find(|e| e.alias == "packet")
|
||
.unwrap()
|
||
.id;
|
||
let origin = app
|
||
.project()
|
||
.elements
|
||
.values()
|
||
.find(|e| e.alias == "origin")
|
||
.unwrap()
|
||
.id;
|
||
assert_eq!(
|
||
app.document.compiled().index.lexical_owners.get(&origin),
|
||
Some(&packet)
|
||
);
|
||
let before = app.document.snapshot();
|
||
save(&mut app, path.clone()).unwrap();
|
||
assert!(!app.dirty());
|
||
for (file, source) in &before.bundle.files {
|
||
assert_eq!(
|
||
std::fs::read_to_string(directory.0.join(file)).unwrap(),
|
||
*source
|
||
);
|
||
}
|
||
let mut reopened = App::with_project(Project::empty());
|
||
send(
|
||
&mut reopened,
|
||
Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))),
|
||
);
|
||
assert!(reopened.migration.is_none());
|
||
assert_eq!(reopened.project(), app.project());
|
||
assert_eq!(reopened.document.bundle(), app.document.bundle());
|
||
assert!(!reopened.dirty());
|
||
}
|
||
}
|
||
|
||
fn pending_journal(directory: &Directory, before: Option<&str>) -> (PathBuf, PathBuf) {
|
||
let path = directory.0.join("project.arcadia");
|
||
let journal = directory.0.join(".arcadia-save-project.arcadia.journal");
|
||
std::fs::create_dir(&journal).unwrap();
|
||
let metadata = json!({"version":1,"entry":"project.arcadia","files":[{
|
||
"path":"project.arcadia","before":before.is_some(),"write":true
|
||
}]});
|
||
std::fs::write(
|
||
journal.join("journal.json"),
|
||
serde_json::to_vec(&metadata).unwrap(),
|
||
)
|
||
.unwrap();
|
||
if let Some(before) = before {
|
||
std::fs::write(&path, before).unwrap();
|
||
std::fs::write(journal.join("0.before"), before).unwrap();
|
||
}
|
||
std::fs::write(
|
||
journal.join("0.after"),
|
||
"arcadia 3; project p \"After\" {}\n",
|
||
)
|
||
.unwrap();
|
||
(path, journal)
|
||
}
|
||
|
||
#[test]
|
||
fn recovery_open_finds_first_save_journal_without_an_entry_file() {
|
||
let directory = Directory::new();
|
||
let (path, journal) = pending_journal(&directory, None);
|
||
let mut app = App::with_project(Project::empty());
|
||
let before = app.document.snapshot();
|
||
send(
|
||
&mut app,
|
||
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
|
||
);
|
||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||
assert_eq!(app.page, Page::Text);
|
||
assert!(!app.source_workspace.applied);
|
||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||
assert!(journal.exists());
|
||
send(&mut app, Message::SourceRecover);
|
||
assert!(app.busy);
|
||
send(
|
||
&mut app,
|
||
Message::SourceRecovered(recover_snapshot(path.clone())),
|
||
);
|
||
assert!(app.pending_recovery.is_none());
|
||
assert!(!journal.exists());
|
||
assert!(!path.exists());
|
||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||
}
|
||
|
||
#[test]
|
||
fn recovery_after_first_save_failure_retains_target_then_retries_without_reentering_path() {
|
||
let directory = Directory::new();
|
||
let (path, journal) = pending_journal(&directory, None);
|
||
let mut app = App::with_project(Project::empty());
|
||
let before = app.document.snapshot();
|
||
let error = write_snapshot(
|
||
app.canvas_document,
|
||
app.document.revision(),
|
||
app.document.snapshot(),
|
||
path.clone(),
|
||
None,
|
||
)
|
||
.unwrap_err();
|
||
assert_eq!(error.recovery.as_ref(), Some(&path));
|
||
send(&mut app, Message::Saved(Err(error)));
|
||
assert!(app.path.is_none());
|
||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||
assert_eq!(app.page, Page::Text);
|
||
send(&mut app, Message::SourceRecover);
|
||
assert!(
|
||
app.busy,
|
||
"Recovery must use its own target when App.path is absent"
|
||
);
|
||
send(
|
||
&mut app,
|
||
Message::SourceRecovered(recover_snapshot(path.clone())),
|
||
);
|
||
assert_eq!(app.path.as_ref(), Some(&path));
|
||
assert!(app.pending_recovery.is_none());
|
||
assert!(!journal.exists());
|
||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||
save(&mut app, path.clone()).unwrap();
|
||
assert!(path.is_file());
|
||
assert!(!app.dirty());
|
||
}
|
||
|
||
#[test]
|
||
fn recovery_target_survives_edit_lint_apply_and_rejected_recovery_until_success() {
|
||
let directory = Directory::new();
|
||
let initial = "arcadia 3; project p \"Before\" {}\n";
|
||
let (path, journal) = pending_journal(&directory, Some(initial));
|
||
let mut app = App::with_project(Project::empty());
|
||
send(
|
||
&mut app,
|
||
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
|
||
);
|
||
edit(&mut app, &format!("{initial}// Added rationale\n"));
|
||
assert!(app.source_diagnostics.is_empty());
|
||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||
let version = app.source_version;
|
||
send(&mut app, Message::Lint(version));
|
||
let diagnostics = source_workspace::lint(&app.source_workspace.bundle());
|
||
send(&mut app, Message::Linted(version, Ok(diagnostics)));
|
||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||
assert!(app.apply_source());
|
||
let accepted = app.document.snapshot();
|
||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||
std::fs::write(&path, "External file edit that must survive").unwrap();
|
||
send(
|
||
&mut app,
|
||
Message::SourceRecovered(recover_snapshot(path.clone())),
|
||
);
|
||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||
assert!(journal.exists());
|
||
assert_eq!(
|
||
std::fs::read_to_string(&path).unwrap(),
|
||
"External file edit that must survive"
|
||
);
|
||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||
std::fs::write(&path, initial).unwrap();
|
||
send(
|
||
&mut app,
|
||
Message::SourceRecovered(recover_snapshot(path.clone())),
|
||
);
|
||
assert!(app.pending_recovery.is_none());
|
||
assert!(!journal.exists());
|
||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||
assert!(app.source_editor.text().contains("Added rationale"));
|
||
}
|
||
|
||
#[test]
|
||
fn recovery_of_previous_save_as_target_does_not_redirect_current_project() {
|
||
let directory = Directory::new();
|
||
let (target, journal) = pending_journal(&directory, None);
|
||
let mut app = App::with_project(Project::empty());
|
||
let error = write_snapshot(
|
||
app.canvas_document,
|
||
app.document.revision(),
|
||
app.document.snapshot(),
|
||
target.clone(),
|
||
None,
|
||
)
|
||
.unwrap_err();
|
||
send(&mut app, Message::Saved(Err(error)));
|
||
let destination = directory.0.join("another.arcadia");
|
||
save(&mut app, destination.clone()).unwrap();
|
||
assert_eq!(app.pending_recovery.as_ref(), Some(&target));
|
||
let saved = app.document.snapshot();
|
||
send(&mut app, Message::SourceRecovered(recover_snapshot(target)));
|
||
assert!(app.pending_recovery.is_none());
|
||
assert!(!journal.exists());
|
||
assert_eq!(app.path.as_ref(), Some(&destination));
|
||
assert_eq!(
|
||
app.disk_project.as_ref().unwrap().bundle.entry,
|
||
"another.arcadia"
|
||
);
|
||
assert!(Arc::ptr_eq(&saved, &app.document.snapshot()));
|
||
save(&mut app, destination).unwrap();
|
||
}
|
||
#[test]
|
||
fn two_file_apply_graphical_edit_exact_undo_save_as_and_reopen() {
|
||
let directory = Directory::new();
|
||
let mut project = Project::empty();
|
||
let activity = project
|
||
.add_element(Kind::Activity, Layer::Oa, "Assess incident", None)
|
||
.unwrap();
|
||
let mut app = App::with_project(project);
|
||
let fragment = app.document.compiled().index.declarations[&activity]
|
||
.path
|
||
.clone();
|
||
send(&mut app, Message::SourceFile(fragment.clone()));
|
||
let edited = format!(
|
||
"// Engineering rationale: preserve this comment.\n{}",
|
||
app.source_editor
|
||
.text()
|
||
.replace("Assess incident", "Review incident")
|
||
);
|
||
edit(&mut app, &edited);
|
||
let entry = app.source_workspace.entry.clone();
|
||
send(&mut app, Message::SourceFile(entry.clone()));
|
||
let entry_text = format!("{}\n// Project rationale.\n", app.source_editor.text());
|
||
edit(&mut app, &entry_text);
|
||
assert!(app.apply_source(), "{}", app.status);
|
||
let accepted = app.document.snapshot();
|
||
send(
|
||
&mut app,
|
||
Message::Command(Command::Rename(activity, "Evaluate incident".into())),
|
||
);
|
||
assert_eq!(app.project().elements[&activity].name, "Evaluate incident");
|
||
assert!(app.document.bundle().files[&fragment].starts_with("// Engineering rationale"));
|
||
send(&mut app, Message::Undo);
|
||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||
send(&mut app, Message::Redo);
|
||
let model = app.project().clone();
|
||
let path = directory.0.join("study.arcadia");
|
||
save(&mut app, path.clone()).unwrap();
|
||
assert!(!app.dirty());
|
||
assert_eq!(app.source_workspace.entry, "study.arcadia");
|
||
assert!(!app.source_workspace.files.contains_key(&entry));
|
||
let mut reopened = App::with_project(Project::empty());
|
||
send(
|
||
&mut reopened,
|
||
Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))),
|
||
);
|
||
assert_eq!(
|
||
reopened.project(),
|
||
&model,
|
||
"{}: {:?}",
|
||
reopened.status,
|
||
reopened.source_diagnostics
|
||
);
|
||
assert_eq!(reopened.document.bundle(), app.document.bundle());
|
||
assert!(!reopened.dirty());
|
||
}
|
||
#[test]
|
||
fn inactive_invalid_draft_blocks_graphical_changes_and_diagnostic_switches_files() {
|
||
let mut project = Project::empty();
|
||
let activity = project
|
||
.add_element(Kind::Activity, Layer::Oa, "Assess incident", None)
|
||
.unwrap();
|
||
let mut app = App::with_project(project.clone());
|
||
let fragment = app.document.compiled().index.declarations[&activity]
|
||
.path
|
||
.clone();
|
||
send(&mut app, Message::SourceFile(fragment.clone()));
|
||
edit(&mut app, "? invalid source");
|
||
let entry = app.source_workspace.entry.clone();
|
||
send(&mut app, Message::SourceFile(entry));
|
||
send(&mut app, Message::Page(Page::Diagram));
|
||
send(
|
||
&mut app,
|
||
Message::Command(Command::Rename(activity, "Must not happen".into())),
|
||
);
|
||
assert_eq!(app.project(), &project);
|
||
assert!(!app.apply_source());
|
||
assert_eq!(app.source_diagnostics[0].path, fragment);
|
||
send(&mut app, Message::SourceDiagnostic(0));
|
||
assert_eq!(app.source_workspace.active, fragment);
|
||
assert_eq!(app.source_editor.text(), "? invalid source");
|
||
send(&mut app, Message::RevertSource);
|
||
assert!(!app.source_dirty());
|
||
assert_eq!(app.project(), &project);
|
||
}
|
||
#[test]
|
||
fn missing_include_open_keeps_old_model_and_can_be_repaired_and_saved() {
|
||
let directory = Directory::new();
|
||
let compiled = source_project::compile_bundle(
|
||
&source_project::format_bundle(&Project::empty()),
|
||
LIMITS,
|
||
)
|
||
.unwrap();
|
||
let mut entry = compiled.bundle.files[&compiled.bundle.entry].clone();
|
||
entry.insert_str(
|
||
compiled.index.project_body.span.end,
|
||
"\n include \"missing.arcadia\";\n",
|
||
);
|
||
let path = directory.0.join(&compiled.bundle.entry);
|
||
std::fs::write(&path, &entry).unwrap();
|
||
let mut app = App::with_project(Project::demo());
|
||
let original = app.project().clone();
|
||
send(
|
||
&mut app,
|
||
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
|
||
);
|
||
assert_eq!(app.project(), &original);
|
||
assert!(!app.source_workspace.applied);
|
||
assert_eq!(app.source_editor.text(), entry);
|
||
send(&mut app, Message::SourceNewPath("missing.arcadia".into()));
|
||
send(&mut app, Message::SourceCreateFile);
|
||
assert!(app.apply_source(), "{}", app.status);
|
||
assert_eq!(app.project(), &compiled.decoded.project);
|
||
save(&mut app, path).unwrap();
|
||
assert_eq!(
|
||
std::fs::read(directory.0.join("missing.arcadia")).unwrap(),
|
||
Vec::<u8>::new()
|
||
);
|
||
assert!(!app.dirty());
|
||
}
|
||
#[test]
|
||
fn closed_detached_file_keeps_first_save_conflict_then_can_be_reloaded_fresh() {
|
||
let directory = Directory::new();
|
||
let mut app = App::with_project(Project::empty());
|
||
send(&mut app, Message::SourceNewPath("spare.arcadia".into()));
|
||
send(&mut app, Message::SourceCreateFile);
|
||
assert!(app.apply_source());
|
||
let path = directory.0.join("project.arcadia");
|
||
save(&mut app, path.clone()).unwrap();
|
||
send(&mut app, Message::SourceFile("project.arcadia".into()));
|
||
let removed = app
|
||
.source_editor
|
||
.text()
|
||
.replace(" include \"spare.arcadia\";\n", "");
|
||
edit(&mut app, &removed);
|
||
assert!(app.apply_source());
|
||
send(&mut app, Message::SourceCloseFile("spare.arcadia".into()));
|
||
assert!(!app.source_workspace.files.contains_key("spare.arcadia"));
|
||
assert!(
|
||
app.disk_project
|
||
.as_ref()
|
||
.unwrap()
|
||
.expectations
|
||
.contains_key("spare.arcadia")
|
||
);
|
||
std::fs::write(directory.0.join("spare.arcadia"), "// external edit\n").unwrap();
|
||
assert!(save(&mut app, path.clone()).is_err());
|
||
assert!(
|
||
std::fs::read_to_string(&path)
|
||
.unwrap()
|
||
.contains("include \"spare.arcadia\"")
|
||
);
|
||
std::fs::write(directory.0.join("spare.arcadia"), "").unwrap();
|
||
save(&mut app, path.clone()).unwrap();
|
||
assert!(
|
||
!app.disk_project
|
||
.as_ref()
|
||
.unwrap()
|
||
.expectations
|
||
.contains_key("spare.arcadia")
|
||
);
|
||
std::fs::write(directory.0.join("spare.arcadia"), "// new external text\n").unwrap();
|
||
let id = app.project().id;
|
||
send(
|
||
&mut app,
|
||
Message::Command(Command::Rename(id, "New project name".into())),
|
||
);
|
||
save(&mut app, path).unwrap();
|
||
let mut entry = app.source_editor.text();
|
||
entry.insert_str(
|
||
app.document.compiled().index.project_body.span.end,
|
||
"\n include \"spare.arcadia\";\n",
|
||
);
|
||
edit(&mut app, &entry);
|
||
let disk = source_files::discover_missing(
|
||
app.disk_project.as_ref().unwrap(),
|
||
&app.source_workspace.bundle().files,
|
||
LIMITS,
|
||
)
|
||
.unwrap();
|
||
let version = app.source_version;
|
||
send(&mut app, Message::IncludesLoaded(version, Ok(disk)));
|
||
assert_eq!(
|
||
app.source_workspace.files["spare.arcadia"].text,
|
||
"// new external text\n"
|
||
);
|
||
assert!(app.apply_source());
|
||
}
|
||
#[test]
|
||
fn json_export_and_rejected_save_as_preserve_source_location_history_and_bytes() {
|
||
let directory = Directory::new();
|
||
let mut app = App::with_project(Project::empty());
|
||
let id = app.project().id;
|
||
send(
|
||
&mut app,
|
||
Message::Command(Command::Rename(id, "Unsaved model".into())),
|
||
);
|
||
let before = app.document.snapshot();
|
||
let revision = app.document.revision();
|
||
save(&mut app, directory.0.join("export.json")).unwrap();
|
||
assert!(app.path.is_none());
|
||
assert!(app.dirty());
|
||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||
let path = directory.0.join("collision.arcadia");
|
||
std::fs::write(&path, "Preserve existing file").unwrap();
|
||
assert!(save(&mut app, path.clone()).is_err());
|
||
assert_eq!(
|
||
std::fs::read_to_string(path).unwrap(),
|
||
"Preserve existing file"
|
||
);
|
||
assert_eq!(app.document.revision(), revision);
|
||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod port_view_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn catalog_creation_follows_the_new_port_and_keeps_group_browsing_explicit() {
|
||
let mut project = Project::empty();
|
||
let actor = project
|
||
.add_element(Kind::SystemActor, Layer::Sa, "External operator", None)
|
||
.unwrap();
|
||
let mut app = App::with_project(project);
|
||
let _ = app.update(Message::Explore(Some(actor)));
|
||
app.explorer_page = 3;
|
||
app.inspector_tab = crate::navigation::InspectorTab::Connections;
|
||
let _ = app.update(Message::PortAction(crate::port_workflow::Action::Component));
|
||
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
|
||
assert_eq!(app.page, Page::Catalog);
|
||
assert_eq!(app.project().elements[&app.selected].parent, Some(actor));
|
||
assert_eq!(app.explorer_focus, Some(app.selected));
|
||
assert_eq!(app.explorer_group, app.explorer_index.group(app.selected));
|
||
assert_eq!(app.explorer_page, 0);
|
||
assert_eq!(
|
||
app.inspector_tab,
|
||
crate::navigation::InspectorTab::Properties
|
||
);
|
||
let selected = app.selected;
|
||
let group = app.explorer_group.unwrap();
|
||
let _ = app.update(Message::BrowseGroup(group));
|
||
assert_eq!(app.selected, selected);
|
||
assert_eq!(app.explorer_group, Some(group));
|
||
assert!(app.explorer_focus.is_none());
|
||
let _ = app.update(Message::Page(Page::Guide));
|
||
let _ = app.update(Message::Page(Page::Catalog));
|
||
assert_eq!(app.explorer_focus, Some(selected));
|
||
assert_eq!(app.explorer_page, 0);
|
||
}
|
||
|
||
#[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,
|
||
routes: Vec::new(),
|
||
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();
|
||
app.inspector_tab = crate::navigation::InspectorTab::Connections;
|
||
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.inspector_tab,
|
||
crate::navigation::InspectorTab::Properties
|
||
);
|
||
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 = source_workspace::lint(&app.source_workspace.bundle());
|
||
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(source_workspace::lint(&app.source_workspace.bundle())),
|
||
));
|
||
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());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod data_paging_lifecycle_tests {
|
||
use super::*;
|
||
#[test]
|
||
fn paging_preserves_same_context_and_resets_on_layer_or_document_replacement() {
|
||
let mut p = Project::empty();
|
||
let mut oa = None;
|
||
for layer in [Layer::Oa, Layer::Sa] {
|
||
for index in 0..70 {
|
||
let id = p
|
||
.add_element(
|
||
Kind::Interface,
|
||
layer,
|
||
&format!("Interface {layer} {index}"),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
if layer == Layer::Oa {
|
||
oa = Some(id);
|
||
}
|
||
}
|
||
}
|
||
let mut app = App::with_project(p.clone());
|
||
let page = Message::Data(crate::data_view::Event::Page {
|
||
section: crate::data_view::Section::Catalog,
|
||
owner: None,
|
||
page: 2,
|
||
});
|
||
let _ = app.update(Message::Page(Page::Data));
|
||
let _ = app.update(page.clone());
|
||
let retained = app.data_state.clone();
|
||
let _ = app.update(Message::Page(Page::Catalog));
|
||
let _ = app.update(Message::Page(Page::Data));
|
||
let _ = app.update(Message::Layer(Layer::Oa));
|
||
assert_eq!(app.data_state, retained);
|
||
let _ = app.update(Message::Layer(Layer::Sa));
|
||
assert_eq!(app.data_state, crate::data_view::State::default());
|
||
let _ = app.update(page.clone());
|
||
let _ = app.update(Message::Select(oa.unwrap()));
|
||
assert_eq!(app.layer, Layer::Oa);
|
||
assert_eq!(app.data_state, crate::data_view::State::default());
|
||
let _ = app.update(page);
|
||
app.reset(p, None);
|
||
assert_eq!(app.data_state, crate::data_view::State::default());
|
||
}
|
||
}
|