mod source_symbols; pub(crate) mod traceability; use crate::{ app::{App, Choice, FileOperation, Message, Page, Pending}, diagram::DiagramCanvas, forms, style, }; use iced::{ Element as View, Fill, Font, widget::{ self, Space, button, column, container, pick_list, row, scrollable, text, text_input, tooltip, }, }; use oa_core::*; use unicode_segmentation::UnicodeSegmentation; pub fn label<'a>(s: impl Into) -> 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!("{} checks", app.issue_count()), Message::Page(Page::Validation) ) ] .spacing(12) .align_y(iced::Center), ) .padding([4, 16]) .style(style::panel); let root = crate::allocation_input::root(app, column![header, task_nav, body, footer]); if app.architecture.as_ref().is_some_and(|s| s.modal()) { return modal_width( root, crate::app::architecture_authoring::dialog(app), (app.window_size.width - 50.).clamp(500., 820.), ); } if let Some(workflow) = &app.workflow { if matches!(workflow, crate::workflow::Workflow::Semantic(_)) { return modal_width( root, workflow.view(p), (app.window_size.width - 50.).clamp(800., 1120.), ); } return modal(root, workflow.view(p)); } if let Some(p) = &app.file_prompt { let title = match p.operation { FileOperation::Open => "Open model", FileOperation::Save => "Save model", FileOperation::Export => "Export diagram", }; return modal( root, column![ text(title).size(21), label(match p.operation { FileOperation::Export => "Save this diagram as .svg.", FileOperation::Save => "Choose a .arcadia entry in a new destination, or .json to export the model.", FileOperation::Open => "Open an .arcadia project entry or import a .json model.", }), text_input("File path", &p.path) .on_input(Message::FilePath) .on_submit(Message::SubmitFile) .padding(10) .size(13), text(match (p.operation, p.overwrite) { (FileOperation::Export, true) => "This file exists. Replace it with the current diagram?", (FileOperation::Export, false) => "Includes visible elements and their authored connection routes.", (FileOperation::Save, _) => "Source Save As retains included file paths. Existing destination files are protected.", (FileOperation::Open, _) => "Included source files are loaded together. Invalid drafts remain editable.", }) .size(12), row![ action("Cancel", Message::CancelFile), button(text(if p.overwrite { "Replace file" } else { title }).size(12)) .padding(10) .on_press(Message::SubmitFile) ] .spacing(10) ] .spacing(16) .into(), ); } if let Some(target) = app.delete_target.as_ref().filter(|_| app.delete_pending) { return modal( root, column![ text("Delete from model?").size(21), text(p.named(target.snapshot.request.object)).size(16), text(crate::app::diagram_removal::explanation( app, target.snapshot.request.object )) .size(13), row![ widget::container(action("Cancel", Message::CancelDelete)).id("delete-cancel"), widget::container( button("Delete from model") .style(button::danger) .padding(10) .on_press(Message::ConfirmDelete(target.snapshot.token)) ) .id("delete-confirm") ] .spacing(10) ] .spacing(16) .into(), ); } if app.pending.is_some() { return modal(root,column![text("Keep your changes?").size(21),text("Save the model and pending edits before continuing. Invalid drafts stay available for correction.").size(13),row![action("Cancel",Message::Cancel),action("Discard changes",Message::Discard),button("Save & continue").padding(10).on_press(Message::SaveAndContinue)].spacing(8)].spacing(16).into()); } if app.source_rename.open() { return modal_width( root, crate::app::source_rename::dialog(app), (app.window_size.width - 32.).clamp(120., 760.), ); } root } fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> { modal_width(base, content, 800.) } fn modal_width<'a>( base: View<'a, Message>, content: View<'a, Message>, width: f32, ) -> View<'a, Message> { widget::stack![ base, widget::opaque( container( container(content) .padding(26) .width(width) .style(style::card) ) .center(Fill) .style(|_| container::Style { background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()), ..Default::default() }) ) ] .into() } fn diagram(app: &App) -> View<'_, Message> { let mut diagrams = app .project() .diagrams .values() .filter(|d| d.layer == app.layer || d.projection == ProjectionKind::Traceability) .map(|d| Choice { id: d.id, label: d.name.clone(), }) .collect::>(); diagrams.sort_by(|a, b| a.label.cmp(&b.label).then(a.id.cmp(&b.id))); let selected = diagrams .iter() .find(|c| Some(c.id) == app.active_diagram) .cloned(); let mut chooser = row![ pick_list(diagrams, selected, Message::Diagram) .placeholder("Choose diagram") .text_size(12) .padding(8) .width(Fill), action("+ View", Message::AddDiagram) ] .spacing(7) .align_y(iced::Center); let mut controls = row![ action("Fit", Message::Fit), action("−", Message::Zoom(1. / 1.2)), label(format!("{:.0}%", app.zoom * 100.)), action("+", Message::Zoom(1.2)), action("Export SVG", Message::Export) ] .spacing(7) .align_y(iced::Center); if let Some(id) = app.active_diagram { chooser = chooser.push(action("View properties", Message::Select(id))); controls = controls.push(action("Auto-layout", Message::Command(Command::Layout(id)))); } let toolbar = column![chooser, controls].spacing(6); let projection = app .active_diagram .and_then(|id| app.project().diagrams.get(&id)) .map(|d| d.projection); let modes = row![ label("View content"), pick_list( [ ProjectionKind::Allocation, ProjectionKind::FunctionalDataflow, ProjectionKind::Capabilities, ProjectionKind::Decomposition, ProjectionKind::Overview, ProjectionKind::Traceability, ], projection, Message::Projection ) .padding(8) .text_size(12) ] .spacing(10) .align_y(iced::Center); let canvas: View<'_, Message> = if let Some(id) = app.active_diagram { let program = DiagramCanvas { allocation: Some(app.allocation_capability()), project: app.project(), view_index: Some(app.document.view_index()), diagram: id, selected: app.selected, navigation_focus: app .diagram_focus(crate::diagram_usage::Surface::Saved(id)) .map(|usage| usage.bounds), zoom: app.zoom, pan: app.pan, context: app.route_context().unwrap(), selected_bend: app .selected_bend .filter(|(edge, _)| *edge == app.selected) .map(|(_, i)| i), editable: !app.draft_dirty() && !app.source_dirty(), placing: app.architecture.as_ref().is_some_and(|s| s.saved.placing), connection: app.connection.as_ref(), }; let mut scene = widget::stack![ crate::diagram::focus::wrap( widget::canvas(program).width(Fill).height(Fill), program.context, crate::app::diagram_removal::focus_enabled(app), app.busy, Message::DiagramTraverse ), widget::canvas(crate::diagram::PortLabels(program)) .width(Fill) .height(Fill), widget::canvas(crate::diagram::WorkFeedback(program)) .width(Fill) .height(Fill), ]; if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) { scene = scene.push(editor); } let reveal = app.architecture_reveal_stamp(id); row![ widget::sensor( container(scene) .id(crate::allocation_input::CANVAS_ID) .width(Fill) .height(Fill) ) .on_show( move |size| reveal.map_or(Message::CanvasResized(size), |(context, epoch)| { Message::DiagramRevealViewport(context, epoch, size) }) ) .on_resize(move |size| reveal .map_or(Message::CanvasResized(size), |(context, epoch)| { Message::DiagramRevealViewport(context, epoch, size) })), crate::app::architecture_authoring::palette(app) ] .height(Fill) .width(Fill) .into() } else { container( column![ text(if app.layer == Layer::Oa { "Define the operational context" } else { "Define your system boundary" }) .size(23), text("Create a view, then add participants and the work they perform.") .size(13) .color(style::MUTED), button("Create architecture view") .padding(12) .on_press(Message::AddDiagram) ] .spacing(16), ) .center(Fill) .into() }; let mut tools = column![toolbar].spacing(8); if app.active_diagram.is_some() { tools = tools.push(modes); } if projection == Some(ProjectionKind::Allocation) { // Reserve this slot even when idle: changing feedback must never resize // the measured canvas and invalidate a held gesture. Its identity is // independent of status length, target geometry and the diagram zoom. let destination = app .allocation_drag .feedback .as_ref() .map(|feedback| feedback.destination_identity()) .unwrap_or_else(|| "None · drag work to choose".into()); let inspection = app .allocation_drag .feedback .as_ref() .map(|feedback| feedback.inspection_identity()) .unwrap_or_else(|| destination.clone()); tools = tools.push( container(tooltip( text(format!("Destination: {}", short(&destination, 28))) .size(13) .wrapping(widget::text::Wrapping::None), container(text(inspection).size(13)) .padding(10) .max_width(420) .style(style::card), tooltip::Position::Top, )) .id("allocation-destination") .height(26) .width(Fill) .clip(true), ); tools = tools.push( row![ action( &format!( "{} · {}", if app.allocation_drag.arrange_next { "Arrange next drag: armed" } else { "Arrange next drag" }, crate::allocation_input::ARRANGE_SHORTCUT ), Message::WorkDrag(crate::app::allocation_drag::Event::ArrangeNext) ), label("Drag work into a performer to assign · out to remove assignment") ] .spacing(8) .align_y(iced::Center), ); } if let Some(diagram) = app.active_diagram { let view = oa_core::projection::project_view(app.project(), diagram); let edge = view.edges.iter().find(|edge| edge.id == app.selected); let promoted = edge.is_some_and(|edge| { oa_core::projection::edge_endpoint_placements( app.project(), &app.project().diagrams[&diagram], edge.id, ) .is_some_and(|(source, target)| source != edge.source || target != edge.target) }); let hint = if let Some(feedback) = &app.allocation_drag.feedback { feedback.text.as_str() } else if let Some(session) = &app.connection { if session.source.is_some() { if session.candidates.is_empty() { "No compatible targets are visible. Expand containers or show an endpoint · Esc cancels" } else { "Choose a highlighted target · middle-drag or arrow keys pan · Esc cancels" } } else { session.tool.hint() } } else if promoted { "Expand endpoint containers to edit this connection route" } else if edge.is_some() { "Right-click for routing · drag line / bend / label · Insert adds a bend · Esc cancels" } else { "Right-click for actions · drag to arrange · scroll to zoom" }; // Status is outside the measured canvas. Clipping prevents long text // from painting over work headers or the dedicated destination slot. tools = tools.push(container(label(hint)).height(30).width(Fill).clip(true)); } column![container(tools).padding(14), canvas].into() } fn inspector(app: &App) -> View<'_, Message> { let p = app.project(); if app.source_dirty() { let mut notice = column![ text("Source drafts pending").size(17), label("Properties become editable after source drafts are applied or reverted."), action("Open source drafts", Message::Page(Page::Text)) ] .spacing(12); if app.draft_dirty() { notice = notice .push(label( "A property draft is also retained. Revert it to apply the source drafts.", )) .push(action("Revert property draft", Message::RevertProperties)); } return container(notice) .padding(14) .width(320) .height(Fill) .style(style::panel) .into(); } let tabs = row![ action( "Properties", Message::InspectorTab(crate::navigation::InspectorTab::Properties) ) .style( if app.inspector_tab == crate::navigation::InspectorTab::Properties { style::selected } else { style::nav } ), action( "Connections", Message::InspectorTab(crate::navigation::InspectorTab::Connections) ) .style( if app.inspector_tab == crate::navigation::InspectorTab::Connections { style::selected } else { style::nav } ), ] .spacing(4); if app.inspector_tab == crate::navigation::InspectorTab::Connections { return container( column![ tabs, scrollable(crate::navigation::view(app, app.selected)).height(Fill) ] .spacing(14), ) .padding(14) .width(320) .height(Fill) .style(style::panel) .into(); } let mut body = column![ row![ label("PROPERTIES"), Space::new().width(Fill), crate::model_menu::trigger(app) ] .align_y(iced::Center) ] .spacing(12); if let Some(e) = p.elements.get(&app.selected) { body = body.push( row![ text(e.kind().label()) .size(17) .color(style::kind_color(e.kind())), Space::new().width(Fill), label(e.layer().to_string()) ] .align_y(iced::Center), ); body = body.push( pick_list( Kind::ALL .into_iter() .filter(|k| k.supports(e.layer())) .collect::>(), 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), ); } } body = body.push( row![ button(text("Apply changes").size(12)) .padding([9, 12]) .on_press(Message::Apply) .width(Fill), action("Revert", Message::RevertProperties) ] .spacing(5), ); if let Some(d) = &app.draft { body = body.push(forms::view(d, p)); } container( column![ tabs, scrollable(body.padding(iced::Padding { right: 14., ..Default::default() })) .id("inspector") .height(Fill) ] .spacing(12), ) .padding(14) .width(320) .height(Fill) .style(style::panel) .into() } fn data_catalog(app: &App) -> View<'_, Message> { crate::data_view::view(app) } fn traces(app: &App) -> View<'_, Message> { let p = app.project(); use traceability::{Event as E, Filter}; let (traces, artifacts) = app.traceability.matching(p); let mut tabs = row![].spacing(6); for (filter, label) in [ ( Filter::All, format!("All ({})", p.traces.len() + p.artifact_links.len()), ), (Filter::Traces, format!("Layer traces ({})", p.traces.len())), ( Filter::Artifacts, format!("Development artifacts ({})", p.artifact_links.len()), ), ] { tabs = tabs.push( button(text(label).size(12)) .style(if app.traceability.filter == filter { button::primary } else { style::nav }) .on_press(Message::Traceability(E::Filter(filter))), ); } let header = column![ heading( "Traceability", "Connect operational needs, system responsibilities and development evidence." ), row![ button( text( if p.layer_of(app.selected).unwrap_or(app.layer) == Layer::Oa { "Create System counterpart…" } else { "Create Operational counterpart…" } ) .size(12) ) .padding(9) .on_press(Message::StartTransition), action("+ Manual trace", Message::AddTrace), action( "Review counterpart changes…", Message::StartSemanticReview(app.selected) ), action("+ Artifact", Message::AddArtifact) ] .spacing(8) .wrap(), tabs.wrap(), row![ text_input( "Search names, aliases, owners or artifact URI…", &app.traceability.query ) .on_input(|raw| Message::Traceability(E::Search(raw))) .padding(8) .size(12) .width(Fill), button("Clear search").on_press(Message::Traceability(E::Search(String::new()))) ] .spacing(6), label(format!( "{} matching traces · {} matching artifacts", traces.len(), artifacts.len() )) ] .spacing(10) .padding(24); let mut out = column![].spacing(16); for id in &traces { let t = &p.traces[id]; let current = match (&t.baseline, p.properties(t.oa), p.properties(t.sa)) { (Some(b), Some(oa), Some(sa)) if b.oa == oa && b.sa == sa => "Reviewed", (Some(_), _, _) => "Review needed", _ => "No review baseline", }; out = out.push( container( column![ row![ action(p.named(t.oa), Message::Select(t.oa)).width(Fill), text("→").color(style::TEAL), action(p.named(t.sa), Message::Select(t.sa)).width(Fill) ] .spacing(8) .align_y(iced::Center), row![ label(format!("{:?} · Prose: {current}", t.kind)), Space::new().width(Fill), action( "Review counterpart changes…", Message::StartSemanticReview(t.id) ), action("Edit link", Message::Select(t.id)) ], text(crate::semantic_review::summary(p, t)) .size(11) .color(style::MUTED), text(short(&t.description, 130)).size(12) ] .spacing(10), ) .padding(16) .style(style::card), ); } if !artifacts.is_empty() { out = out.push(text("Development artifacts").size(18)); } for id in &artifacts { let a = &p.artifact_links[id]; let owner = traceability::owner_label(p, a.owner); out = out.push( container( column![ row![ action(&a.name, Message::Select(a.id)).width(Fill), label(format!("{:?}", a.relation)) ], text(&a.uri).size(12).font(Font::MONOSPACE), label(format!( "{}{}", owner, a.revision .as_ref() .map(|r| format!(" · revision {r}")) .unwrap_or_default() )) ] .spacing(8), ) .padding(16) .style(style::card), ); } if p.traces.is_empty() && p.artifact_links.is_empty() { out=out.push(text("Select a model element, then connect it to the other perspective or attach a code, CAD, schematic or document reference.").size(14).color(style::MUTED)); } else if traces.is_empty() && artifacts.is_empty() { out = out .push(text("No matching records. Clear the search or choose another filter.").size(13)); } column![ header, scrollable(out.padding(iced::Padding::from(24).right(42))) .id("traceability-list") .height(Fill) ] .height(Fill) .into() } fn source_files(app: &App) -> View<'_, Message> { let workspace = &app.source_workspace; let panel = column![ row![ text("SOURCE FILES").size(13).width(Fill), crate::context_menu::Area::left( action("Actions ▾", Message::Menu(crate::model_menu::Event::Close)), move |anchor| Message::SourceTree(crate::source_tree::Event::FileMenu( workspace.active.clone(), anchor )) ) ] .align_y(iced::Center), label(format!( "{} files · one system model", workspace.files.len() )), row![ action("Collapse all", Message::SourceTreeCollapse), action("Locate", Message::SourceTreeLocate) ] .spacing(3), crate::source_tree::view(workspace, &app.source_diagnostics, &app.source_tree) .map(Message::SourceTree), action("Load includes", Message::SourceLoadIncludes).width(Fill), text_input("oa/new-file.arcadia", &workspace.new_path) .size(12) .padding(8) .on_input(Message::SourceNewPath) .on_submit(Message::SourceCreateFile), action("+ Create source file", Message::SourceCreateFile).width(Fill), label("Includes define file membership. Closing a detached buffer never deletes its file."), ] .spacing(9); panel.into() } fn source(app: &App) -> View<'_, Message> { use crate::source_navigation::Event as SourceEvent; let (undo_label, undo_message) = match app.source_rename_undo_message() { Some(message) => ("Undo rename", Some(message)), None => ( "Undo model", (app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo), ), }; let navigation: View<'_, Message> = if app.source_navigation.open { column![ row![ text_input("Find literal text…", &app.source_navigation.query) .id("source-find") .on_input(|query| Message::SourceNavigation(SourceEvent::Query(query))) .on_submit(Message::SourceNavigation(SourceEvent::Next(false))) .padding(8) .size(12) .width(Fill), action( "Previous", Message::SourceNavigation(SourceEvent::Next(true)) ), action("Next", Message::SourceNavigation(SourceEvent::Next(false))), label(app.source_navigation.label()), action("Close", app.source_find_close_message()), ] .spacing(6) .align_y(iced::Center), row![ text_input("Line", &app.source_navigation.line) .id("source-line") .on_input(|line| Message::SourceNavigation(SourceEvent::Line(line))) .on_submit(Message::SourceNavigation(SourceEvent::Go)) .padding(8) .size(12) .width(80), action("Go to line", Message::SourceNavigation(SourceEvent::Go)), label("Enter or F3: next match · Shift F3: previous · Esc: close"), ] .spacing(6) .align_y(iced::Center), ] .spacing(6) .into() } else { column![].into() }; let recovery: View<'_, Message> = if let Some(path) = &app.pending_recovery { let edits_block_recovery = !app.source_workspace.applied && app.source_workspace.has_edits(); let filename = path.file_name().unwrap_or_default().to_string_lossy(); container( column![ tooltip( text(format!("Interrupted save · {filename}")).size(15), text(path.display().to_string()).size(12), tooltip::Position::Bottom, ), text(if edits_block_recovery { "Revert the edited files before recovery. Recovery restores files to their state before the interrupted save, then reopens them." } else if app.source_workspace.applied { "Restore files to their state before the interrupted save. Your current model and editor drafts are kept." } else { "Restore files to their state before the interrupted save, then reopen them. This rolls back the incomplete save." }) .size(13), button(text("Recover interrupted save").size(12)) .padding(10) .on_press_maybe( (!edits_block_recovery && !app.busy).then_some(Message::SourceRecover), ), ] .spacing(8), ) .padding(12) .width(Fill) .style(style::card) .into() } else { column![].into() }; let mut diagnostics = column![].spacing(5); let mut visible_diagnostics = 0; for (index, issue) in app.source_diagnostics.iter().enumerate() { // The persistent recovery banner supplies the action and explanation; // repeating the internal journal path obscures ordinary diagnostics. if app.pending_recovery.is_some() && issue.diagnostic.code == "pending_save" { continue; } visible_diagnostics += 1; diagnostics = diagnostics.push( button( text(format!( "{}:{}:{} {}", issue.path, issue.diagnostic.line, issue.diagnostic.column, issue.diagnostic.message )) .size(12) .color(match issue.diagnostic.severity { validation::Severity::Error => iced::Color::from_rgb8(174, 46, 64), validation::Severity::Warning => iced::Color::from_rgb8(145, 93, 23), }), ) .width(Fill) .padding([4, 6]) .style(style::nav) .on_press(Message::SourceDiagnostic(index)), ); for related in &issue.related { diagnostics = diagnostics.push(action( &format!("Related declaration · {}", related.path), Message::SourceRelated(related.clone()), )); } } let editor = widget::text_editor(&app.source_editor) .id(app.source_editor_id()) .style(style::source_editor) .on_action(Message::SourceEdit) .key_binding({ let context = app.source_symbol_context(); let rename = app.source_rename_begin(); move |press| source_binding(press, &context, Some(&rename)) }) .font(Font::MONOSPACE) .size(14) .padding(16) .height(Fill) .highlight_with::( app.source_snapshot.clone(), crate::highlight::format, ); let header = container( column![ row![ container(heading( "Textual model", "One model across files · comments and identities preserved" )) .width(Fill), button(text("Apply all").size(12)) .padding(10) .on_press(Message::ApplySource), action("Revert file", Message::RevertSource) ] .spacing(8) .align_y(iced::Center), row![ action("Undo text", Message::SourceUndo), action("Redo text", Message::SourceRedo), button(text(undo_label).size(12)).padding([8,10]).style(style::nav) .on_press_maybe(undo_message), button(text("Redo model").size(12)).padding([8,10]).style(style::nav) .on_press_maybe((app.document.can_redo() && !app.source_dirty()).then_some(Message::Redo)), action("Find / go to…", Message::SourceNavigation(SourceEvent::Open(false))), label(if app.source_dirty() { "Drafts pending" } else { "Model in sync" }) ] .spacing(7) .align_y(iced::Center), recovery, text(app.source_workspace.active.clone()).font(Font::MONOSPACE).size(12).color(style::INK), label(if app.pending_recovery.is_some() && !app.source_workspace.applied { "The previous graphical model remains available while these files are recovered." } else if !app.source_workspace.applied { "This project needs repair. Apply all adopts it; the previous graphical model remains available." } else if app.source_workspace.detached_dirty() { "A detached file has an unsaved draft. Reattach it or revert that file before saving." } else { "Undo text affects this file's draft. Model Undo restores accepted files together (100 changes / about 32 MiB)." }), navigation, row![ action("Definition · F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::Definition))), action("References · Shift F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::References))), action("Rename alias · F2", app.source_rename_message()), action("Show in diagrams…", app.source_symbol_message(crate::source_navigation::SymbolEvent::DiagramUsages)), button(text("Back · Alt ←").size(12)).padding([8, 10]).style(style::nav) .on_press_maybe((app.source_symbols.back.iter().any(|entry| entry.session == app.source_workspace.session())).then(|| app.source_symbol_message(crate::source_navigation::SymbolEvent::Back))), ].spacing(7), scrollable(diagnostics) .height((visible_diagnostics as f32 * 32.).min(132.)) ] .spacing(12) ) .padding(18); let editor = widget::sensor(editor) .key(( app.source_workspace.active.clone(), app.source_reveal_ticket(), )) .on_show({ let ticket = app.source_reveal_ticket(); move |_| Message::SourceViewport(ticket) }) .on_resize({ let ticket = app.source_reveal_ticket(); move |_| Message::SourceViewport(ticket) }); let workspace: View<'_, Message> = if app.source_symbols.open { row![ container(editor).width(Fill).height(Fill), container(scrollable(source_symbols::panel(app))) .width(300) .height(Fill), ] .height(Fill) .into() } else { container(editor).width(Fill).height(Fill).into() }; source_layout(header.into(), workspace, app.window_size.height) } fn source_layout<'a, Renderer: iced::advanced::text::Renderer + 'a>( header: View<'a, Message, iced::Theme, Renderer>, workspace: View<'a, Message, iced::Theme, Renderer>, window_height: f32, ) -> View<'a, Message, iced::Theme, Renderer> { // Lint clears and repopulates diagnostics asynchronously. Reserve the same // outer slot so a pointer aimed at the editor cannot land in the header. column![ container(scrollable(header).id("source-header")).height((window_height * 0.42).max(180.)), workspace, ] .height(Fill) .into() } pub(crate) fn source_binding( press: widget::text_editor::KeyPress, context: &crate::source_navigation::SymbolContext, rename: Option<&crate::app::source_rename::Begin>, ) -> Option> { use crate::source_navigation::{SymbolEvent, query::Action}; use iced::keyboard::{Key, key::Named}; use widget::text_editor::{Binding, Status}; if matches!(press.status, Status::Focused { .. }) && press.modifiers.is_empty() && press.key == Key::Named(Named::F2) && let Some(begin) = rename { return Some(Binding::Custom(Message::SourceRename( crate::app::source_rename::Event::Begin(begin.clone()), ))); } // Windows owns Alt+Space for its window menu. Iced's default binding would // otherwise insert a supplied space payload, even after that menu closes. // Keep Ctrl+Alt (AltGr), shifted input, and ordinary Space unchanged. #[cfg(target_os = "windows")] if matches!(press.status, Status::Focused { .. }) && press.modifiers == iced::keyboard::Modifiers::ALT && matches!( press.key.as_ref(), Key::Named(Named::Space) | Key::Character(" ") ) { return None; } if matches!(press.status, Status::Focused { .. }) && !press.modifiers.control() && !press.modifiers.logo() { let event = match &press.key { Key::Named(Named::F12) if !press.modifiers.alt() => { Some(SymbolEvent::Query(if press.modifiers.shift() { Action::References } else { Action::Definition })) } Key::Named(Named::ArrowLeft) if press.modifiers.alt() && !press.modifiers.shift() => { Some(SymbolEvent::Back) } _ => None, }; if let Some(event) = event { return Some(Binding::Custom(Message::SourceSymbols( context.clone(), event, ))); } } Binding::from_key_press(press) } #[cfg(all(test, debug_assertions))] pub(crate) mod source_tests { use super::*; use iced::advanced::{ Layout, Shell, graphics, layout, renderer, text as shaped, widget as core_widget, }; use iced::widget::column; use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation, Vector}; // Measure real Iced paragraphs/editor text without constructing a window or GPU. pub(crate) struct FontLayout; impl renderer::Renderer for FontLayout { fn start_layer(&mut self, _: Rectangle) {} fn end_layer(&mut self) {} fn start_transformation(&mut self, _: Transformation) {} fn end_transformation(&mut self) {} fn reset(&mut self, _: Rectangle) {} fn fill_quad(&mut self, _: renderer::Quad, _: impl Into) {} 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) {} } #[derive(Default)] struct Geometry { header: Option<(Rectangle, Rectangle, Vector)>, editor: Option, } impl core_widget::Operation for Geometry { fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn core_widget::Operation)) { visit(self); } fn container(&mut self, id: Option<&core_widget::Id>, bounds: Rectangle) { if id == Some(&core_widget::Id::new("test-source-editor")) { self.editor = Some(bounds); } } fn scrollable( &mut self, id: Option<&core_widget::Id>, bounds: Rectangle, content: Rectangle, offset: Vector, _: &mut dyn core_widget::operation::Scrollable, ) { if id == Some(&core_widget::Id::new("source-header")) { self.header = Some((bounds, content, offset)); } } } fn mounted_source<'a>( content: &'a widget::text_editor::Content, diagnostics: usize, find: bool, window_height: f32, ) -> View<'a, Message, iced::Theme, FontLayout> { let mut notices = column![].spacing(5); for index in 0..diagnostics { notices = notices.push(button(text(format!( "救援/assess.arcadia:{}:5 · Warning: verify this participant's engineering allocation", index + 1, )).size(12)).padding([4, 6]).width(Fill)); } let find: View<'_, Message, iced::Theme, FontLayout> = if find { column![ text_input("Find literal text", "assess").on_input(|s| Message::SourceNavigation( crate::source_navigation::Event::Query(s) )), text_input("Line", "1").on_input(|s| Message::SourceNavigation( crate::source_navigation::Event::Line(s) )), ] .spacing(8) .into() } else { Space::new().into() }; let header = container( column![ text("Textual model").size(24), text("Edit the current file. Apply text validates the complete included model.") .size(11), row![ button(text("Apply text")), button(text("Undo text")), button(text("Model Undo")) ] .spacing(8), text("救援/assess.arcadia · All files applied to the accepted model").size(11), text("Undo text affects this draft. Model Undo restores accepted files together.") .size(11), find, row![ button(text("Definition · F12")), button(text("References · Shift F12")), button(text("Back")) ] .spacing(7), scrollable(notices).height((diagnostics as f32 * 32.).min(132.)), ] .spacing(12), ) .padding(18); let editor = container( widget::text_editor(content) .on_action(Message::SourceEdit) .font(Font::MONOSPACE) .size(14) .padding(16) .height(Fill), ) .id("test-source-editor") .height(Fill) .width(Fill); source_layout(header.into(), editor.into(), window_height) } #[test] fn source_header_keeps_editor_bounds_stable_through_lint_and_find_rebuilds() { for (host, window_height) in [ (Size::new(720., 540.), 700.), (Size::new(1180., 890.), 1080.), ] { let content = widget::text_editor::Content::::with_text( "// is excluded\r\n// 雨\r\n", ); let mut tree = core_widget::Tree::empty(); let mut baseline = None; for (diagnostics, find) in [ (0, false), (6, false), (0, false), (6, true), (1, true), (0, false), ] { let mut ui = mounted_source(&content, diagnostics, find, window_height); tree.diff(ui.as_widget()); let node = ui.as_widget_mut().layout( &mut tree, &FontLayout, &layout::Limits::new(Size::ZERO, host), ); let mut geometry = Geometry::default(); ui.as_widget_mut().operate( &mut tree, Layout::new(&node), &FontLayout, &mut geometry, ); let editor = geometry.editor.unwrap(); let (header, _, _) = geometry.header.unwrap(); let slot = node.children()[0].bounds(); assert_eq!(slot.height, (window_height * 0.42).max(180.)); assert!( header.height <= slot.height, "short content may shrink inside the fixed slot" ); assert!( editor.height >= 240., "remaining editor must stay usable: {editor:?}" ); assert_eq!(editor.y, slot.y + slot.height); if let Some(expected) = baseline { assert_eq!(editor, expected, "diagnostics={diagnostics}, find={find}"); } else { baseline = Some(editor); } } } } #[test] fn source_header_remains_scrollable_and_preserves_offset_across_diagnostic_refresh() { let content = widget::text_editor::Content::::with_text("// 雨\r\n"); let host = Size::new(720., 540.); let mut tree = core_widget::Tree::empty(); let mut saved = None; for diagnostics in [6, 7] { let mut ui = mounted_source(&content, diagnostics, true, 700.); tree.diff(ui.as_widget()); let node = ui.as_widget_mut().layout( &mut tree, &FontLayout, &layout::Limits::new(Size::ZERO, host), ); let mut geometry = Geometry::default(); ui.as_widget_mut() .operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry); let (bounds, contents, _) = geometry.header.unwrap(); assert!( contents.height > bounds.height, "fixture must actually overflow" ); if saved.is_none() { let mut messages = Vec::new(); let mut shell = Shell::new(&mut messages); ui.as_widget_mut().update( &mut tree, &iced::Event::Mouse(iced::mouse::Event::WheelScrolled { delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -45. }, }), Layout::new(&node), iced::mouse::Cursor::Available(Point::new(bounds.x + 8., bounds.y + 8.)), &FontLayout, &mut iced::advanced::clipboard::Null, &mut shell, &Rectangle::with_size(host), ); assert!(shell.is_event_captured()); } let mut geometry = Geometry::default(); ui.as_widget_mut() .operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry); let offset = geometry.header.unwrap().2; assert!(offset.y > 0., "the actual mounted Scrollable must move"); if let Some(before) = saved { assert_eq!(offset, before); } else { saved = Some(offset); } } } #[cfg(target_os = "windows")] #[test] fn source_editor_windows_menu_payload_is_inert_but_space_and_altgr_still_edit() { use core_widget::operation::Focusable; use iced::keyboard::{ self, Key, Modifiers, key::{Code, Named, Physical}, }; use widget::text_editor::{Binding, Content, KeyPress, State, Status}; let baseline = "// is excluded\r\n// 雨\r\n"; let context = crate::source_navigation::SymbolContext { session: Id::new_v4(), path: "救援/assess.arcadia".into(), }; for (key, physical_key, modifiers, payload, inserted) in [ ( Key::Named(Named::Space), Physical::Code(Code::Space), Modifiers::ALT, " ", false, ), ( Key::Character(" ".into()), Physical::Code(Code::Space), Modifiers::ALT, " ", false, ), ( Key::Named(Named::Space), Physical::Code(Code::Space), Modifiers::empty(), " ", true, ), ( Key::Named(Named::Space), Physical::Code(Code::Space), Modifiers::CTRL | Modifiers::ALT, " ", true, ), ( Key::Character("e".into()), Physical::Code(Code::KeyE), Modifiers::CTRL | Modifiers::ALT, "€", true, ), ( Key::Named(Named::Space), Physical::Code(Code::Space), Modifiers::ALT | Modifiers::SHIFT, " ", true, ), ] { let mut content = Content::::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(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), "…"); } }