Replace the oversized header and reference rail with stable editor slots and a retained supporting drawer. Bind diagnostic, result and menu actions to their original publication and visit; preserve effective selections on navigation and dismiss Actions, Find and the drawer one surface per Escape. Retain exact Windows source captures, eight final gates (871 desktop passes), independent reviews, native selection/rename/recovery trials and the original failures. All 526 tested inputs and staged evidence were byte-checked before commit. Combined preview integration and packaging remain with issue 27; Linux remains paused.
1438 lines
52 KiB
Rust
1438 lines
52 KiB
Rust
mod source_symbols;
|
||
mod text_workspace;
|
||
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!("{} model 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 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 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());
|
||
}
|
||
if app.source_rename.open() {
|
||
return modal_width(
|
||
root,
|
||
crate::app::source_rename::dialog(app),
|
||
(app.window_size.width - 32.).clamp(120., 760.),
|
||
);
|
||
}
|
||
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("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(),
|
||
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![
|
||
widget::canvas(program).width(Fill).height(Fill),
|
||
widget::canvas(crate::diagram::PortLabels(program))
|
||
.width(Fill)
|
||
.height(Fill),
|
||
];
|
||
if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) {
|
||
scene = scene.push(editor);
|
||
}
|
||
row![
|
||
widget::sensor(scene)
|
||
.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 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| 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::app::text_workspace::DrawerTab;
|
||
use oa_core::source_project::SourceLocation;
|
||
let visible = app.text_workspace.drawer() == Some(DrawerTab::Problems);
|
||
let publication = app
|
||
.text_workspace
|
||
.diagnostic_publication()
|
||
.filter(|publication| visible && publication.source() == app.source_key());
|
||
let mut diagnostics = column![].spacing(3).width(Fill);
|
||
if visible {
|
||
if publication.is_none() && !app.source_diagnostics.is_empty() {
|
||
diagnostics = diagnostics
|
||
.push(text("Source changed. Waiting for current source checks…").size(12));
|
||
}
|
||
if app.source_diagnostics.is_empty() {
|
||
diagnostics = diagnostics.push(text("No source problems reported.").size(12));
|
||
}
|
||
for issue in &app.source_diagnostics {
|
||
let location = SourceLocation {
|
||
path: issue.path.clone(),
|
||
span: issue.diagnostic.span,
|
||
};
|
||
let severity = match issue.diagnostic.severity {
|
||
validation::Severity::Error => "Error",
|
||
validation::Severity::Warning => "Warning",
|
||
};
|
||
diagnostics = diagnostics.push(
|
||
button(
|
||
text(format!(
|
||
"{severity} · {}:{}:{} · {}",
|
||
issue.path,
|
||
issue.diagnostic.line,
|
||
issue.diagnostic.column,
|
||
issue.diagnostic.message,
|
||
))
|
||
.size(12)
|
||
.wrapping(widget::text::Wrapping::WordOrGlyph),
|
||
)
|
||
.width(Fill)
|
||
.padding([4, 6])
|
||
.style(style::nav)
|
||
.on_press_maybe(app.text_diagnostic_message(location)),
|
||
);
|
||
for related in &issue.related {
|
||
diagnostics = diagnostics.push(
|
||
button(
|
||
text(format!("Related declaration · {}", related.path))
|
||
.size(12)
|
||
.wrapping(widget::text::Wrapping::WordOrGlyph),
|
||
)
|
||
.width(Fill)
|
||
.padding([4, 12])
|
||
.style(style::nav)
|
||
.on_press_maybe(app.text_diagnostic_message(related.clone())),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
let guard = crate::diagnostic_input::Guard::new(
|
||
publication.map(|p| (p.generation(), app.text_workspace.drawer_visit())),
|
||
diagnostics,
|
||
);
|
||
text_workspace::view(app, source_symbols::panel(app), guard.into())
|
||
}
|
||
|
||
pub(crate) fn source_binding(
|
||
press: widget::text_editor::KeyPress,
|
||
context: &crate::source_navigation::SymbolContext,
|
||
rename: Option<&crate::app::source_rename::Begin>,
|
||
) -> Option<widget::text_editor::Binding<Message>> {
|
||
use crate::source_navigation::{SymbolEvent, query::Action};
|
||
use iced::keyboard::{Key, key::Named};
|
||
use widget::text_editor::{Binding, Status};
|
||
if matches!(press.status, Status::Focused { .. })
|
||
&& press.modifiers.is_empty()
|
||
&& press.key == Key::Named(Named::F2)
|
||
&& let Some(begin) = rename
|
||
{
|
||
return Some(Binding::Custom(Message::SourceRename(
|
||
crate::app::source_rename::Event::Begin(begin.clone()),
|
||
)));
|
||
}
|
||
// Windows owns Alt+Space for its window menu. Iced's default binding would
|
||
// otherwise insert a supplied space payload, even after that menu closes.
|
||
// Keep Ctrl+Alt (AltGr), shifted input, and ordinary Space unchanged.
|
||
#[cfg(target_os = "windows")]
|
||
if matches!(press.status, Status::Focused { .. })
|
||
&& press.modifiers == iced::keyboard::Modifiers::ALT
|
||
&& matches!(
|
||
press.key.as_ref(),
|
||
Key::Named(Named::Space) | Key::Character(" ")
|
||
)
|
||
{
|
||
return None;
|
||
}
|
||
if matches!(press.status, Status::Focused { .. })
|
||
&& !press.modifiers.control()
|
||
&& !press.modifiers.logo()
|
||
{
|
||
let event = match &press.key {
|
||
Key::Named(Named::F12) if !press.modifiers.alt() => {
|
||
Some(SymbolEvent::Query(if press.modifiers.shift() {
|
||
Action::References
|
||
} else {
|
||
Action::Definition
|
||
}))
|
||
}
|
||
Key::Named(Named::ArrowLeft) if press.modifiers.alt() && !press.modifiers.shift() => {
|
||
Some(SymbolEvent::Back)
|
||
}
|
||
_ => None,
|
||
};
|
||
if let Some(event) = event {
|
||
return Some(Binding::Custom(Message::SourceSymbols(
|
||
context.clone(),
|
||
event,
|
||
)));
|
||
}
|
||
}
|
||
Binding::from_key_press(press)
|
||
}
|
||
|
||
#[cfg(all(test, debug_assertions))]
|
||
pub(crate) mod source_tests {
|
||
use super::*;
|
||
use iced::advanced::{
|
||
Layout, Shell, graphics, layout, renderer, text as shaped, widget as core_widget,
|
||
};
|
||
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation};
|
||
|
||
// Measure real Iced paragraphs/editor text without constructing a window or GPU.
|
||
pub(crate) struct FontLayout;
|
||
impl renderer::Renderer for FontLayout {
|
||
fn start_layer(&mut self, _: Rectangle) {}
|
||
fn end_layer(&mut self) {}
|
||
fn start_transformation(&mut self, _: Transformation) {}
|
||
fn end_transformation(&mut self) {}
|
||
fn reset(&mut self, _: Rectangle) {}
|
||
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
|
||
fn allocate_image(
|
||
&mut self,
|
||
handle: &iced::advanced::image::Handle,
|
||
callback: impl FnOnce(
|
||
Result<iced::advanced::image::Allocation, iced::advanced::image::Error>,
|
||
) + Send
|
||
+ 'static,
|
||
) {
|
||
renderer::Renderer::allocate_image(&mut (), handle, callback);
|
||
}
|
||
}
|
||
impl shaped::Renderer for FontLayout {
|
||
type Font = Font;
|
||
type Paragraph = graphics::text::Paragraph;
|
||
type Editor = graphics::text::Editor;
|
||
const ICON_FONT: Font = Font::DEFAULT;
|
||
const CHECKMARK_ICON: char = '0';
|
||
const ARROW_DOWN_ICON: char = '0';
|
||
const SCROLL_UP_ICON: char = '0';
|
||
const SCROLL_DOWN_ICON: char = '0';
|
||
const SCROLL_LEFT_ICON: char = '0';
|
||
const SCROLL_RIGHT_ICON: char = '0';
|
||
const ICED_LOGO: char = '0';
|
||
fn default_font(&self) -> Font {
|
||
Font::DEFAULT
|
||
}
|
||
fn default_size(&self) -> Pixels {
|
||
Pixels(16.)
|
||
}
|
||
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
|
||
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
|
||
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
#[test]
|
||
fn source_editor_windows_menu_payload_is_inert_but_space_and_altgr_still_edit() {
|
||
use core_widget::operation::Focusable;
|
||
use iced::keyboard::{
|
||
self, Key, Modifiers,
|
||
key::{Code, Named, Physical},
|
||
};
|
||
use widget::text_editor::{Binding, Content, KeyPress, State, Status};
|
||
let baseline = "// is excluded\r\n// 雨\r\n";
|
||
let context = crate::source_navigation::SymbolContext {
|
||
session: Id::new_v4(),
|
||
path: "救援/assess.arcadia".into(),
|
||
};
|
||
for (key, physical_key, modifiers, payload, inserted) in [
|
||
(
|
||
Key::Named(Named::Space),
|
||
Physical::Code(Code::Space),
|
||
Modifiers::ALT,
|
||
" ",
|
||
false,
|
||
),
|
||
(
|
||
Key::Character(" ".into()),
|
||
Physical::Code(Code::Space),
|
||
Modifiers::ALT,
|
||
" ",
|
||
false,
|
||
),
|
||
(
|
||
Key::Named(Named::Space),
|
||
Physical::Code(Code::Space),
|
||
Modifiers::empty(),
|
||
" ",
|
||
true,
|
||
),
|
||
(
|
||
Key::Named(Named::Space),
|
||
Physical::Code(Code::Space),
|
||
Modifiers::CTRL | Modifiers::ALT,
|
||
" ",
|
||
true,
|
||
),
|
||
(
|
||
Key::Character("e".into()),
|
||
Physical::Code(Code::KeyE),
|
||
Modifiers::CTRL | Modifiers::ALT,
|
||
"€",
|
||
true,
|
||
),
|
||
(
|
||
Key::Named(Named::Space),
|
||
Physical::Code(Code::Space),
|
||
Modifiers::ALT | Modifiers::SHIFT,
|
||
" ",
|
||
true,
|
||
),
|
||
] {
|
||
let mut content = Content::<FontLayout>::with_text(baseline);
|
||
content.move_to(widget::text_editor::Cursor {
|
||
position: widget::text_editor::Position { line: 0, column: 5 },
|
||
selection: None,
|
||
});
|
||
let press = KeyPress {
|
||
key: key.clone(),
|
||
modified_key: key.clone(),
|
||
physical_key,
|
||
modifiers,
|
||
text: Some(payload.into()),
|
||
status: Status::Focused { is_hovered: false },
|
||
};
|
||
assert!(
|
||
matches!(
|
||
Binding::<Message>::from_key_press(press),
|
||
Some(Binding::Insert(_))
|
||
),
|
||
"the supplied event must expose the default insertion path"
|
||
);
|
||
let mut messages = Vec::new();
|
||
{
|
||
let mut editor: View<'_, Message, iced::Theme, FontLayout> =
|
||
widget::text_editor(&content)
|
||
.on_action(Message::SourceEdit)
|
||
.font(Font::MONOSPACE)
|
||
.size(14)
|
||
.padding(16)
|
||
.height(Fill)
|
||
.key_binding(|press| source_binding(press, &context, None))
|
||
.into();
|
||
let mut tree = core_widget::Tree::new(editor.as_widget());
|
||
let size = Size::new(600., 240.);
|
||
let node = editor.as_widget_mut().layout(
|
||
&mut tree,
|
||
&FontLayout,
|
||
&layout::Limits::new(Size::ZERO, size),
|
||
);
|
||
tree.state
|
||
.downcast_mut::<State<shaped::highlighter::PlainText>>()
|
||
.focus();
|
||
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||
key: key.clone(),
|
||
modified_key: key,
|
||
physical_key,
|
||
location: keyboard::Location::Standard,
|
||
modifiers,
|
||
text: Some(payload.into()),
|
||
repeat: false,
|
||
});
|
||
let mut shell = Shell::new(&mut messages);
|
||
editor.as_widget_mut().update(
|
||
&mut tree,
|
||
&event,
|
||
Layout::new(&node),
|
||
iced::mouse::Cursor::Unavailable,
|
||
&FontLayout,
|
||
&mut iced::advanced::clipboard::Null,
|
||
&mut shell,
|
||
&Rectangle::with_size(size),
|
||
);
|
||
}
|
||
assert_eq!(
|
||
messages.len(),
|
||
usize::from(inserted),
|
||
"modifiers={modifiers:?}"
|
||
);
|
||
for message in messages {
|
||
let Message::SourceEdit(action) = message else {
|
||
panic!("unexpected navigation action");
|
||
};
|
||
content.perform(action);
|
||
}
|
||
let expected = if inserted {
|
||
format!("// is{payload} excluded\r\n// 雨\r\n")
|
||
} else {
|
||
baseline.into()
|
||
};
|
||
assert_eq!(content.text(), expected);
|
||
}
|
||
}
|
||
}
|
||
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()
|
||
}
|