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

1122 lines
41 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::{
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()
}