1948 lines
72 KiB
Rust
1948 lines
72 KiB
Rust
mod source_symbols;
|
||
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::*;
|
||
use unicode_segmentation::UnicodeSegmentation;
|
||
|
||
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.graphemes(true).count() > limit {
|
||
format!(
|
||
"{}…",
|
||
s.graphemes(true)
|
||
.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 mut body = if app.page == Page::Home {
|
||
row![container(main).width(Fill).height(Fill)].height(Fill)
|
||
} else {
|
||
row![sidebar, container(main).width(Fill).height(Fill)].height(Fill)
|
||
};
|
||
if !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());
|
||
}
|
||
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("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,
|
||
navigation_focus: app
|
||
.diagram_focus(crate::diagram_usage::Surface::Saved(id))
|
||
.map(|usage| usage.bounds),
|
||
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::focus_enabled(app),
|
||
app.busy,
|
||
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);
|
||
}
|
||
let reveal = app.architecture_reveal_stamp(id);
|
||
row![
|
||
widget::sensor(
|
||
container(scene)
|
||
.id(crate::allocation_input::CANVAS_ID)
|
||
.width(Fill)
|
||
.height(Fill)
|
||
)
|
||
.on_show(
|
||
move |size| reveal.map_or(Message::CanvasResized(size), |(context, epoch)| {
|
||
Message::DiagramRevealViewport(context, epoch, size)
|
||
})
|
||
)
|
||
.on_resize(move |size| reveal
|
||
.map_or(Message::CanvasResized(size), |(context, epoch)| {
|
||
Message::DiagramRevealViewport(context, epoch, size)
|
||
})),
|
||
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(app, 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 (undo_label, undo_message) = match app.source_rename_undo_message() {
|
||
Some(message) => ("Undo rename", Some(message)),
|
||
None => (
|
||
"Undo model",
|
||
(app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo),
|
||
),
|
||
};
|
||
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", app.source_find_close_message()),
|
||
]
|
||
.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(app.source_editor_id())
|
||
.style(style::source_editor)
|
||
.on_action(Message::SourceEdit)
|
||
.key_binding({
|
||
let context = app.source_symbol_context();
|
||
let rename = app.source_rename_begin();
|
||
move |press| source_binding(press, &context, Some(&rename))
|
||
})
|
||
.font(Font::MONOSPACE)
|
||
.size(14)
|
||
.padding(16)
|
||
.height(Fill)
|
||
.highlight_with::<crate::highlight::ArcadiaHighlighter>(
|
||
app.source_snapshot.clone(),
|
||
crate::highlight::format,
|
||
);
|
||
let header = 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_label).size(12)).padding([8,10]).style(style::nav)
|
||
.on_press_maybe(undo_message),
|
||
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,
|
||
row![
|
||
action("Definition · F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::Definition))),
|
||
action("References · Shift F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::References))),
|
||
action("Rename alias · F2", app.source_rename_message()),
|
||
action("Show in diagrams…", app.source_symbol_message(crate::source_navigation::SymbolEvent::DiagramUsages)),
|
||
button(text("Back · Alt ←").size(12)).padding([8, 10]).style(style::nav)
|
||
.on_press_maybe((app.source_symbols.back.iter().any(|entry| entry.session == app.source_workspace.session())).then(|| app.source_symbol_message(crate::source_navigation::SymbolEvent::Back))),
|
||
].spacing(7),
|
||
scrollable(diagnostics)
|
||
.height((visible_diagnostics as f32 * 32.).min(132.))
|
||
]
|
||
.spacing(12)
|
||
)
|
||
.padding(18);
|
||
let editor = widget::sensor(editor)
|
||
.key((
|
||
app.source_workspace.active.clone(),
|
||
app.source_reveal_ticket(),
|
||
))
|
||
.on_show({
|
||
let ticket = app.source_reveal_ticket();
|
||
move |_| Message::SourceViewport(ticket)
|
||
})
|
||
.on_resize({
|
||
let ticket = app.source_reveal_ticket();
|
||
move |_| Message::SourceViewport(ticket)
|
||
});
|
||
let workspace: View<'_, Message> = if app.source_symbols.open {
|
||
row![
|
||
container(editor).width(Fill).height(Fill),
|
||
container(scrollable(source_symbols::panel(app)))
|
||
.width(300)
|
||
.height(Fill),
|
||
]
|
||
.height(Fill)
|
||
.into()
|
||
} else {
|
||
container(editor).width(Fill).height(Fill).into()
|
||
};
|
||
source_layout(header.into(), workspace, app.window_size.height)
|
||
}
|
||
|
||
fn source_layout<'a, Renderer: iced::advanced::text::Renderer + 'a>(
|
||
header: View<'a, Message, iced::Theme, Renderer>,
|
||
workspace: View<'a, Message, iced::Theme, Renderer>,
|
||
window_height: f32,
|
||
) -> View<'a, Message, iced::Theme, Renderer> {
|
||
// Lint clears and repopulates diagnostics asynchronously. Reserve the same
|
||
// outer slot so a pointer aimed at the editor cannot land in the header.
|
||
column![
|
||
container(scrollable(header).id("source-header")).height((window_height * 0.42).max(180.)),
|
||
workspace,
|
||
]
|
||
.height(Fill)
|
||
.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::widget::column;
|
||
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation, Vector};
|
||
|
||
// 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) {}
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct Geometry {
|
||
header: Option<(Rectangle, Rectangle, Vector)>,
|
||
editor: Option<Rectangle>,
|
||
}
|
||
impl core_widget::Operation for Geometry {
|
||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn core_widget::Operation)) {
|
||
visit(self);
|
||
}
|
||
fn container(&mut self, id: Option<&core_widget::Id>, bounds: Rectangle) {
|
||
if id == Some(&core_widget::Id::new("test-source-editor")) {
|
||
self.editor = Some(bounds);
|
||
}
|
||
}
|
||
fn scrollable(
|
||
&mut self,
|
||
id: Option<&core_widget::Id>,
|
||
bounds: Rectangle,
|
||
content: Rectangle,
|
||
offset: Vector,
|
||
_: &mut dyn core_widget::operation::Scrollable,
|
||
) {
|
||
if id == Some(&core_widget::Id::new("source-header")) {
|
||
self.header = Some((bounds, content, offset));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn mounted_source<'a>(
|
||
content: &'a widget::text_editor::Content<FontLayout>,
|
||
diagnostics: usize,
|
||
find: bool,
|
||
window_height: f32,
|
||
) -> View<'a, Message, iced::Theme, FontLayout> {
|
||
let mut notices = column![].spacing(5);
|
||
for index in 0..diagnostics {
|
||
notices = notices.push(button(text(format!(
|
||
"救援/assess.arcadia:{}:5 · Warning: verify this participant's engineering allocation", index + 1,
|
||
)).size(12)).padding([4, 6]).width(Fill));
|
||
}
|
||
let find: View<'_, Message, iced::Theme, FontLayout> = if find {
|
||
column![
|
||
text_input("Find literal text", "assess").on_input(|s| Message::SourceNavigation(
|
||
crate::source_navigation::Event::Query(s)
|
||
)),
|
||
text_input("Line", "1").on_input(|s| Message::SourceNavigation(
|
||
crate::source_navigation::Event::Line(s)
|
||
)),
|
||
]
|
||
.spacing(8)
|
||
.into()
|
||
} else {
|
||
Space::new().into()
|
||
};
|
||
let header = container(
|
||
column![
|
||
text("Textual model").size(24),
|
||
text("Edit the current file. Apply text validates the complete included model.")
|
||
.size(11),
|
||
row![
|
||
button(text("Apply text")),
|
||
button(text("Undo text")),
|
||
button(text("Model Undo"))
|
||
]
|
||
.spacing(8),
|
||
text("救援/assess.arcadia · All files applied to the accepted model").size(11),
|
||
text("Undo text affects this draft. Model Undo restores accepted files together.")
|
||
.size(11),
|
||
find,
|
||
row![
|
||
button(text("Definition · F12")),
|
||
button(text("References · Shift F12")),
|
||
button(text("Back"))
|
||
]
|
||
.spacing(7),
|
||
scrollable(notices).height((diagnostics as f32 * 32.).min(132.)),
|
||
]
|
||
.spacing(12),
|
||
)
|
||
.padding(18);
|
||
let editor = container(
|
||
widget::text_editor(content)
|
||
.on_action(Message::SourceEdit)
|
||
.font(Font::MONOSPACE)
|
||
.size(14)
|
||
.padding(16)
|
||
.height(Fill),
|
||
)
|
||
.id("test-source-editor")
|
||
.height(Fill)
|
||
.width(Fill);
|
||
source_layout(header.into(), editor.into(), window_height)
|
||
}
|
||
|
||
#[test]
|
||
fn source_header_keeps_editor_bounds_stable_through_lint_and_find_rebuilds() {
|
||
for (host, window_height) in [
|
||
(Size::new(720., 540.), 700.),
|
||
(Size::new(1180., 890.), 1080.),
|
||
] {
|
||
let content = widget::text_editor::Content::<FontLayout>::with_text(
|
||
"// is excluded\r\n// 雨\r\n",
|
||
);
|
||
let mut tree = core_widget::Tree::empty();
|
||
let mut baseline = None;
|
||
for (diagnostics, find) in [
|
||
(0, false),
|
||
(6, false),
|
||
(0, false),
|
||
(6, true),
|
||
(1, true),
|
||
(0, false),
|
||
] {
|
||
let mut ui = mounted_source(&content, diagnostics, find, window_height);
|
||
tree.diff(ui.as_widget());
|
||
let node = ui.as_widget_mut().layout(
|
||
&mut tree,
|
||
&FontLayout,
|
||
&layout::Limits::new(Size::ZERO, host),
|
||
);
|
||
let mut geometry = Geometry::default();
|
||
ui.as_widget_mut().operate(
|
||
&mut tree,
|
||
Layout::new(&node),
|
||
&FontLayout,
|
||
&mut geometry,
|
||
);
|
||
let editor = geometry.editor.unwrap();
|
||
let (header, _, _) = geometry.header.unwrap();
|
||
let slot = node.children()[0].bounds();
|
||
assert_eq!(slot.height, (window_height * 0.42).max(180.));
|
||
assert!(
|
||
header.height <= slot.height,
|
||
"short content may shrink inside the fixed slot"
|
||
);
|
||
assert!(
|
||
editor.height >= 240.,
|
||
"remaining editor must stay usable: {editor:?}"
|
||
);
|
||
assert_eq!(editor.y, slot.y + slot.height);
|
||
if let Some(expected) = baseline {
|
||
assert_eq!(editor, expected, "diagnostics={diagnostics}, find={find}");
|
||
} else {
|
||
baseline = Some(editor);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn source_header_remains_scrollable_and_preserves_offset_across_diagnostic_refresh() {
|
||
let content = widget::text_editor::Content::<FontLayout>::with_text("// 雨\r\n");
|
||
let host = Size::new(720., 540.);
|
||
let mut tree = core_widget::Tree::empty();
|
||
let mut saved = None;
|
||
for diagnostics in [6, 7] {
|
||
let mut ui = mounted_source(&content, diagnostics, true, 700.);
|
||
tree.diff(ui.as_widget());
|
||
let node = ui.as_widget_mut().layout(
|
||
&mut tree,
|
||
&FontLayout,
|
||
&layout::Limits::new(Size::ZERO, host),
|
||
);
|
||
let mut geometry = Geometry::default();
|
||
ui.as_widget_mut()
|
||
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry);
|
||
let (bounds, contents, _) = geometry.header.unwrap();
|
||
assert!(
|
||
contents.height > bounds.height,
|
||
"fixture must actually overflow"
|
||
);
|
||
if saved.is_none() {
|
||
let mut messages = Vec::new();
|
||
let mut shell = Shell::new(&mut messages);
|
||
ui.as_widget_mut().update(
|
||
&mut tree,
|
||
&iced::Event::Mouse(iced::mouse::Event::WheelScrolled {
|
||
delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -45. },
|
||
}),
|
||
Layout::new(&node),
|
||
iced::mouse::Cursor::Available(Point::new(bounds.x + 8., bounds.y + 8.)),
|
||
&FontLayout,
|
||
&mut iced::advanced::clipboard::Null,
|
||
&mut shell,
|
||
&Rectangle::with_size(host),
|
||
);
|
||
assert!(shell.is_event_captured());
|
||
}
|
||
let mut geometry = Geometry::default();
|
||
ui.as_widget_mut()
|
||
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry);
|
||
let offset = geometry.header.unwrap().2;
|
||
assert!(offset.y > 0., "the actual mounted Scrollable must move");
|
||
if let Some(before) = saved {
|
||
assert_eq!(offset, before);
|
||
} else {
|
||
saved = Some(offset);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::short;
|
||
|
||
#[test]
|
||
fn short_preserves_native_lifeline_clusters_at_cutoff() {
|
||
let oa = "abcdefghijklmnope\u{0301}尾";
|
||
assert_eq!(short(oa, 17), "abcdefghijklmnop…");
|
||
assert_eq!(short(oa, 18), oa);
|
||
assert_eq!(short(oa, 19), oa);
|
||
|
||
let sa = "abcdefghijklmnop👩\u{200d}🔬 tail";
|
||
assert_eq!(short(sa, 17), "abcdefghijklmnop…");
|
||
assert_eq!(short(sa, 18), "abcdefghijklmnop👩\u{200d}🔬…");
|
||
assert_eq!(short(sa, 19), "abcdefghijklmnop👩\u{200d}🔬 …");
|
||
assert_eq!(short(sa, 22), sa);
|
||
}
|
||
|
||
#[test]
|
||
fn short_never_splits_retained_extended_graphemes() {
|
||
for cluster in ["e\u{0301}", "👩\u{200d}🔬", "👍🏽", "🇫🇷"] {
|
||
let value = format!("A{cluster}BC");
|
||
assert_eq!(short(&value, 2), "A…");
|
||
assert_eq!(short(&value, 3), format!("A{cluster}…"));
|
||
assert_eq!(short(&value, 4), value);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn short_preserves_ascii_cjk_and_whitespace_policy() {
|
||
assert_eq!(short("abcd", 3), "ab…");
|
||
assert_eq!(short("abcd", 4), "abcd");
|
||
assert_eq!(short("abcd", 5), "abcd");
|
||
assert_eq!(short("甲乙丙丁", 3), "甲乙…");
|
||
assert_eq!(short("甲乙丙丁", 4), "甲乙丙丁");
|
||
assert_eq!(short(" \tA\r\n e\u{0301}\u{2003}B ", 5), "A e\u{0301} B");
|
||
assert_eq!(short(" \tA\r\n e\u{0301}\u{2003}B ", 4), "A e\u{0301}…");
|
||
}
|
||
|
||
#[test]
|
||
fn short_preserves_empty_and_small_limit_policy() {
|
||
for limit in [0, 1, 2] {
|
||
assert_eq!(short("", limit), "");
|
||
assert_eq!(short(" \t\r\n", limit), "");
|
||
}
|
||
assert_eq!(short("A", 0), "…");
|
||
assert_eq!(short("A", 1), "A");
|
||
assert_eq!(short("AB", 1), "…");
|
||
assert_eq!(short("e\u{0301}", 0), "…");
|
||
assert_eq!(short("e\u{0301}", 1), "e\u{0301}");
|
||
assert_eq!(short("e\u{0301}B", 1), "…");
|
||
}
|
||
}
|