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) -> 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::>().join(" "); if s.graphemes(true).count() > limit { format!( "{}…", s.graphemes(true) .take(limit.saturating_sub(1)) .collect::() ) } 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::>(); 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::>(); 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::>(), 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::>(), 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> { 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) {} fn allocate_image( &mut self, handle: &iced::advanced::image::Handle, callback: impl FnOnce( Result, ) + 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::::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::::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::>() .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), "…"); } }