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

4379 lines
173 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
}
}