1122 lines
41 KiB
Rust
1122 lines
41 KiB
Rust
use crate::{
|
||
app::{App, Choice, FileOperation, Message, Page, Pending},
|
||
diagram::DiagramCanvas,
|
||
forms, style,
|
||
};
|
||
use iced::{
|
||
Element as View, Fill, Font,
|
||
widget::{
|
||
self, Space, button, column, container, pick_list, row, scrollable, text, text_input,
|
||
tooltip,
|
||
},
|
||
};
|
||
use oa_core::*;
|
||
|
||
pub fn label<'a>(s: impl Into<String>) -> widget::Text<'a> {
|
||
text(s.into()).size(11).color(style::MUTED)
|
||
}
|
||
pub fn action<'a>(s: &str, m: Message) -> widget::Button<'a, Message> {
|
||
button(text(s.to_owned()).size(12))
|
||
.padding([8, 10])
|
||
.on_press(m)
|
||
.style(style::nav)
|
||
}
|
||
pub fn short(s: &str, limit: usize) -> String {
|
||
let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
if s.chars().count() > limit {
|
||
format!(
|
||
"{}…",
|
||
s.chars().take(limit.saturating_sub(1)).collect::<String>()
|
||
)
|
||
} else {
|
||
s
|
||
}
|
||
}
|
||
pub fn heading<'a>(title: &str, subtitle: &str) -> View<'a, Message> {
|
||
column![
|
||
text(title.to_owned()).size(24).color(style::INK),
|
||
label(subtitle)
|
||
]
|
||
.spacing(6)
|
||
.into()
|
||
}
|
||
|
||
pub fn view(app: &App) -> View<'_, Message> {
|
||
let p = app.project();
|
||
let header = container(
|
||
row![
|
||
row![
|
||
text("◈").size(28).color(style::TEAL),
|
||
column![
|
||
text("ARCADIA STUDIO").size(14),
|
||
label("SYSTEMS ENGINEERING · V2")
|
||
]
|
||
.spacing(3)
|
||
]
|
||
.spacing(10)
|
||
.width(220),
|
||
column![
|
||
text(short(&p.name, 46)).size(17),
|
||
label(if app.dirty() {
|
||
"Unsaved changes"
|
||
} else if app.path.is_some() {
|
||
"All changes saved"
|
||
} else {
|
||
"Local project"
|
||
})
|
||
]
|
||
.spacing(3)
|
||
.width(Fill),
|
||
action("New", Message::Request(Pending::New)),
|
||
action("Open", Message::Request(Pending::Open)),
|
||
action("Save as", Message::Save(true)),
|
||
button(text("Save all").size(12))
|
||
.padding([10, 16])
|
||
.on_press(Message::Save(false))
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center),
|
||
)
|
||
.padding([14, 18])
|
||
.style(style::panel);
|
||
let mut nav = column![
|
||
label("ENGINEERING PERSPECTIVE"),
|
||
row![
|
||
button(text("01 Operational").size(11))
|
||
.padding(9)
|
||
.width(Fill)
|
||
.on_press(Message::Layer(Layer::Oa))
|
||
.style(if app.layer == Layer::Oa {
|
||
button::primary
|
||
} else {
|
||
style::nav
|
||
}),
|
||
button(text("02 System").size(11))
|
||
.padding(9)
|
||
.width(Fill)
|
||
.on_press(Message::Layer(Layer::Sa))
|
||
.style(if app.layer == Layer::Sa {
|
||
button::primary
|
||
} else {
|
||
style::nav
|
||
})
|
||
]
|
||
.spacing(3),
|
||
Space::new().height(5)
|
||
]
|
||
.spacing(8);
|
||
let mut task_nav = row![].spacing(2);
|
||
for (page, name) in [
|
||
(Page::Diagram, "Architecture"),
|
||
(Page::Catalog, "Model explorer"),
|
||
(Page::Behavior, "Behavior & scenarios"),
|
||
(Page::Data, "Data & interfaces"),
|
||
(Page::Traces, "Traceability"),
|
||
(Page::Text, "Textual model"),
|
||
(Page::Validation, "Model checks"),
|
||
(Page::Guide, "Engineering guide"),
|
||
] {
|
||
task_nav = task_nav.push(
|
||
button(text(name).size(12))
|
||
.padding([9, 11])
|
||
.on_press(Message::Page(page))
|
||
.style(if app.page == page {
|
||
style::selected
|
||
} else {
|
||
style::nav
|
||
}),
|
||
);
|
||
}
|
||
let task_nav = container(task_nav)
|
||
.width(Fill)
|
||
.padding([4, 14])
|
||
.style(style::panel);
|
||
let mut creation = column![label("CREATE ELEMENT")].spacing(8);
|
||
let kinds = Kind::ALL
|
||
.into_iter()
|
||
.filter(|k| k.supports(app.layer) && *k != Kind::Port)
|
||
.collect::<Vec<_>>();
|
||
creation = creation.push(
|
||
pick_list(kinds, Some(app.create_kind), Message::CreateKind)
|
||
.text_size(12)
|
||
.padding(8)
|
||
.width(Fill),
|
||
);
|
||
let allocated = app.create_kind.is_work()
|
||
&& p.elements
|
||
.get(&app.selected)
|
||
.is_some_and(|e| e.layer() == app.layer && e.kind().is_performer());
|
||
if crate::port_workflow::actions(p, app.selected)
|
||
.contains(&crate::port_workflow::Action::Component)
|
||
{
|
||
creation = creation.push(
|
||
button(text("+ Add port to selected…").size(12))
|
||
.padding(9)
|
||
.width(Fill)
|
||
.on_press(Message::PortAction(crate::port_workflow::Action::Component)),
|
||
);
|
||
}
|
||
creation = creation.push(
|
||
row![
|
||
button(text("+ At root").size(11))
|
||
.padding(8)
|
||
.style(style::nav)
|
||
.width(Fill)
|
||
.on_press_maybe(
|
||
validation::can_parent(p, app.create_kind, app.layer, None)
|
||
.then_some(Message::Add(false))
|
||
),
|
||
button(
|
||
text(if allocated {
|
||
"+ Allocated"
|
||
} else {
|
||
"+ In selected"
|
||
})
|
||
.size(11)
|
||
)
|
||
.padding(8)
|
||
.style(style::nav)
|
||
.width(Fill)
|
||
.on_press_maybe(
|
||
(allocated
|
||
|| validation::can_parent(p, app.create_kind, app.layer, Some(app.selected)))
|
||
.then_some(Message::Add(true))
|
||
)
|
||
]
|
||
.spacing(3),
|
||
);
|
||
nav = nav.push(
|
||
text_input("Search names or @aliases…", &app.search)
|
||
.on_input(Message::Search)
|
||
.size(12)
|
||
.padding(9),
|
||
);
|
||
nav = nav
|
||
.push(
|
||
row![
|
||
action("Collapse all", Message::ExplorerCollapseAll),
|
||
action("Locate", Message::ExplorerLocate)
|
||
]
|
||
.spacing(3),
|
||
)
|
||
.push(crate::explorer_view::sidebar(app))
|
||
.push(creation)
|
||
.push(action("Project properties", Message::SelectProject).width(Fill));
|
||
let sidebar = container(if app.page == Page::Text {
|
||
source_files(app)
|
||
} else {
|
||
nav.into()
|
||
})
|
||
.padding(14)
|
||
.width(260)
|
||
.height(Fill)
|
||
.style(style::panel);
|
||
let main = match app.page {
|
||
Page::Diagram => diagram(app),
|
||
Page::Catalog => crate::explorer_view::catalog(app),
|
||
Page::Data => data_catalog(app),
|
||
Page::Behavior => crate::behavior::view(app),
|
||
Page::Traces => traces(app),
|
||
Page::Text => source(app),
|
||
Page::Validation => checks(app),
|
||
Page::Guide => crate::guide::view(&app.guide),
|
||
};
|
||
let mut body = row![sidebar, container(main).width(Fill).height(Fill)].height(Fill);
|
||
if !matches!(app.page, Page::Text | Page::Guide) {
|
||
body = body.push(inspector(app));
|
||
}
|
||
let footer = container(
|
||
row![
|
||
text(short(if app.busy { "Working…" } else { &app.status }, 145))
|
||
.size(11)
|
||
.width(Fill),
|
||
label(format!(
|
||
"{} objects · {} links",
|
||
p.elements.len(),
|
||
p.relations.len()
|
||
)),
|
||
action(
|
||
&format!("{} checks", app.issue_count()),
|
||
Message::Page(Page::Validation)
|
||
)
|
||
]
|
||
.spacing(12)
|
||
.align_y(iced::Center),
|
||
)
|
||
.padding([4, 16])
|
||
.style(style::panel);
|
||
let root: View<'_, Message> = column![header, task_nav, body, footer].into();
|
||
if let Some(workflow) = &app.workflow {
|
||
return modal(root, workflow.view(p));
|
||
}
|
||
if let Some(p) = &app.file_prompt {
|
||
let title = match p.operation {
|
||
FileOperation::Open => "Open model",
|
||
FileOperation::Save => "Save model",
|
||
FileOperation::Export => "Export diagram",
|
||
};
|
||
return modal(
|
||
root,
|
||
column![
|
||
text(title).size(21),
|
||
label(match p.operation {
|
||
FileOperation::Export => "Save this diagram as .svg.",
|
||
FileOperation::Save => "Choose a .arcadia entry in a new destination, or .json to export the model.",
|
||
FileOperation::Open => "Open an .arcadia project entry or import a .json model.",
|
||
}),
|
||
text_input("File path", &p.path)
|
||
.on_input(Message::FilePath)
|
||
.on_submit(Message::SubmitFile)
|
||
.padding(10)
|
||
.size(13),
|
||
text(match (p.operation, p.overwrite) {
|
||
(FileOperation::Export, true) =>
|
||
"This file exists. Replace it with the current diagram?",
|
||
(FileOperation::Export, false) =>
|
||
"Includes visible elements and their authored connection routes.",
|
||
(FileOperation::Save, _) => "Source Save As retains included file paths. Existing destination files are protected.",
|
||
(FileOperation::Open, _) => "Included source files are loaded together. Invalid drafts remain editable.",
|
||
})
|
||
.size(12),
|
||
row![
|
||
action("Cancel", Message::CancelFile),
|
||
button(text(if p.overwrite { "Replace file" } else { title }).size(12))
|
||
.padding(10)
|
||
.on_press(Message::SubmitFile)
|
||
]
|
||
.spacing(10)
|
||
]
|
||
.spacing(16)
|
||
.into(),
|
||
);
|
||
}
|
||
if app.delete_pending {
|
||
return modal(root,column![text("Delete from model?").size(21),text(format!("{} and its owned elements will be removed. Incident relationships, occurrences and view placements are updated together. Counterparts in the other layer remain.",p.named(app.selected))).size(13),label("Undo restores the complete change. Use Hide for diagram-only removal."),row![action("Cancel",Message::CancelDelete),button("Delete from model").style(button::danger).padding(10).on_press(Message::ConfirmDelete)].spacing(10)].spacing(16).into());
|
||
}
|
||
if app.pending.is_some() {
|
||
return modal(root,column![text("Keep your changes?").size(21),text("Save the model and pending edits before continuing. Invalid drafts stay available for correction.").size(13),row![action("Cancel",Message::Cancel),action("Discard changes",Message::Discard),button("Save & continue").padding(10).on_press(Message::SaveAndContinue)].spacing(8)].spacing(16).into());
|
||
}
|
||
root
|
||
}
|
||
fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> {
|
||
widget::stack![
|
||
base,
|
||
widget::opaque(
|
||
container(container(content).padding(26).width(800).style(style::card))
|
||
.center(Fill)
|
||
.style(|_| container::Style {
|
||
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
|
||
..Default::default()
|
||
})
|
||
)
|
||
]
|
||
.into()
|
||
}
|
||
fn diagram(app: &App) -> View<'_, Message> {
|
||
let diagrams = app
|
||
.project()
|
||
.diagrams
|
||
.values()
|
||
.filter(|d| d.layer == app.layer || d.projection == ProjectionKind::Traceability)
|
||
.map(|d| Choice {
|
||
id: d.id,
|
||
label: short(
|
||
&d.name,
|
||
if app.window_size.width < 1250. {
|
||
26
|
||
} else {
|
||
48
|
||
},
|
||
),
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let selected = diagrams
|
||
.iter()
|
||
.find(|c| Some(c.id) == app.active_diagram)
|
||
.cloned();
|
||
let mut chooser = row![
|
||
pick_list(diagrams, selected, Message::Diagram)
|
||
.placeholder("Choose diagram")
|
||
.text_size(12)
|
||
.padding(8)
|
||
.width(Fill),
|
||
action("+ View", Message::AddDiagram)
|
||
]
|
||
.spacing(7)
|
||
.align_y(iced::Center);
|
||
let mut controls = row![
|
||
action("Fit", Message::Fit),
|
||
action("−", Message::Zoom(1. / 1.2)),
|
||
label(format!("{:.0}%", app.zoom * 100.)),
|
||
action("+", Message::Zoom(1.2)),
|
||
action("Export SVG", Message::Export)
|
||
]
|
||
.spacing(7)
|
||
.align_y(iced::Center);
|
||
if let Some(id) = app.active_diagram {
|
||
chooser = chooser.push(action("View properties", Message::Select(id)));
|
||
controls = controls.push(action("Arrange", Message::Command(Command::Layout(id))));
|
||
}
|
||
let toolbar = column![chooser, controls].spacing(6);
|
||
let projection = app
|
||
.active_diagram
|
||
.and_then(|id| app.project().diagrams.get(&id))
|
||
.map(|d| d.projection);
|
||
let modes = row![
|
||
label("View content"),
|
||
pick_list(
|
||
[
|
||
ProjectionKind::Allocation,
|
||
ProjectionKind::FunctionalDataflow,
|
||
ProjectionKind::Capabilities,
|
||
ProjectionKind::Decomposition,
|
||
ProjectionKind::Overview,
|
||
ProjectionKind::Traceability,
|
||
],
|
||
projection,
|
||
Message::Projection
|
||
)
|
||
.padding(8)
|
||
.text_size(12)
|
||
]
|
||
.spacing(10)
|
||
.align_y(iced::Center);
|
||
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
|
||
let program = DiagramCanvas {
|
||
project: app.project(),
|
||
diagram: id,
|
||
selected: app.selected,
|
||
zoom: app.zoom,
|
||
pan: app.pan,
|
||
context: app.route_context().unwrap(),
|
||
selected_bend: app
|
||
.selected_bend
|
||
.filter(|(edge, _)| *edge == app.selected)
|
||
.map(|(_, i)| i),
|
||
editable: !app.draft_dirty() && !app.source_dirty(),
|
||
connection: app.connection.as_ref(),
|
||
};
|
||
widget::sensor(widget::stack![
|
||
widget::canvas(program).width(Fill).height(Fill),
|
||
widget::canvas(crate::diagram::PortLabels(program))
|
||
.width(Fill)
|
||
.height(Fill),
|
||
])
|
||
.on_show(Message::CanvasResized)
|
||
.on_resize(Message::CanvasResized)
|
||
.into()
|
||
} else {
|
||
container(
|
||
column![
|
||
text(if app.layer == Layer::Oa {
|
||
"Define the operational context"
|
||
} else {
|
||
"Define your system boundary"
|
||
})
|
||
.size(23),
|
||
text("Create a view, then add participants and the work they perform.")
|
||
.size(13)
|
||
.color(style::MUTED),
|
||
button("Create architecture view")
|
||
.padding(12)
|
||
.on_press(Message::AddDiagram)
|
||
]
|
||
.spacing(16),
|
||
)
|
||
.center(Fill)
|
||
.into()
|
||
};
|
||
let choose = |tool: Option<crate::connection_tool::Tool>| {
|
||
let active = app.connection.as_ref().map(|s| s.tool) == tool;
|
||
button(text(tool.map_or("Select / move", |t| t.label())).size(12))
|
||
.padding([8, 10])
|
||
.on_press(Message::Connection(crate::connection_edit::Event::Choose(
|
||
tool,
|
||
)))
|
||
.style(if active { button::primary } else { style::nav })
|
||
};
|
||
let mut palette = row![choose(None)].spacing(4);
|
||
for tool in crate::connection_tool::Tool::for_layer(app.layer) {
|
||
palette = palette.push(choose(Some(*tool)));
|
||
}
|
||
let mut tools = column![toolbar, modes, palette].spacing(8);
|
||
if let Some(diagram) = app.active_diagram {
|
||
let view = oa_core::projection::project_view(app.project(), diagram);
|
||
let edge = view.edges.iter().find(|edge| edge.id == app.selected);
|
||
let promoted = edge.is_some_and(|edge| {
|
||
oa_core::projection::edge_endpoint_placements(
|
||
app.project(),
|
||
&app.project().diagrams[&diagram],
|
||
edge.id,
|
||
)
|
||
.is_some_and(|(source, target)| source != edge.source || target != edge.target)
|
||
});
|
||
let hint = if let Some(session) = &app.connection {
|
||
if session.source.is_some() {
|
||
if session.candidates.is_empty() {
|
||
"No compatible targets are visible. Expand containers or show an endpoint · Esc cancels"
|
||
} else {
|
||
"Choose a highlighted target · middle-drag or arrow keys pan · Esc cancels"
|
||
}
|
||
} else {
|
||
session.tool.hint()
|
||
}
|
||
} else if promoted {
|
||
"Expand endpoint containers to edit this connection route"
|
||
} else if edge.is_some() {
|
||
"Right-click for routing · drag line / bend / label · Insert adds a bend · Esc cancels"
|
||
} else {
|
||
"Right-click for actions · drag to arrange · scroll to zoom"
|
||
};
|
||
tools = tools.push(container(label(hint)).height(30));
|
||
}
|
||
column![container(tools).padding(14), canvas].into()
|
||
}
|
||
|
||
fn inspector(app: &App) -> View<'_, Message> {
|
||
let p = app.project();
|
||
if app.source_dirty() {
|
||
let mut notice = column![
|
||
text("Source drafts pending").size(17),
|
||
label("Properties become editable after source drafts are applied or reverted."),
|
||
action("Open source drafts", Message::Page(Page::Text))
|
||
]
|
||
.spacing(12);
|
||
if app.draft_dirty() {
|
||
notice = notice
|
||
.push(label(
|
||
"A property draft is also retained. Revert it to apply the source drafts.",
|
||
))
|
||
.push(action("Revert property draft", Message::RevertProperties));
|
||
}
|
||
return container(notice)
|
||
.padding(14)
|
||
.width(320)
|
||
.height(Fill)
|
||
.style(style::panel)
|
||
.into();
|
||
}
|
||
let tabs = row![
|
||
action(
|
||
"Properties",
|
||
Message::InspectorTab(crate::navigation::InspectorTab::Properties)
|
||
)
|
||
.style(
|
||
if app.inspector_tab == crate::navigation::InspectorTab::Properties {
|
||
style::selected
|
||
} else {
|
||
style::nav
|
||
}
|
||
),
|
||
action(
|
||
"Connections",
|
||
Message::InspectorTab(crate::navigation::InspectorTab::Connections)
|
||
)
|
||
.style(
|
||
if app.inspector_tab == crate::navigation::InspectorTab::Connections {
|
||
style::selected
|
||
} else {
|
||
style::nav
|
||
}
|
||
),
|
||
]
|
||
.spacing(4);
|
||
if app.inspector_tab == crate::navigation::InspectorTab::Connections {
|
||
return container(
|
||
column![
|
||
tabs,
|
||
scrollable(crate::navigation::view(p, app.selected)).height(Fill)
|
||
]
|
||
.spacing(14),
|
||
)
|
||
.padding(14)
|
||
.width(320)
|
||
.height(Fill)
|
||
.style(style::panel)
|
||
.into();
|
||
}
|
||
let mut body = column![
|
||
row![
|
||
label("PROPERTIES"),
|
||
Space::new().width(Fill),
|
||
crate::model_menu::trigger(app)
|
||
]
|
||
.align_y(iced::Center)
|
||
]
|
||
.spacing(12);
|
||
if let Some(e) = p.elements.get(&app.selected) {
|
||
body = body.push(
|
||
row![
|
||
text(e.kind().label())
|
||
.size(17)
|
||
.color(style::kind_color(e.kind())),
|
||
Space::new().width(Fill),
|
||
label(e.layer().to_string())
|
||
]
|
||
.align_y(iced::Center),
|
||
);
|
||
body = body.push(
|
||
pick_list(
|
||
Kind::ALL
|
||
.into_iter()
|
||
.filter(|k| k.supports(e.layer()))
|
||
.collect::<Vec<_>>(),
|
||
Some(e.kind()),
|
||
Message::ElementKind,
|
||
)
|
||
.text_size(12)
|
||
.padding(8)
|
||
.width(Fill),
|
||
);
|
||
if let Some(parent) = e.parent {
|
||
body = body.push(
|
||
action(
|
||
&format!("Parent: {}", short(p.named(parent), 27)),
|
||
Message::Select(parent),
|
||
)
|
||
.width(Fill),
|
||
);
|
||
}
|
||
for r in p
|
||
.relations
|
||
.values()
|
||
.filter(|r| r.kind() == RelationKind::Allocation && r.target() == e.id)
|
||
{
|
||
body = body.push(
|
||
action(
|
||
&format!("Performer: {}", short(p.named(r.source()), 24)),
|
||
Message::Select(r.source()),
|
||
)
|
||
.width(Fill),
|
||
);
|
||
}
|
||
if !p
|
||
.relations
|
||
.values()
|
||
.any(|r| r.kind() == RelationKind::Allocation && r.target() == e.id)
|
||
&& let Some(performer) = oa_core::validation::allocation_summary(p, e.id)
|
||
{
|
||
body = body.push(label(
|
||
"Allocation summary: all leaf activities or functions belong to the same performer. The parent has no direct allocation.",
|
||
));
|
||
body = body.push(
|
||
action(
|
||
&format!("Leaf performer: {}", short(p.named(performer), 24)),
|
||
Message::Select(performer),
|
||
)
|
||
.width(Fill),
|
||
);
|
||
}
|
||
}
|
||
// Keep a discoverable primary action; secondary actions live in Actions.
|
||
if p.elements
|
||
.get(&app.selected)
|
||
.is_some_and(|e| matches!(e.kind(), Kind::System | Kind::SystemActor))
|
||
{
|
||
body = body.push(
|
||
button(text("+ Add port…").size(12))
|
||
.padding(9)
|
||
.width(Fill)
|
||
.on_press(Message::PortAction(crate::port_workflow::Action::Component)),
|
||
);
|
||
}
|
||
if p.elements
|
||
.get(&app.selected)
|
||
.is_some_and(|e| matches!(port_contract(e), Some(PortContract::Component { .. })))
|
||
{
|
||
let draw = app.page == Page::Diagram
|
||
&& crate::connection_tool::Tool::BoundaryExchange.can_start(p, app.selected)
|
||
&& app.active_diagram.is_some_and(|diagram| {
|
||
crate::connection_edit::visible(&projection::project_view(p, diagram))
|
||
.any(|id| id == app.selected)
|
||
});
|
||
body = body.push(
|
||
button(
|
||
text(if draw {
|
||
"Draw boundary exchange"
|
||
} else {
|
||
"Configure boundary exchange…"
|
||
})
|
||
.size(12),
|
||
)
|
||
.padding(9)
|
||
.width(Fill)
|
||
.on_press(if draw {
|
||
Message::Connection(crate::connection_edit::Event::Begin(
|
||
crate::connection_tool::Tool::BoundaryExchange,
|
||
app.selected,
|
||
))
|
||
} else {
|
||
Message::AddRelationKind(
|
||
RelationKind::BoundaryExchange,
|
||
p.elements.get(&app.selected).is_some_and(|e| {
|
||
matches!(
|
||
port_contract(e),
|
||
Some(PortContract::Component {
|
||
kind: ComponentPortKind::Flow,
|
||
orientation: ComponentPortOrientation::In
|
||
})
|
||
)
|
||
}),
|
||
)
|
||
}),
|
||
);
|
||
}
|
||
if let Some(r) = p.relations.get(&app.selected) {
|
||
body = body.push(text(r.kind().to_string()).size(17)).push(
|
||
pick_list(RelationKind::ALL, Some(r.kind()), Message::RelationKind)
|
||
.text_size(12)
|
||
.padding(8)
|
||
.width(Fill),
|
||
);
|
||
}
|
||
body = body.push(
|
||
row![
|
||
button(text("Apply changes").size(12))
|
||
.padding([9, 12])
|
||
.on_press(Message::Apply)
|
||
.width(Fill),
|
||
action("Revert", Message::RevertProperties)
|
||
]
|
||
.spacing(5),
|
||
);
|
||
if let Some(d) = &app.draft {
|
||
body = body.push(forms::view(d, p));
|
||
}
|
||
container(
|
||
column![
|
||
tabs,
|
||
scrollable(body.padding(iced::Padding {
|
||
right: 14.,
|
||
..Default::default()
|
||
}))
|
||
.id("inspector")
|
||
.height(Fill)
|
||
]
|
||
.spacing(12),
|
||
)
|
||
.padding(14)
|
||
.width(320)
|
||
.height(Fill)
|
||
.style(style::panel)
|
||
.into()
|
||
}
|
||
|
||
fn data_catalog(app: &App) -> View<'_, Message> {
|
||
crate::data_view::view(app)
|
||
}
|
||
|
||
fn traces(app: &App) -> View<'_, Message> {
|
||
let p = app.project();
|
||
let mut out = column![
|
||
heading(
|
||
"Traceability",
|
||
"Connect operational needs, system responsibilities and development evidence."
|
||
),
|
||
row![
|
||
button(text("Create / update counterpart…").size(12))
|
||
.padding(9)
|
||
.on_press(Message::StartTransition),
|
||
action("+ Manual trace", Message::AddTrace),
|
||
action("+ Artifact", Message::AddArtifact)
|
||
]
|
||
.spacing(8)
|
||
]
|
||
.spacing(16);
|
||
for t in p.traces.values() {
|
||
let current = match (&t.baseline, p.properties(t.oa), p.properties(t.sa)) {
|
||
(Some(b), Some(oa), Some(sa)) if b.oa == oa && b.sa == sa => "Reviewed",
|
||
(Some(_), _, _) => "Review needed",
|
||
_ => "No review baseline",
|
||
};
|
||
out = out.push(
|
||
container(
|
||
column![
|
||
row![
|
||
action(p.named(t.oa), Message::Select(t.oa)).width(Fill),
|
||
text("→").color(style::TEAL),
|
||
action(p.named(t.sa), Message::Select(t.sa)).width(Fill)
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center),
|
||
row![
|
||
label(format!("{:?} · {current}", t.kind)),
|
||
Space::new().width(Fill),
|
||
action("Review link", Message::Select(t.id))
|
||
],
|
||
text(short(&t.description, 130)).size(12)
|
||
]
|
||
.spacing(10),
|
||
)
|
||
.padding(16)
|
||
.style(style::card),
|
||
);
|
||
}
|
||
out = out.push(text("Development artifacts").size(18));
|
||
for a in p.artifact_links.values() {
|
||
let owner = p
|
||
.diagrams
|
||
.values()
|
||
.find_map(|d| {
|
||
d.nodes
|
||
.get(&a.owner)
|
||
.map(|n| format!("Placement of {} · {}", p.named(n.element), d.name))
|
||
})
|
||
.unwrap_or_else(|| p.named(a.owner).to_owned());
|
||
out = out.push(
|
||
container(
|
||
column![
|
||
row![
|
||
action(&a.name, Message::Select(a.id)).width(Fill),
|
||
label(format!("{:?}", a.relation))
|
||
],
|
||
text(&a.uri).size(12).font(Font::MONOSPACE),
|
||
label(format!(
|
||
"{}{}",
|
||
owner,
|
||
a.revision
|
||
.as_ref()
|
||
.map(|r| format!(" · revision {r}"))
|
||
.unwrap_or_default()
|
||
))
|
||
]
|
||
.spacing(8),
|
||
)
|
||
.padding(16)
|
||
.style(style::card),
|
||
);
|
||
}
|
||
if p.traces.is_empty() && p.artifact_links.is_empty() {
|
||
out=out.push(text("Select a model element, then connect it to the other perspective or attach a code, CAD, schematic or document reference.").size(14).color(style::MUTED));
|
||
}
|
||
scrollable(out.padding(24)).into()
|
||
}
|
||
|
||
fn source_files(app: &App) -> View<'_, Message> {
|
||
let workspace = &app.source_workspace;
|
||
let panel = column![
|
||
row![
|
||
text("SOURCE FILES").size(13).width(Fill),
|
||
crate::context_menu::Area::left(
|
||
action("Actions ▾", Message::Menu(crate::model_menu::Event::Close)),
|
||
move |anchor| Message::SourceTree(crate::source_tree::Event::FileMenu(
|
||
workspace.active.clone(),
|
||
anchor
|
||
))
|
||
)
|
||
]
|
||
.align_y(iced::Center),
|
||
label(format!(
|
||
"{} files · one system model",
|
||
workspace.files.len()
|
||
)),
|
||
row![
|
||
action("Collapse all", Message::SourceTreeCollapse),
|
||
action("Locate", Message::SourceTreeLocate)
|
||
]
|
||
.spacing(3),
|
||
crate::source_tree::view(workspace, &app.source_diagnostics, &app.source_tree)
|
||
.map(Message::SourceTree),
|
||
action("Load includes", Message::SourceLoadIncludes).width(Fill),
|
||
text_input("oa/new-file.arcadia", &workspace.new_path)
|
||
.size(12)
|
||
.padding(8)
|
||
.on_input(Message::SourceNewPath)
|
||
.on_submit(Message::SourceCreateFile),
|
||
action("+ Create source file", Message::SourceCreateFile).width(Fill),
|
||
label("Includes define file membership. Closing a detached buffer never deletes its file."),
|
||
]
|
||
.spacing(9);
|
||
panel.into()
|
||
}
|
||
|
||
fn source(app: &App) -> View<'_, Message> {
|
||
use crate::source_navigation::Event as SourceEvent;
|
||
let navigation: View<'_, Message> = if app.source_navigation.open {
|
||
column![
|
||
row![
|
||
text_input("Find literal text…", &app.source_navigation.query)
|
||
.id("source-find")
|
||
.on_input(|query| Message::SourceNavigation(SourceEvent::Query(query)))
|
||
.on_submit(Message::SourceNavigation(SourceEvent::Next(false)))
|
||
.padding(8)
|
||
.size(12)
|
||
.width(Fill),
|
||
action(
|
||
"Previous",
|
||
Message::SourceNavigation(SourceEvent::Next(true))
|
||
),
|
||
action("Next", Message::SourceNavigation(SourceEvent::Next(false))),
|
||
label(app.source_navigation.label()),
|
||
action("Close", Message::SourceNavigation(SourceEvent::Close)),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center),
|
||
row![
|
||
text_input("Line", &app.source_navigation.line)
|
||
.id("source-line")
|
||
.on_input(|line| Message::SourceNavigation(SourceEvent::Line(line)))
|
||
.on_submit(Message::SourceNavigation(SourceEvent::Go))
|
||
.padding(8)
|
||
.size(12)
|
||
.width(80),
|
||
action("Go to line", Message::SourceNavigation(SourceEvent::Go)),
|
||
label("Enter or F3: next match · Shift F3: previous · Esc: close"),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center),
|
||
]
|
||
.spacing(6)
|
||
.into()
|
||
} else {
|
||
column![].into()
|
||
};
|
||
let recovery: View<'_, Message> = if let Some(path) = &app.pending_recovery {
|
||
let edits_block_recovery =
|
||
!app.source_workspace.applied && app.source_workspace.has_edits();
|
||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||
container(
|
||
column![
|
||
tooltip(
|
||
text(format!("Interrupted save · {filename}")).size(15),
|
||
text(path.display().to_string()).size(12),
|
||
tooltip::Position::Bottom,
|
||
),
|
||
text(if edits_block_recovery {
|
||
"Revert the edited files before recovery. Recovery restores files to their state before the interrupted save, then reopens them."
|
||
} else if app.source_workspace.applied {
|
||
"Restore files to their state before the interrupted save. Your current model and editor drafts are kept."
|
||
} else {
|
||
"Restore files to their state before the interrupted save, then reopen them. This rolls back the incomplete save."
|
||
})
|
||
.size(13),
|
||
button(text("Recover interrupted save").size(12))
|
||
.padding(10)
|
||
.on_press_maybe(
|
||
(!edits_block_recovery && !app.busy).then_some(Message::SourceRecover),
|
||
),
|
||
]
|
||
.spacing(8),
|
||
)
|
||
.padding(12)
|
||
.width(Fill)
|
||
.style(style::card)
|
||
.into()
|
||
} else {
|
||
column![].into()
|
||
};
|
||
let mut diagnostics = column![].spacing(5);
|
||
let mut visible_diagnostics = 0;
|
||
for (index, issue) in app.source_diagnostics.iter().enumerate() {
|
||
// The persistent recovery banner supplies the action and explanation;
|
||
// repeating the internal journal path obscures ordinary diagnostics.
|
||
if app.pending_recovery.is_some() && issue.diagnostic.code == "pending_save" {
|
||
continue;
|
||
}
|
||
visible_diagnostics += 1;
|
||
diagnostics = diagnostics.push(
|
||
button(
|
||
text(format!(
|
||
"{}:{}:{} {}",
|
||
issue.path,
|
||
issue.diagnostic.line,
|
||
issue.diagnostic.column,
|
||
issue.diagnostic.message
|
||
))
|
||
.size(12)
|
||
.color(match issue.diagnostic.severity {
|
||
validation::Severity::Error => iced::Color::from_rgb8(174, 46, 64),
|
||
validation::Severity::Warning => iced::Color::from_rgb8(145, 93, 23),
|
||
}),
|
||
)
|
||
.width(Fill)
|
||
.padding([4, 6])
|
||
.style(style::nav)
|
||
.on_press(Message::SourceDiagnostic(index)),
|
||
);
|
||
for related in &issue.related {
|
||
diagnostics = diagnostics.push(action(
|
||
&format!("Related declaration · {}", related.path),
|
||
Message::SourceRelated(related.clone()),
|
||
));
|
||
}
|
||
}
|
||
let editor = widget::text_editor(&app.source_editor)
|
||
.id("source-editor")
|
||
.on_action(Message::SourceEdit)
|
||
.font(Font::MONOSPACE)
|
||
.size(14)
|
||
.padding(16)
|
||
.height(Fill)
|
||
.highlight_with::<crate::highlight::ArcadiaHighlighter>(
|
||
app.source_snapshot.clone(),
|
||
crate::highlight::format,
|
||
);
|
||
column![
|
||
container(
|
||
column![
|
||
row![
|
||
container(heading(
|
||
"Textual model",
|
||
"One model across files · comments and identities preserved"
|
||
))
|
||
.width(Fill),
|
||
button(text("Apply all").size(12))
|
||
.padding(10)
|
||
.on_press(Message::ApplySource),
|
||
action("Revert file", Message::RevertSource)
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center),
|
||
row![
|
||
action("Undo text", Message::SourceUndo),
|
||
action("Redo text", Message::SourceRedo),
|
||
button(text("Undo model").size(12)).padding([8,10]).style(style::nav)
|
||
.on_press_maybe((app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo)),
|
||
button(text("Redo model").size(12)).padding([8,10]).style(style::nav)
|
||
.on_press_maybe((app.document.can_redo() && !app.source_dirty()).then_some(Message::Redo)),
|
||
action("Find / go to…", Message::SourceNavigation(SourceEvent::Open(false))),
|
||
label(if app.source_dirty() {
|
||
"Drafts pending"
|
||
} else {
|
||
"Model in sync"
|
||
})
|
||
]
|
||
.spacing(7)
|
||
.align_y(iced::Center),
|
||
recovery,
|
||
text(app.source_workspace.active.clone()).font(Font::MONOSPACE).size(12).color(style::INK),
|
||
label(if app.pending_recovery.is_some() && !app.source_workspace.applied {
|
||
"The previous graphical model remains available while these files are recovered."
|
||
} else if !app.source_workspace.applied {
|
||
"This project needs repair. Apply all adopts it; the previous graphical model remains available."
|
||
} else if app.source_workspace.detached_dirty() {
|
||
"A detached file has an unsaved draft. Reattach it or revert that file before saving."
|
||
} else {
|
||
"Undo text affects this file's draft. Model Undo restores accepted files together (100 changes / about 32 MiB)."
|
||
}),
|
||
navigation,
|
||
scrollable(diagnostics)
|
||
.height((visible_diagnostics as f32 * 32.).min(132.))
|
||
]
|
||
.spacing(12)
|
||
)
|
||
.padding(18),
|
||
widget::sensor(editor).key((app.source_workspace.active.clone(),app.source_jump))
|
||
.on_show(|_| Message::SourceViewport).on_resize(|_| Message::SourceViewport)
|
||
]
|
||
.into()
|
||
}
|
||
fn checks(app: &App) -> View<'_, Message> {
|
||
let issues = &app.model_issues;
|
||
let mut out = column![heading(
|
||
"Model checks",
|
||
"Structural validity and engineering completeness, with direct navigation."
|
||
)]
|
||
.spacing(15);
|
||
if let Some(report) = &app.migration {
|
||
let mut card = column![text(format!("Imported schema {} → {}", report.from_schema, report.to_schema)).size(19),
|
||
label("Import preserves the original file. Saving explicitly writes the current schema; legacy associations remain available for review.")].spacing(8);
|
||
for port in &report.preserved_ports {
|
||
card = card
|
||
.push(action(
|
||
&format!("Preserved port · @{}", port.alias),
|
||
Message::Select(port.id),
|
||
))
|
||
.push(label(&port.required_resolution));
|
||
}
|
||
for endpoint in &report.generated_endpoints {
|
||
card = card
|
||
.push(action(
|
||
&format!("Created {} endpoint · @{}", endpoint.side, endpoint.alias),
|
||
Message::Select(endpoint.port),
|
||
))
|
||
.push(label(format!(
|
||
"Owner: {} · exchange: {}",
|
||
app.project().named(endpoint.owner),
|
||
app.project().named(endpoint.exchange)
|
||
)));
|
||
}
|
||
card=card.push(label(format!("Data migration: {} semantic defaults · {} interface allocations · {} unresolved value addresses",report.semantic_defaults.len(),report.generated_interface_allocations.len(),report.unresolved_message_values.len())));
|
||
for default in report.semantic_defaults.iter().take(6) {
|
||
card = card
|
||
.push(action(
|
||
&format!(
|
||
"{} · {}",
|
||
app.project().named(default.object),
|
||
default.field
|
||
),
|
||
Message::Select(default.object),
|
||
))
|
||
.push(label(&default.description));
|
||
}
|
||
for allocation in report.generated_interface_allocations.iter().take(6) {
|
||
card = card
|
||
.push(action(
|
||
&format!(
|
||
"Created item allocation · @{} · order {}",
|
||
allocation.alias, allocation.order
|
||
),
|
||
Message::Select(allocation.id),
|
||
))
|
||
.push(label(format!(
|
||
"{} → {}",
|
||
app.project().named(allocation.source),
|
||
app.project().named(allocation.target)
|
||
)));
|
||
}
|
||
for value in report.unresolved_message_values.iter().take(6) {
|
||
card = card
|
||
.push(action(
|
||
&format!(
|
||
"Review qualified value · {}",
|
||
app.project().named(value.occurrence)
|
||
),
|
||
Message::EditProperties(value.occurrence),
|
||
))
|
||
.push(label(&value.reason));
|
||
}
|
||
if report.semantic_defaults.len() > 6
|
||
|| report.generated_interface_allocations.len() > 6
|
||
|| report.unresolved_message_values.len() > 6
|
||
{
|
||
card=card.push(label("Showing the first six entries in each data migration category. Model checks below link remaining unresolved contracts."));
|
||
}
|
||
for boundary in &report.orientation_defaults {
|
||
card = card.push(action(
|
||
&format!(
|
||
"Preserved directed exchange · {}",
|
||
app.project().named(boundary.exchange)
|
||
),
|
||
Message::Select(boundary.exchange),
|
||
));
|
||
}
|
||
out = out.push(container(card).padding(18).style(style::card));
|
||
}
|
||
if issues.is_empty() {
|
||
out = out.push(
|
||
container(
|
||
column![
|
||
text("Model checks passed").size(19).color(style::TEAL),
|
||
label("No structural errors or completeness warnings.")
|
||
]
|
||
.spacing(8),
|
||
)
|
||
.padding(20)
|
||
.style(style::card),
|
||
);
|
||
}
|
||
for issue in issues {
|
||
let mut card =
|
||
column![text(format!("{:?} · {}", issue.severity, issue.message)).size(13)].spacing(8);
|
||
if let Some(id) = issue.element {
|
||
card = card.push(action(
|
||
&format!("Open {}", app.project().named(id)),
|
||
Message::Select(id),
|
||
));
|
||
}
|
||
out = out.push(container(card).padding(15).style(style::card));
|
||
}
|
||
scrollable(out.padding(24)).into()
|
||
}
|