1387 lines
50 KiB
Rust
1387 lines
50 KiB
Rust
pub(crate) mod traceability;
|
||
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(app.hosted_label())]
|
||
.spacing(3)
|
||
.width(Fill),
|
||
action("New", Message::Request(Pending::New)),
|
||
crate::hosted::view::header_action(),
|
||
action("Open", Message::Request(Pending::Open)),
|
||
action("Save as", Message::Save(true)),
|
||
button(text(app.save_label()).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::Home, "Home"),
|
||
(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.wrap())
|
||
.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));
|
||
if app.page != Page::Diagram {
|
||
nav = nav.push(creation);
|
||
}
|
||
nav = nav.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::Home => crate::home::view(
|
||
&app.home,
|
||
(app.dirty()
|
||
|| !p.elements.is_empty()
|
||
|| app.path.is_some()
|
||
|| app.hosted.session.is_some())
|
||
.then_some((p.name.as_str(), app.dirty())),
|
||
&app.recovery,
|
||
)
|
||
.map(Message::Home),
|
||
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 process_layout = app.topology_context().is_some();
|
||
let scenario_layout = app.scenario_context().is_some();
|
||
let focus_behavior = (process_layout && app.behavior.topology.focused)
|
||
|| (scenario_layout && app.behavior.scenario.focused());
|
||
let mut body = if app.page == Page::Home {
|
||
row![container(main).width(Fill).height(Fill)].height(Fill)
|
||
} else if process_layout || scenario_layout {
|
||
// Keep main at the same widget-tree path across Focus toggles. The
|
||
// collapsed slot has no controls, so it cannot paint over or hit the canvas.
|
||
let process_sidebar: View<'_, Message> = if focus_behavior {
|
||
Space::new().width(0).into()
|
||
} else {
|
||
sidebar.into()
|
||
};
|
||
// Row::push would discard the zero-width slot and shift main again.
|
||
widget::Row::from_vec(vec![
|
||
container(process_sidebar)
|
||
.width(if focus_behavior { 0 } else { 260 })
|
||
.height(Fill)
|
||
.clip(true)
|
||
.into(),
|
||
container(main).width(Fill).height(Fill).into(),
|
||
])
|
||
.width(Fill)
|
||
.height(Fill)
|
||
} else {
|
||
row![sidebar, container(main).width(Fill).height(Fill)].height(Fill)
|
||
};
|
||
if !focus_behavior && !matches!(app.page, Page::Home | Page::Text | Page::Guide) {
|
||
body = body.push(inspector(app));
|
||
}
|
||
let footer = container(
|
||
row![
|
||
container(tooltip(
|
||
text(short(if app.busy { "Working…" } else { &app.status }, 145)).size(11),
|
||
container(text(if app.busy { "Working…" } else { &app.status }).size(12))
|
||
.padding(10)
|
||
.max_width(600)
|
||
.style(style::card),
|
||
tooltip::Position::Top,
|
||
))
|
||
.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 = crate::allocation_input::root(app, column![header, task_nav, body, footer]);
|
||
if app.architecture.as_ref().is_some_and(|s| s.modal()) {
|
||
return modal_width(
|
||
root,
|
||
crate::app::architecture_authoring::dialog(app),
|
||
(app.window_size.width - 50.).clamp(500., 820.),
|
||
);
|
||
}
|
||
if let Some(workflow) = &app.workflow {
|
||
if matches!(workflow, crate::workflow::Workflow::Semantic(_)) {
|
||
return modal_width(
|
||
root,
|
||
workflow.view(p),
|
||
(app.window_size.width - 50.).clamp(800., 1120.),
|
||
);
|
||
}
|
||
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 let Some(target) = app.delete_target.as_ref().filter(|_| app.delete_pending) {
|
||
return modal(
|
||
root,
|
||
column![
|
||
text("Delete from model?").size(21),
|
||
text(p.named(target.snapshot.request.object)).size(16),
|
||
text(crate::app::diagram_removal::explanation(
|
||
app,
|
||
target.snapshot.request.object
|
||
))
|
||
.size(13),
|
||
row![
|
||
widget::container(action("Cancel", Message::CancelDelete)).id("delete-cancel"),
|
||
widget::container(
|
||
button("Delete from model")
|
||
.style(button::danger)
|
||
.padding(10)
|
||
.on_press(Message::ConfirmDelete(target.snapshot.token))
|
||
)
|
||
.id("delete-confirm")
|
||
]
|
||
.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> {
|
||
modal_width(base, content, 800.)
|
||
}
|
||
fn modal_width<'a>(
|
||
base: View<'a, Message>,
|
||
content: View<'a, Message>,
|
||
width: f32,
|
||
) -> View<'a, Message> {
|
||
widget::stack![
|
||
base,
|
||
widget::opaque(
|
||
container(
|
||
container(content)
|
||
.padding(26)
|
||
.width(width)
|
||
.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 mut diagrams = app
|
||
.project()
|
||
.diagrams
|
||
.values()
|
||
.filter(|d| d.layer == app.layer || d.projection == ProjectionKind::Traceability)
|
||
.map(|d| Choice {
|
||
id: d.id,
|
||
label: d.name.clone(),
|
||
})
|
||
.collect::<Vec<_>>();
|
||
diagrams.sort_by(|a, b| a.label.cmp(&b.label).then(a.id.cmp(&b.id)));
|
||
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("Auto-layout", 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 {
|
||
allocation: Some(app.allocation_capability()),
|
||
project: app.project(),
|
||
view_index: Some(app.document.view_index()),
|
||
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(),
|
||
placing: app.architecture.as_ref().is_some_and(|s| s.saved.placing),
|
||
connection: app.connection.as_ref(),
|
||
};
|
||
let mut scene = widget::stack![
|
||
crate::diagram::focus::wrap(
|
||
widget::canvas(program).width(Fill).height(Fill),
|
||
program.context,
|
||
crate::app::diagram_removal::enabled(app),
|
||
Message::DiagramTraverse
|
||
),
|
||
widget::canvas(crate::diagram::PortLabels(program))
|
||
.width(Fill)
|
||
.height(Fill),
|
||
widget::canvas(crate::diagram::WorkFeedback(program))
|
||
.width(Fill)
|
||
.height(Fill),
|
||
];
|
||
if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) {
|
||
scene = scene.push(editor);
|
||
}
|
||
row![
|
||
widget::sensor(
|
||
container(scene)
|
||
.id(crate::allocation_input::CANVAS_ID)
|
||
.width(Fill)
|
||
.height(Fill)
|
||
)
|
||
.on_show(Message::CanvasResized)
|
||
.on_resize(Message::CanvasResized),
|
||
crate::app::architecture_authoring::palette(app)
|
||
]
|
||
.height(Fill)
|
||
.width(Fill)
|
||
.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 mut tools = column![toolbar].spacing(8);
|
||
if app.active_diagram.is_some() {
|
||
tools = tools.push(modes);
|
||
}
|
||
if projection == Some(ProjectionKind::Allocation) {
|
||
// Reserve this slot even when idle: changing feedback must never resize
|
||
// the measured canvas and invalidate a held gesture. Its identity is
|
||
// independent of status length, target geometry and the diagram zoom.
|
||
let destination = app
|
||
.allocation_drag
|
||
.feedback
|
||
.as_ref()
|
||
.map(|feedback| feedback.destination_identity())
|
||
.unwrap_or_else(|| "None · drag work to choose".into());
|
||
let inspection = app
|
||
.allocation_drag
|
||
.feedback
|
||
.as_ref()
|
||
.map(|feedback| feedback.inspection_identity())
|
||
.unwrap_or_else(|| destination.clone());
|
||
tools = tools.push(
|
||
container(tooltip(
|
||
text(format!("Destination: {}", short(&destination, 28)))
|
||
.size(13)
|
||
.wrapping(widget::text::Wrapping::None),
|
||
container(text(inspection).size(13))
|
||
.padding(10)
|
||
.max_width(420)
|
||
.style(style::card),
|
||
tooltip::Position::Top,
|
||
))
|
||
.id("allocation-destination")
|
||
.height(26)
|
||
.width(Fill)
|
||
.clip(true),
|
||
);
|
||
tools = tools.push(
|
||
row![
|
||
action(
|
||
&format!(
|
||
"{} · {}",
|
||
if app.allocation_drag.arrange_next {
|
||
"Arrange next drag: armed"
|
||
} else {
|
||
"Arrange next drag"
|
||
},
|
||
crate::allocation_input::ARRANGE_SHORTCUT
|
||
),
|
||
Message::WorkDrag(crate::app::allocation_drag::Event::ArrangeNext)
|
||
),
|
||
label("Drag work into a performer to assign · out to remove assignment")
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center),
|
||
);
|
||
}
|
||
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(feedback) = &app.allocation_drag.feedback {
|
||
feedback.text.as_str()
|
||
} else 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"
|
||
};
|
||
// Status is outside the measured canvas. Clipping prevents long text
|
||
// from painting over work headers or the dedicated destination slot.
|
||
tools = tools.push(container(label(hint)).height(30).width(Fill).clip(true));
|
||
}
|
||
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| e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor))
|
||
{
|
||
let draw = app.page == Page::Diagram
|
||
&& 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 communication means"
|
||
} else {
|
||
"Configure communication means…"
|
||
})
|
||
.size(12),
|
||
)
|
||
.padding(9)
|
||
.width(Fill)
|
||
.on_press(if draw {
|
||
Message::Connection(crate::connection_edit::Event::Begin(
|
||
crate::connection_tool::Tool::CommunicationMean,
|
||
app.selected,
|
||
))
|
||
} else {
|
||
Message::AddRelationKind(RelationKind::CommunicationMean, false)
|
||
}),
|
||
);
|
||
}
|
||
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));
|
||
if r.kind() != RelationKind::ContextualLink {
|
||
body = body.push(
|
||
pick_list(
|
||
RelationKind::ALL
|
||
.into_iter()
|
||
.filter(|kind| *kind != RelationKind::ContextualLink)
|
||
.collect::<Vec<_>>(),
|
||
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();
|
||
use traceability::{Event as E, Filter};
|
||
let (traces, artifacts) = app.traceability.matching(p);
|
||
let mut tabs = row![].spacing(6);
|
||
for (filter, label) in [
|
||
(
|
||
Filter::All,
|
||
format!("All ({})", p.traces.len() + p.artifact_links.len()),
|
||
),
|
||
(Filter::Traces, format!("Layer traces ({})", p.traces.len())),
|
||
(
|
||
Filter::Artifacts,
|
||
format!("Development artifacts ({})", p.artifact_links.len()),
|
||
),
|
||
] {
|
||
tabs = tabs.push(
|
||
button(text(label).size(12))
|
||
.style(if app.traceability.filter == filter {
|
||
button::primary
|
||
} else {
|
||
style::nav
|
||
})
|
||
.on_press(Message::Traceability(E::Filter(filter))),
|
||
);
|
||
}
|
||
let header = column![
|
||
heading(
|
||
"Traceability",
|
||
"Connect operational needs, system responsibilities and development evidence."
|
||
),
|
||
row![
|
||
button(
|
||
text(
|
||
if p.layer_of(app.selected).unwrap_or(app.layer) == Layer::Oa {
|
||
"Create System counterpart…"
|
||
} else {
|
||
"Create Operational counterpart…"
|
||
}
|
||
)
|
||
.size(12)
|
||
)
|
||
.padding(9)
|
||
.on_press(Message::StartTransition),
|
||
action("+ Manual trace", Message::AddTrace),
|
||
action(
|
||
"Review counterpart changes…",
|
||
Message::StartSemanticReview(app.selected)
|
||
),
|
||
action("+ Artifact", Message::AddArtifact)
|
||
]
|
||
.spacing(8)
|
||
.wrap(),
|
||
tabs.wrap(),
|
||
row![
|
||
text_input(
|
||
"Search names, aliases, owners or artifact URI…",
|
||
&app.traceability.query
|
||
)
|
||
.on_input(|raw| Message::Traceability(E::Search(raw)))
|
||
.padding(8)
|
||
.size(12)
|
||
.width(Fill),
|
||
button("Clear search").on_press(Message::Traceability(E::Search(String::new())))
|
||
]
|
||
.spacing(6),
|
||
label(format!(
|
||
"{} matching traces · {} matching artifacts",
|
||
traces.len(),
|
||
artifacts.len()
|
||
))
|
||
]
|
||
.spacing(10)
|
||
.padding(24);
|
||
let mut out = column![].spacing(16);
|
||
for id in &traces {
|
||
let t = &p.traces[id];
|
||
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!("{:?} · Prose: {current}", t.kind)),
|
||
Space::new().width(Fill),
|
||
action(
|
||
"Review counterpart changes…",
|
||
Message::StartSemanticReview(t.id)
|
||
),
|
||
action("Edit link", Message::Select(t.id))
|
||
],
|
||
text(crate::semantic_review::summary(p, t))
|
||
.size(11)
|
||
.color(style::MUTED),
|
||
text(short(&t.description, 130)).size(12)
|
||
]
|
||
.spacing(10),
|
||
)
|
||
.padding(16)
|
||
.style(style::card),
|
||
);
|
||
}
|
||
if !artifacts.is_empty() {
|
||
out = out.push(text("Development artifacts").size(18));
|
||
}
|
||
for id in &artifacts {
|
||
let a = &p.artifact_links[id];
|
||
let owner = traceability::owner_label(p, a.owner);
|
||
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));
|
||
} else if traces.is_empty() && artifacts.is_empty() {
|
||
out = out
|
||
.push(text("No matching records. Clear the search or choose another filter.").size(13));
|
||
}
|
||
column![
|
||
header,
|
||
scrollable(out.padding(iced::Padding::from(24).right(42)))
|
||
.id("traceability-list")
|
||
.height(Fill)
|
||
]
|
||
.height(Fill)
|
||
.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()
|
||
}
|