1733 lines
63 KiB
Rust
1733 lines
63 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::*;
|
||
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!("{} model 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> {
|
||
if app
|
||
.active_diagram
|
||
.and_then(|id| app.project().diagrams.get(&id))
|
||
.is_some_and(|d| d.projection.scenario().is_some())
|
||
{
|
||
return heading(
|
||
"Saved Scenario view",
|
||
crate::app::SAVED_SCENARIO_UNAVAILABLE,
|
||
);
|
||
}
|
||
let mut diagrams = app
|
||
.project()
|
||
.diagrams
|
||
.values()
|
||
.filter(|d| {
|
||
d.projection.scenario().is_none()
|
||
&& (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 {
|
||
selection: app.diagram_selection.as_ref(),
|
||
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_attachment: app
|
||
.selected_attachment
|
||
.filter(|(context, edge, _)| {
|
||
Some(*context) == app.route_context() && *edge == app.selected
|
||
})
|
||
.map(|(_, _, end)| end),
|
||
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);
|
||
if projection == Some(ProjectionKind::Traceability) {
|
||
let correspondence_lines = view.edges.iter().filter(|edge| edge.trace).count();
|
||
tools = tools.push(
|
||
column![
|
||
label("Shows OA and SA elements together, with correspondence lines alongside ordinary relationships."),
|
||
label("Place both ends of a trace to show its line. Port traces use their owner cards; traces between relationships are reviewed on the Traceability page."),
|
||
row![
|
||
label(format!("{correspondence_lines} correspondence lines in this view")),
|
||
action(
|
||
"Add existing elements…",
|
||
Message::Architecture(crate::app::architecture_authoring::Event::Existing(None))
|
||
),
|
||
action("Review layer traces", Message::ReviewLayerTraces),
|
||
action("View visibility…", Message::Select(diagram)),
|
||
]
|
||
.spacing(7)
|
||
.align_y(iced::Center)
|
||
.wrap(),
|
||
]
|
||
.spacing(4),
|
||
);
|
||
}
|
||
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 selection = app.multiselect_snapshot();
|
||
let count = selection.as_ref().map_or(0, |s| s.targets().len());
|
||
let mut selection_controls =
|
||
row![label(format!("{count} selected")).wrapping(widget::text::Wrapping::None)]
|
||
.spacing(7)
|
||
.align_y(iced::Center)
|
||
.height(32);
|
||
if let Some(snapshot) = selection.as_ref().filter(|s| s.targets().len() > 1) {
|
||
if crate::app::multiselect::placements(snapshot).is_some() {
|
||
selection_controls = selection_controls.push(action(
|
||
"Hide selected",
|
||
Message::DiagramSelection(crate::app::multiselect::Event::Hide(
|
||
snapshot.clone(),
|
||
)),
|
||
));
|
||
}
|
||
selection_controls = selection_controls
|
||
.push(action(
|
||
"Clear selection",
|
||
Message::DiagramSelection(crate::app::multiselect::Event::Clear(
|
||
snapshot.context(),
|
||
)),
|
||
))
|
||
.push(
|
||
label(format!(
|
||
"Properties edit only {}",
|
||
short(app.project().named(app.selected), 36)
|
||
))
|
||
.wrapping(widget::text::Wrapping::None),
|
||
);
|
||
}
|
||
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 count > 1 {
|
||
if selection
|
||
.as_ref()
|
||
.is_some_and(|s| crate::app::multiselect::placements(s).is_some())
|
||
&& projection != Some(ProjectionKind::Allocation)
|
||
{
|
||
"Shift-click to add/remove · drag selected to move · right-click for actions · scroll to zoom"
|
||
} else {
|
||
"Shift-click to add/remove · right-click for selection actions · Esc clears selection"
|
||
}
|
||
} 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 {
|
||
"Shift-click to add/remove · right-click for actions · drag to arrange · scroll to zoom"
|
||
};
|
||
// Selection changes must not resize the measured canvas or refit its
|
||
// camera. Reserve both rows even when no group actions are available.
|
||
tools = tools.push(
|
||
container(
|
||
column![
|
||
selection_controls,
|
||
label(hint).wrapping(widget::text::Wrapping::None)
|
||
]
|
||
.spacing(3),
|
||
)
|
||
.height(56)
|
||
.width(Fill)
|
||
.clip(true),
|
||
);
|
||
}
|
||
column![container(tools).padding(14), canvas].into()
|
||
}
|
||
|
||
fn inspector(app: &App) -> View<'_, Message> {
|
||
let p = app.project();
|
||
if let Some(target) = app.process_preview_primary() {
|
||
let endpoint = match target {
|
||
crate::diagram_usage::Target::ProcessOccurrence(endpoint) => endpoint.clone(),
|
||
crate::diagram_usage::Target::ProcessPreviewLink { source, .. } => source.clone(),
|
||
_ => unreachable!(),
|
||
};
|
||
let context = app.topology_context().unwrap();
|
||
return container(
|
||
column![
|
||
text(p.named(app.selected)).size(17),
|
||
label("Reused contents · read-only here"),
|
||
label("Open the referenced definition to edit this contribution or connection."),
|
||
action(
|
||
"Open referenced definition",
|
||
Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::OpenQualified(
|
||
context, endpoint
|
||
))
|
||
),
|
||
crate::model_menu::trigger(app),
|
||
]
|
||
.spacing(12),
|
||
)
|
||
.padding(14)
|
||
.width(320)
|
||
.height(Fill)
|
||
.style(style::panel)
|
||
.into();
|
||
}
|
||
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),
|
||
);
|
||
}
|
||
}
|
||
let actions = 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![
|
||
column![
|
||
tabs,
|
||
container(actions).padding(iced::Padding {
|
||
right: 14.,
|
||
..Default::default()
|
||
})
|
||
]
|
||
.spacing(12),
|
||
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,
|
||
);
|
||
// The complete byte/evidence comparison is shared by the summary and rows,
|
||
// once per visible References rebuild; no parser or second query is started.
|
||
let references_current =
|
||
app.text_workspace.drawer() == Some(DrawerTab::References) && app.source_results_current();
|
||
text_workspace::view(
|
||
app,
|
||
source_symbols::panel(app, references_current),
|
||
guard.into(),
|
||
references_current,
|
||
)
|
||
}
|
||
|
||
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.key == Key::Named(Named::Tab)
|
||
&& (press.modifiers.is_empty() || press.modifiers == iced::keyboard::Modifiers::SHIFT)
|
||
{
|
||
return Some(Binding::Custom(Message::SourceIndent(
|
||
context.clone(),
|
||
press.modifiers.shift(),
|
||
)));
|
||
}
|
||
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()
|
||
}
|
||
|
||
#[cfg(all(test, debug_assertions))]
|
||
pub(crate) fn captured_text_drawer_resize(app: &App) -> Message {
|
||
text_workspace::capture_drawer_resize(app)
|
||
}
|
||
|
||
#[cfg(all(test, debug_assertions))]
|
||
pub(crate) fn captured_source_reference_stale_summary(app: &App) -> bool {
|
||
text_workspace::summary_visibility_tests::rendered_stale(app)
|
||
}
|
||
|
||
#[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), "…");
|
||
}
|
||
}
|