Checkpoint source 7 workflows and in-progress diagram authoring redesign
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s

Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
This commit is contained in:
Codex
2026-09-10 10:06:55 -07:00
parent 4b6b5fc197
commit e2ecfea69b
162 changed files with 17595 additions and 926 deletions
@@ -0,0 +1,683 @@
//! Named, view-local creation. A draft never enters the accepted model.
use super::*;
use iced::widget::{button, column, pick_list, row, scrollable, text, text_input};
use serde::{Deserialize, Serialize};
mod palette;
pub use palette::view as palette;
#[derive(Debug, Clone)]
pub enum Event {
Start(Kind, Option<Position>),
Allocate(Kind, Id, Option<Position>),
Rename(crate::route_edit::Context, Id, Position),
TogglePalette,
Existing(Option<Position>),
Name(String),
Submit,
Click(crate::route_edit::Context, Position),
Pick(Id),
Locate(Id),
Search(String),
Filter(Filter),
ShowPlaced(bool),
Page(usize),
Edit,
Cancel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Filter {
Context,
AllCompatible,
Kind(Kind),
}
impl std::fmt::Display for Filter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Context => f.write_str("Suggested for this view"),
Self::AllCompatible => f.write_str("All compatible types"),
Self::Kind(k) => f.write_str(k.label()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Intent {
Create {
kind: Kind,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
performer: Option<Id>,
},
Rename {
element: Id,
name: String,
},
Existing {
selected: Option<Id>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Saved {
pub version: u32,
pub base: String,
pub diagram: Id,
pub intent: Intent,
pub point: Option<Position>,
pub placing: bool,
pub query: String,
pub filter: Filter,
pub show_placed: bool,
pub page: usize,
}
#[derive(Debug, Clone)]
pub struct State {
pub saved: Saved,
document: Id,
revision: u64,
pub error: Option<String>,
}
impl State {
pub fn restore(app: &App, saved: Saved) -> Result<Self, String> {
validate_saved(app.project(), &saved)?;
Ok(Self {
saved,
document: app.canvas_document,
revision: app.document.revision(),
error: None,
})
}
pub fn modal(&self) -> bool {
!self.saved.placing && matches!(self.saved.intent, Intent::Existing { .. })
}
pub fn inline(&self) -> bool {
!self.saved.placing
&& self.saved.point.is_some()
&& !matches!(self.saved.intent, Intent::Existing { .. })
}
pub fn unnamed_tool(&self) -> bool {
self.saved.placing
&& self.saved.point.is_none()
&& matches!(&self.saved.intent, Intent::Create { name, .. } if name.is_empty())
}
}
pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
if s.version != 1 || s.base != oa_core::transitions::project_fingerprint(p) {
return Err("The architecture draft does not match its accepted model.".into());
}
let d = p
.diagrams
.get(&s.diagram)
.ok_or("The creation draft's diagram is missing.")?;
if matches!(d.projection, ProjectionKind::StateMachine { .. })
|| s.query.len() > 16384
|| s.page > 1_000_000
|| s.point
.is_some_and(|p| !p.x.is_finite() || !p.y.is_finite())
{
return Err("Invalid architecture creation draft context.".into());
}
match &s.intent {
Intent::Create {
kind,
name,
performer,
} if name.len() > 16384
|| !kind.supports(d.layer)
|| !d.projection.includes(*kind)
|| *kind == Kind::Port
|| performer.is_some_and(|id| {
!kind.is_work()
|| !p
.elements
.get(&id)
.is_some_and(|e| e.kind().is_performer() && e.layer() == d.layer)
}) =>
{
Err("Invalid architecture creation draft type or name.".into())
}
Intent::Existing { selected: Some(id) } if !compatible(p, d, *id) => {
Err("The creation draft's existing element is incompatible.".into())
}
Intent::Rename { element, name }
if name.len() > 16384
|| !p.elements.contains_key(element)
|| !d.nodes.values().any(|n| n.element == *element) =>
{
Err("The rename draft's element is not shown in this view.".into())
}
_ => Ok(()),
}
}
fn context_kinds(p: &Project, d: &Diagram) -> Vec<Kind> {
let mut values = match (d.layer, d.projection) {
(_, ProjectionKind::StateMachine { .. }) => vec![],
(Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity],
(Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function],
(Layer::Oa, ProjectionKind::Capabilities) => vec![
Kind::OperationalMission,
Kind::Capability,
Kind::Entity,
Kind::Actor,
],
(Layer::Sa, ProjectionKind::Capabilities) => vec![
Kind::Mission,
Kind::SystemCapability,
Kind::System,
Kind::SystemActor,
],
(Layer::Oa, _) => vec![Kind::Entity, Kind::Actor, Kind::Activity],
(Layer::Sa, _) => vec![Kind::System, Kind::SystemActor, Kind::Function],
};
values.retain(|k| *k != Kind::System || !p.elements.values().any(|e| e.kind() == Kind::System));
values
}
pub fn kinds(app: &App) -> Vec<Kind> {
app.active_diagram
.and_then(|id| app.project().diagrams.get(&id))
.map(|d| context_kinds(app.project(), d))
.unwrap_or_default()
}
fn compatible(p: &Project, d: &Diagram, id: Id) -> bool {
p.elements.get(&id).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
})
}
fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool {
// Existing System remains a useful choice even when the singleton exists.
context_kinds(p, d).contains(&kind)
|| (kind == Kind::System
&& d.layer == Layer::Sa
&& d.projection != ProjectionKind::FunctionalDataflow)
}
pub fn candidates(app: &App) -> Vec<(Id, String, bool)> {
let Some(s) = &app.architecture else {
return vec![];
};
let p = app.project();
let Some(d) = p.diagrams.get(&s.saved.diagram) else {
return vec![];
};
let query = s.saved.query.to_lowercase();
let mut entries: Vec<_> = p
.elements
.values()
.filter(|e| compatible(p, d, e.id))
.filter(|e| match s.saved.filter {
Filter::Context => suggested(p, d, e.kind()),
Filter::AllCompatible => true,
Filter::Kind(k) => e.kind() == k,
})
.filter_map(|e| {
let shown = d.nodes.values().any(|n| n.element == e.id);
let label = format!(
"{} · @{} · {} · {}",
e.name,
e.alias,
e.kind().label(),
e.parent.map(|id| p.named(id)).unwrap_or("At root")
);
((!shown || s.saved.show_placed) && label.to_lowercase().contains(&query))
.then_some((e.id, label, shown))
})
.collect();
entries.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
entries
}
impl App {
pub(super) fn architecture_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => {
if self.architecture.is_some() {
if self.architecture.as_ref().is_some_and(State::unnamed_tool) {
self.architecture = None;
} else {
self.status =
"Finish or cancel the current name before choosing another tool."
.into();
return Task::none();
}
}
if self.page != Page::Diagram
|| self.source_dirty()
|| self.draft_dirty()
|| self.behavior.has_pending()
|| self.workflow.is_some()
{
self.status =
"Apply or cancel pending edits before creating diagram content.".into();
return Task::none();
}
let Some(diagram) = self.active_diagram else {
return Task::none();
};
let intent = match event {
Event::Start(kind, _) if kinds(self).contains(&kind) => Intent::Create {
kind,
name: String::new(),
performer: None,
},
Event::Allocate(kind, performer, _)
if kinds(self).contains(&kind) && kind.is_work() =>
{
Intent::Create {
kind,
name: String::new(),
performer: Some(performer),
}
}
Event::Existing(_) => Intent::Existing { selected: None },
_ => return Task::none(),
};
let placing = point.is_none() && matches!(intent, Intent::Create { .. });
let saved = Saved {
version: 1,
base: oa_core::transitions::project_fingerprint(self.project()),
diagram,
intent,
point,
placing,
query: String::new(),
filter: Filter::Context,
show_placed: false,
page: 0,
};
if let Ok(state) = State::restore(self, saved) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.architecture = Some(state);
self.connection = None;
self.fit_to_view = false;
self.status =
"Click in the diagram to position the new element · Esc cancels".into();
}
return iced::widget::operation::focus("architecture-name");
}
Event::TogglePalette => {
self.architecture_palette_expanded = !self.architecture_palette_expanded;
self.fit_to_view = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
Event::Rename(context, element, point) => {
if self.architecture.is_some()
|| self.route_context() != Some(context)
|| self.page != Page::Diagram
|| self.draft_dirty()
|| self.source_dirty()
|| self.behavior.has_pending()
{
return Task::none();
}
let Some(e) = self.project().elements.get(&element) else {
return Task::none();
};
let saved = Saved {
version: 1,
base: oa_core::transitions::project_fingerprint(self.project()),
diagram: context.diagram,
intent: Intent::Rename {
element,
name: e.name.clone(),
},
point: Some(point),
placing: false,
query: String::new(),
filter: Filter::Context,
show_placed: false,
page: 0,
};
if let Ok(state) = State::restore(self, saved) {
self.architecture = Some(state);
self.connection = None;
self.fit_to_view = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return iced::widget::operation::focus("architecture-name");
}
Event::Cancel => {
self.architecture = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
Event::Name(value) => {
if value.len() <= 16384
&& let Some(s) = &mut self.architecture
&& let (Intent::Create { name, .. } | Intent::Rename { name, .. }) =
&mut s.saved.intent
{
*name = value;
s.error = None;
}
}
Event::Search(query) => {
if query.len() <= 16384
&& let Some(s) = &mut self.architecture
{
s.saved.query = query;
s.saved.page = 0;
}
}
Event::Filter(filter) => {
if let Some(s) = &mut self.architecture {
s.saved.filter = filter;
s.saved.page = 0;
}
}
Event::ShowPlaced(value) => {
if let Some(s) = &mut self.architecture {
s.saved.show_placed = value;
s.saved.page = 0;
}
}
Event::Page(page) => {
if let Some(s) = &mut self.architecture {
s.saved.page = page.min(1_000_000);
}
}
Event::Edit => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if let Some(s) = &mut self.architecture {
s.saved.placing = false;
}
return iced::widget::operation::focus("architecture-name");
}
Event::Pick(id) => {
if !candidates(self)
.iter()
.any(|(candidate, _, shown)| *candidate == id && !shown)
{
return Task::none();
}
if let Some(s) = &mut self.architecture {
s.saved.intent = Intent::Existing { selected: Some(id) };
}
return self.architecture_event(Event::Submit);
}
Event::Locate(id) => {
if candidates(self)
.iter()
.any(|(candidate, _, shown)| *candidate == id && *shown)
&& let Some(s) = self.architecture.take()
{
return self.update(Message::RevealInDiagram(s.saved.diagram, id));
}
}
Event::Submit => {
let Some(s) = &mut self.architecture else {
return Task::none();
};
if matches!(&s.saved.intent, Intent::Create { name, .. } | Intent::Rename { name, .. } if name.trim().is_empty())
{
s.error = Some("Enter an engineering name before placing this element.".into());
return Task::none();
}
if matches!(s.saved.intent, Intent::Existing { selected: None }) {
return Task::none();
}
s.saved.placing = true;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if let Some(point) = s.saved.point {
self.commit_architecture(point);
}
}
Event::Click(context, point) => {
if self.route_context() != Some(context) {
return Task::none();
}
if self.architecture.as_ref().is_some_and(|s| s.saved.placing) {
if matches!(
self.architecture.as_ref().unwrap().saved.intent,
Intent::Existing { .. }
) {
self.commit_architecture(point);
} else {
let s = self.architecture.as_mut().unwrap();
s.saved.point = Some(point);
s.saved.placing = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return iced::widget::operation::focus("architecture-name");
}
}
}
}
Task::none()
}
fn commit_architecture(&mut self, point: Position) {
let Some(s) = self.architecture.as_ref() else {
return;
};
if s.document != self.canvas_document
|| s.revision != self.document.revision()
|| Some(s.saved.diagram) != self.active_diagram
|| self.page != Page::Diagram
|| self.source_dirty()
|| self.draft_dirty()
|| self.behavior.has_pending()
|| !point.x.is_finite()
|| !point.y.is_finite()
{
self.status =
"The diagram changed. The name is retained; cancel and restart this placement."
.into();
return;
}
let diagram = s.saved.diagram;
if let Intent::Rename { element, name } = &s.saved.intent {
let element = *element;
let name = name.clone();
if name.trim().is_empty() {
return;
}
if self.execute(Command::Rename(element, name)) {
self.architecture = None;
self.selected = element;
self.refresh_draft();
self.status = "Name updated · Undo restores the previous name".into();
} else if let Some(s) = &mut self.architecture {
s.error = Some(self.status.clone());
s.saved.placing = false;
}
return;
}
let mut candidate = self.project().clone();
let mut commands = vec![];
let id = match &s.saved.intent {
Intent::Create {
kind,
name,
performer,
} => {
if name.trim().is_empty() || !kinds(self).contains(kind) {
return;
}
let Some(element) =
candidate.make_element(*kind, candidate.diagrams[&diagram].layer, name, None)
else {
return;
};
let id = element.id;
candidate.elements.insert(id, element.clone());
commands.push(Command::CreateElement(element));
if let Some(performer) = performer {
commands.push(Command::Connect(
RelationKind::Allocation,
"Performs".into(),
*performer,
id,
));
}
id
}
Intent::Existing { selected: Some(id) }
if compatible(&candidate, &candidate.diagrams[&diagram], *id)
&& !candidate.diagrams[&diagram]
.nodes
.values()
.any(|n| n.element == *id) =>
{
*id
}
_ => return,
};
let node = candidate.place(diagram, id, point);
commands.push(Command::CreateNode {
diagram,
node: candidate.diagrams[&diagram].nodes[&node].clone(),
});
if self.execute(Command::Batch(commands)) {
self.architecture = None;
self.selected = id;
self.selected_node = Some(node);
self.selected_bend = None;
self.inspector_tab = crate::navigation::InspectorTab::Properties;
self.refresh_draft();
self.fit_to_view = false;
self.status = "Element placed · Undo restores the whole change. Drag to move; Fit is available when needed.".into();
} else if let Some(s) = &mut self.architecture {
s.error = Some(self.status.clone());
s.saved.placing = false;
}
}
}
pub fn inline_editor(app: &App) -> Option<Element<'_, Message>> {
let s = app.architecture.as_ref().filter(|s| s.inline())?;
let point = s.saved.point?;
let size = app.canvas_size.unwrap_or(iced::Size::new(450., 350.));
let width = (size.width - 16.).clamp(120., 320.);
let x = (point.x * app.zoom + app.pan.x).clamp(8., (size.width - width - 8.).max(8.));
let y = (point.y * app.zoom + app.pan.y).clamp(8., (size.height - 152.).max(8.));
Some(
column![
iced::widget::Space::new().height(y),
row![
iced::widget::Space::new().width(x),
iced::widget::opaque(
iced::widget::container(name_editor(app))
.padding(10)
.width(width)
.style(crate::style::card)
)
]
]
.into(),
)
}
fn name_editor(app: &App) -> Element<'_, Message> {
let s = app.architecture.as_ref().unwrap();
let (title, name, action) = match &s.saved.intent {
Intent::Create {
kind,
name,
performer,
} => (
performer
.map(|id| {
format!(
"{} · performed by {}",
kind.label(),
crate::views::short(app.project().named(id), 32)
)
})
.unwrap_or_else(|| format!("New {}", kind.label())),
name,
"Create",
),
Intent::Rename { name, .. } => ("Rename".into(), name, "Apply"),
Intent::Existing { .. } => unreachable!(),
};
let mut content = column![
text(title).size(12),
text_input("Engineering name", name)
.id("architecture-name")
.on_input(|v| Message::Architecture(Event::Name(v)))
.on_submit(Message::Architecture(Event::Submit))
.padding(7)
.size(13),
row![
button(text(action).size(12))
.padding([5, 9])
.style(crate::style::nav)
.on_press(Message::Architecture(Event::Submit)),
button(text("Cancel").size(12))
.padding([5, 9])
.style(crate::style::nav)
.on_press(Message::Architecture(Event::Cancel))
]
.spacing(6)
]
.spacing(7);
if let Some(error) = &s.error {
content = content.push(text(crate::views::short(error, 100)).size(11));
}
content.into()
}
pub fn dialog(app: &App) -> Element<'_, Message> {
let s = app.architecture.as_ref().unwrap();
let action = |label: String, event| {
button(text(label).size(12))
.padding(8)
.on_press(Message::Architecture(event))
};
let mut content = column![].spacing(12);
match &s.saved.intent {
Intent::Create { .. } | Intent::Rename { .. } => return name_editor(app),
Intent::Existing { .. } => {
let p = app.project();
let d = &p.diagrams[&s.saved.diagram];
let mut filters = vec![Filter::Context, Filter::AllCompatible];
filters.extend(
Kind::ALL
.into_iter()
.filter(|k| {
*k != Kind::Port
&& d.projection.includes(*k)
&& (k.supports(d.layer) || d.projection == ProjectionKind::Traceability)
})
.map(Filter::Kind),
);
let entries = candidates(app);
let page = s.saved.page.min(entries.len().saturating_sub(1) / 30);
let mut rows = column![].spacing(5);
for (id, label, shown) in entries.iter().skip(page * 30).take(30) {
rows = rows.push(
action(
format!("{}{}", if *shown { "Locate · " } else { "Add · " }, label),
if *shown {
Event::Locate(*id)
} else {
Event::Pick(*id)
},
)
.width(iced::Fill),
);
}
content = content.push(text("Add existing to this diagram").size(22))
.push(text("Add a model element to this view. Its name and properties stay shared across views.").size(13))
.push(text_input("Optional name, alias or owner filter", &s.saved.query).id("architecture-name").on_input(|v| Message::Architecture(Event::Search(v))).padding(9))
.push(pick_list(filters, Some(s.saved.filter), |f| Message::Architecture(Event::Filter(f))))
.push(iced::widget::checkbox(s.saved.show_placed).label("Include already shown (Locate)").on_toggle(|v| Message::Architecture(Event::ShowPlaced(v))))
.push(text(format!("{} compatible matches · page {}", entries.len(), page + 1)).size(12));
if entries.is_empty() {
content = content.push(text("No unplaced matches. Try All compatible types or include already shown elements.").size(13));
}
content = content
.push(scrollable(rows).height((app.window_size.height - 500.).clamp(120., 260.)))
.push(
row![
action("Previous".into(), Event::Page(page.saturating_sub(1))),
action(
"Next".into(),
Event::Page((page + 1).min(entries.len().saturating_sub(1) / 30))
),
action("Cancel".into(), Event::Cancel)
]
.spacing(8),
);
}
}
if let Some(error) = &s.error {
content = content.push(text(error).size(13));
}
content.into()
}
@@ -0,0 +1,268 @@
//! Compact neutral tool palette. Tool state never changes its allocated width.
use super::*;
use iced::{
Point, Rectangle, Size, Theme, mouse,
widget::{
canvas::{self, Frame, Geometry, Path, Stroke},
container, tooltip,
},
};
#[derive(Clone, Copy)]
enum Icon {
Element(Kind),
Arrow,
Existing,
Select,
Collapse(bool),
}
impl canvas::Program<Message> for Icon {
type State = ();
fn draw(
&self,
_: &(),
renderer: &iced::Renderer,
_: &Theme,
bounds: Rectangle,
_: mouse::Cursor,
) -> Vec<Geometry> {
let mut f = Frame::new(renderer, bounds.size());
let color = match self {
Self::Element(k) => crate::style::kind_color(*k),
_ => crate::style::INK,
};
let stroke = Stroke::default().with_color(color).with_width(1.4);
let line = |points: &[(f32, f32)]| {
Path::new(|p| {
p.move_to(Point::new(points[0].0, points[0].1));
for &(x, y) in &points[1..] {
p.line_to(Point::new(x, y));
}
})
};
match self {
Self::Element(Kind::Actor | Kind::SystemActor | Kind::Role) => {
f.stroke(&Path::circle(Point::new(8., 4.), 2.5), stroke);
for points in [
&[(8., 6.5), (8., 11.)][..],
&[(3., 8.), (13., 8.)],
&[(4., 15.), (8., 11.), (12., 15.)],
] {
f.stroke(&line(points), stroke);
}
}
Self::Element(Kind::Capability | Kind::SystemCapability) => {
f.stroke(&Path::circle(Point::new(8., 8.), 6.), stroke);
}
Self::Element(Kind::Mission | Kind::OperationalMission) => {
f.stroke(
&line(&[(3., 15.), (3., 2.), (13., 2.), (10., 6.), (3., 6.)]),
stroke,
);
}
Self::Element(k) => {
f.stroke(
&Path::rounded_rectangle(
Point::new(1., 3.),
Size::new(14., 10.),
if k.is_work() { 3.into() } else { 0.into() },
),
stroke,
);
if !k.is_work() {
f.stroke(
&Path::rectangle(Point::new(4., 6.), Size::new(8., 4.)),
stroke,
);
}
}
Self::Arrow => {
f.stroke(&line(&[(1., 8.), (14., 8.), (10., 4.)]), stroke);
f.stroke(&line(&[(14., 8.), (10., 12.)]), stroke);
}
Self::Existing => {
f.stroke(
&Path::rectangle(Point::new(1., 1.), Size::new(10., 10.)),
stroke,
);
f.stroke(
&Path::rectangle(Point::new(5., 5.), Size::new(10., 10.)),
stroke,
);
}
Self::Select => {
f.stroke(
&line(&[
(3., 1.),
(3., 14.),
(7., 10.),
(11., 15.),
(13., 14.),
(9., 9.),
(15., 8.),
(3., 1.),
]),
stroke,
);
}
Self::Collapse(expanded) => {
let (a, b) = if *expanded { (5., 11.) } else { (11., 5.) };
f.stroke(&line(&[(a, 3.), (b, 8.), (a, 13.)]), stroke);
}
}
vec![f.into_geometry()]
}
}
fn tool<'a>(
app: &App,
icon: Icon,
label: &'a str,
message: Message,
selected: bool,
) -> Element<'a, Message> {
let width = if app.architecture_palette_expanded {
156.
} else {
36.
};
let graphic = iced::widget::canvas(icon).width(16).height(16);
let mut content = row![graphic].spacing(7).align_y(iced::Center);
if app.architecture_palette_expanded {
content = content.push(text(label).size(11).wrapping(text::Wrapping::None));
}
let mut enabled = app.architecture.as_ref().is_none_or(State::unnamed_tool)
|| matches!(
&message,
Message::Architecture(Event::Cancel | Event::TogglePalette)
);
let mut hint = label.to_owned();
if let Message::Connection(crate::connection_edit::Event::Choose(Some(connection))) = &message {
let view = oa_core::projection::project_view(app.project(), app.active_diagram.unwrap());
if !crate::connection_edit::visible(&view).any(|id| connection.can_start(app.project(), id))
{
enabled = false;
hint.push_str(" — add or show a compatible source first");
}
}
let control = button(content)
.padding([5, 7])
.height(28)
.width(width - 4.)
.clip(true)
.style(if selected {
crate::style::selected
} else {
crate::style::nav
})
.on_press_maybe(enabled.then_some(message));
tooltip(
control,
container(text(hint).size(12))
.padding(7)
.style(crate::style::card),
tooltip::Position::Left,
)
.into()
}
pub fn view(app: &App) -> Element<'_, Message> {
let mut content = column![tool(
app,
Icon::Collapse(app.architecture_palette_expanded),
if app.architecture_palette_expanded {
"Collapse tools"
} else {
"Expand tools"
},
Message::Architecture(Event::TogglePalette),
false
)]
.spacing(3);
let group = |name: &'static str| -> Element<'_, Message> {
if app.architecture_palette_expanded {
container(text(name).size(10).color(crate::style::MUTED))
.padding([5, 4])
.into()
} else {
iced::widget::rule::horizontal(1).into()
}
};
content = content.push(tool(
app,
Icon::Select,
if app.architecture.is_some() {
"Cancel / select"
} else {
"Select / move"
},
if app.architecture.is_some() {
Message::Architecture(Event::Cancel)
} else {
Message::Connection(crate::connection_edit::Event::Choose(None))
},
app.architecture.is_none() && app.connection.is_none(),
));
for (heading, work) in [
("Participants / capabilities", false),
("Activities / functions", true),
] {
let choices: Vec<_> = kinds(app)
.into_iter()
.filter(|k| k.is_work() == work)
.collect();
if !choices.is_empty() {
content = content.push(group(heading));
}
for kind in choices {
let active = app
.architecture
.as_ref()
.is_some_and(|s| matches!(s.saved.intent,Intent::Create{kind:k,..} if kind==k));
content = content.push(tool(
app,
Icon::Element(kind),
kind.label(),
Message::Architecture(Event::Start(kind, None)),
active,
));
}
}
content = content.push(group("Connections"));
for connection in crate::connection_tool::Tool::for_layer(app.layer) {
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
let kind = match connection {
crate::connection_tool::Tool::Interaction => Kind::Activity,
crate::connection_tool::Tool::FunctionalExchange => Kind::Function,
crate::connection_tool::Tool::CommunicationMean => Kind::Entity,
crate::connection_tool::Tool::BoundaryExchange => Kind::System,
};
if !d.projection.includes(kind) {
continue;
}
content = content.push(tool(
app,
Icon::Arrow,
connection.label(),
Message::Connection(crate::connection_edit::Event::Choose(Some(*connection))),
app.connection
.as_ref()
.is_some_and(|s| s.tool == *connection),
));
}
content = content.push(group("Common")).push(tool(
app,
Icon::Existing,
"Add existing…",
Message::Architecture(Event::Existing(None)),
false,
));
container(scrollable(content))
.width(if app.architecture_palette_expanded {
156
} else {
36
})
.height(iced::Fill)
.padding(2)
.style(crate::style::panel)
.into()
}
@@ -0,0 +1,328 @@
use super::*;
use crate::app::architecture_authoring::{Event, kinds};
fn setup(layer: Layer) -> App {
let mut app = App::with_project(Project::empty());
app.layer = layer;
app.active_diagram = None;
let _ = app.update(Message::AddDiagram);
app.zoom = 0.75;
app.pan = iced::Vector::new(37., -19.);
app
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Architecture(event));
}
#[test]
fn architecture_creation_names_before_commit_preserves_camera_and_undoes_once() {
let mut app = setup(Layer::Oa);
let before = app.document.bundle().clone();
let model = app.project().clone();
let camera = (app.zoom, app.pan);
send(&mut app, Event::Start(Kind::Entity, None));
assert_eq!(app.project(), &model);
let context = app.route_context().unwrap();
send(
&mut app,
Event::Click(context, Position { x: 219., y: 117. }),
);
assert_eq!(app.project(), &model);
assert!(app.architecture.as_ref().unwrap().inline());
send(&mut app, Event::Name(" Response team 雨 ".into()));
send(&mut app, Event::Submit);
assert!(app.architecture.is_none());
assert_eq!((app.zoom, app.pan), camera);
let element = &app.project().elements[&app.selected];
assert_eq!(element.name, " Response team 雨 ");
assert_eq!(element.parent, None);
let node =
&app.project().diagrams[&app.active_diagram.unwrap()].nodes[&app.selected_node.unwrap()];
assert_eq!(node.position, Position { x: 219., y: 117. });
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn architecture_context_choices_are_small_and_system_is_singleton() {
let mut app = setup(Layer::Oa);
assert_eq!(kinds(&app), vec![Kind::Entity, Kind::Actor, Kind::Activity]);
app = setup(Layer::Sa);
assert_eq!(
kinds(&app),
vec![Kind::System, Kind::SystemActor, Kind::Function]
);
send(
&mut app,
Event::Start(Kind::System, Some(Position { x: 60., y: 60. })),
);
send(&mut app, Event::Name("Pump controller".into()));
send(&mut app, Event::Submit);
assert_eq!(kinds(&app), vec![Kind::SystemActor, Kind::Function]);
}
#[test]
fn architecture_name_survives_rejected_click_resize_navigation_and_escape_is_explicit() {
let mut app = setup(Layer::Oa);
let before = app.document.bundle().clone();
send(&mut app, Event::Start(Kind::Activity, None));
send(&mut app, Event::Name("Diagnose pressure".into()));
send(&mut app, Event::Start(Kind::Entity, None));
assert!(
matches!(&app.architecture.as_ref().unwrap().saved.intent, architecture_authoring::Intent::Create { name, kind: Kind::Activity, .. } if name == "Diagnose pressure")
);
send(&mut app, Event::Submit);
let mut stale = app.route_context().unwrap();
stale.revision += 1;
send(&mut app, Event::Click(stale, Position { x: 1., y: 2. }));
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::WindowResized(iced::Size::new(1100., 700.)));
assert_eq!(app.page, Page::Diagram);
assert!(app.architecture.is_some());
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn architecture_existing_inventory_is_contextual_and_placement_never_implies_allocation() {
let mut app = setup(Layer::Oa);
let entity = app
.project()
.make_element(Kind::Entity, Layer::Oa, "Response team", None)
.unwrap();
let entity_id = entity.id;
let work = app
.project()
.make_element(Kind::Activity, Layer::Oa, "Report status", None)
.unwrap();
let work_id = work.id;
let data = app
.project()
.make_element(Kind::DataType, Layer::Oa, "Pressure", None)
.unwrap();
let data_id = data.id;
assert!(app.execute(Command::Batch(vec![
Command::CreateElement(entity),
Command::CreateElement(work),
Command::CreateElement(data)
])));
send(&mut app, Event::Existing(Some(Position { x: 80., y: 80. })));
let ids: Vec<_> = architecture_authoring::candidates(&app)
.iter()
.map(|row| row.0)
.collect();
assert!(ids.contains(&entity_id) && ids.contains(&work_id));
assert!(!ids.contains(&data_id));
send(&mut app, Event::Pick(entity_id));
let semantic = app.project().elements.clone();
let before = app.document.bundle().clone();
let camera = (app.zoom, app.pan);
send(
&mut app,
Event::Existing(Some(Position { x: 90., y: 100. })),
);
assert!(
!architecture_authoring::candidates(&app)
.iter()
.any(|row| row.0 == entity_id)
);
send(&mut app, Event::ShowPlaced(true));
assert!(
architecture_authoring::candidates(&app)
.iter()
.any(|row| row.0 == entity_id && row.2)
);
send(&mut app, Event::Pick(work_id));
assert_eq!(app.project().elements, semantic);
assert!(app.project().relations.is_empty());
assert_eq!((app.zoom, app.pan), camera);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn architecture_checkpoint_retains_exact_name_point_and_fresh_binding_without_commands() {
for (name, placing) in [(" Team 雨 ", true), (" ", false)] {
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Entity, None));
send(&mut app, Event::Name(name.into()));
if placing {
send(&mut app, Event::Submit);
}
app.architecture.as_mut().unwrap().saved.point = Some(Position { x: 127.5, y: 215. });
app.architecture.as_mut().unwrap().saved.placing = placing;
let snapshot = crate::recovery::Snapshot::capture(
app.canvas_document,
&app.document,
&app.source_workspace,
app.draft.as_ref(),
&app.behavior,
None,
app.selected,
)
.unwrap()
.with_architecture(app.architecture.as_ref());
let bytes = serde_json::to_vec(&snapshot).unwrap();
let decoded: crate::recovery::Snapshot = serde_json::from_slice(&bytes).unwrap();
let accepted = app.document.bundle().clone();
let mut restored = setup(Layer::Sa);
let _ = restored.update(Message::CheckpointLoaded(Ok(Arc::new(decoded))));
assert_eq!(restored.document.bundle(), &accepted);
let draft = &restored.architecture.as_ref().unwrap().saved;
assert!(
matches!(&draft.intent, architecture_authoring::Intent::Create { name: actual, .. } if actual == name)
);
assert_eq!(draft.placing, placing);
assert_eq!(draft.point, Some(Position { x: 127.5, y: 215. }));
if placing {
let context = restored.route_context().unwrap();
send(
&mut restored,
Event::Click(context, Position { x: 20., y: 30. }),
);
assert!(restored.architecture.as_ref().unwrap().inline());
send(&mut restored, Event::Submit);
assert!(restored.architecture.is_none());
assert_eq!(restored.project().elements[&restored.selected].name, name);
} else {
send(&mut restored, Event::Submit);
assert!(restored.architecture.is_some());
assert_eq!(restored.document.bundle(), &accepted);
}
let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
value["architecture"]["base"] = json!("stale");
assert!(
serde_json::from_value::<crate::recovery::Snapshot>(value)
.unwrap()
.restore()
.is_err()
);
}
}
#[test]
fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
use iced::widget::canvas::Program;
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Actor, None));
send(&mut app, Event::Name("Control centre".into()));
send(&mut app, Event::Submit);
let bounds = iced::Rectangle {
x: 260.,
y: 180.,
width: 600.,
height: 380.,
};
let local = iced::Point::new(190., 100.);
let expected = Position {
x: (local.x - app.pan.x) / app.zoom,
y: (local.y - app.pan.y) / app.zoom,
};
let canvas = crate::diagram::DiagramCanvas {
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
editable: true,
placing: true,
connection: None,
};
let mut state = crate::diagram::State::default();
let action = canvas
.update(
&mut state,
&iced::widget::canvas::Event::Mouse(iced::mouse::Event::ButtonPressed(
iced::mouse::Button::Left,
)),
bounds,
iced::mouse::Cursor::Available(iced::Point::new(
bounds.x + local.x,
bounds.y + local.y,
)),
)
.unwrap();
let message = action
.into_inner()
.0
.expect("named placement emits a command");
let _ = app.update(message);
assert_eq!(
app.architecture.as_ref().unwrap().saved.point,
Some(expected)
);
send(&mut app, Event::Submit);
let node =
&app.project().diagrams[&app.active_diagram.unwrap()].nodes[&app.selected_node.unwrap()];
assert_eq!(node.position, expected);
}
#[test]
fn architecture_cancelled_gesture_cannot_commit_a_later_named_draft() {
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Entity, None));
send(&mut app, Event::Name("First".into()));
send(&mut app, Event::Submit);
let stale = app.route_context().unwrap();
send(&mut app, Event::Cancel);
send(&mut app, Event::Start(Kind::Actor, None));
send(&mut app, Event::Name("Second".into()));
send(&mut app, Event::Submit);
let before = app.document.bundle().clone();
send(&mut app, Event::Click(stale, Position { x: 1., y: 1. }));
assert_eq!(app.document.bundle(), &before);
assert!(app.architecture.is_some());
}
#[test]
fn basic_transition_explicit_overview_focus_wins_over_an_already_populated_architecture() {
use crate::workflow::{Event as W, Workflow};
let mut p = Project::empty();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Report status", None)
.unwrap();
let architecture = p.ensure_diagram(Layer::Sa);
p.diagrams.get_mut(&architecture).unwrap().projection = ProjectionKind::Allocation;
let mut first = Workflow::transition(&p, work).unwrap();
let Workflow::Transition(t) = &first else {
unreachable!()
};
let target = t
.plan
.changes
.iter()
.find(|c| c.source == work)
.unwrap()
.target;
let mut p = first.update(&p, W::Apply).unwrap().unwrap();
let mut overview = p.diagrams[&architecture].clone();
overview.id = Id::new_v4();
overview.alias = "chosen_overview".into();
overview.name = "Chosen system overview".into();
overview.projection = ProjectionKind::Overview;
overview.nodes.clear();
let chosen = overview.id;
p.diagrams.insert(chosen, overview);
let placement = p.place(chosen, target, Position { x: 823., y: 417. });
let mut app = App::with_project(p);
app.selected = work;
app.active_diagram = Some(architecture);
app.refresh_draft();
let _ = app.update(Message::StartTransition);
let _ = app.update(Message::Workflow(W::Destination(Choice {
id: chosen,
label: "Chosen system overview".into(),
})));
let before = app.project().diagrams.clone();
let _ = app.update(Message::Workflow(W::Apply));
assert!(app.workflow.is_none(), "{}", app.status);
assert_eq!(app.active_diagram, Some(chosen));
assert_eq!(app.selected, target);
assert_eq!(app.selected_node, Some(placement));
assert_eq!(app.project().diagrams, before);
}
+15 -4
View File
@@ -62,6 +62,7 @@ impl App {
self.selected,
)
.and_then(|snapshot| snapshot.with_workflow(self.workflow.as_ref()))
.map(|snapshot| snapshot.with_architecture(self.architecture.as_ref()))
{
Ok(snapshot) => snapshot,
Err(error) => {
@@ -186,11 +187,21 @@ impl App {
} else {
Page::Diagram
};
self.status = if self.migration.is_some() {
"Recovered source 5 as source 6 in an unsaved local copy. Draft text is preserved; workflow previews were rebuilt and semantic impact approvals need review. Choose Save as to save it."
if let Some(saved) = restored.architecture {
self.active_diagram = Some(saved.diagram);
self.layer = self.project().diagrams[&saved.diagram].layer;
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.page = Page::Diagram;
self.architecture = architecture_authoring::State::restore(self, saved).ok();
}
self.status = if let Some(report) = &self.migration {
format!(
"Recovered source {} as source {} in an unsaved local copy. Draft text is preserved; workflow previews were rebuilt and semantic impact approvals need review. Choose Save as to save it.",
report.from_schema, report.to_schema
)
} else {
"Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged."
}.into();
"Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into()
};
self.request_lint()
}
}
@@ -59,12 +59,62 @@ impl App {
.topology
.enter(context.document, context.owner);
match event {
CanvasEvent::ExpandQualified(_, endpoint) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let changed = state.expand_context(self.project(), context.owner, endpoint);
self.behavior.topology = state;
if changed {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::OpenQualified(_, endpoint) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
let resolved = oa_core::topology_context::TopologyIndex::new(self.project())
.resolve(context.owner, &endpoint);
if let Ok(resolved) = resolved {
self.behavior.topology.remember(
context.owner,
endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence),
);
self.behavior.active_owner = Some(resolved.defining_sequence);
self.select(endpoint.occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::SelectQualified(_, endpoint) => {
if !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
&& let Ok(resolved) =
oa_core::topology_context::TopologyIndex::new(self.project())
.resolve(context.owner, &endpoint)
{
let reference = endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence);
self.select(reference);
self.behavior.topology.select_context(resolved.address);
}
}
CanvasEvent::Focus(_) => {
self.behavior.topology.focused = !self.behavior.topology.focused;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
&& crate::behavior::selected_behavior(self.project(), id) == Some(context.owner)
{
self.select(id);
self.selected_node = None;
self.behavior.topology.clear_context();
}
}
CanvasEvent::Choose(_, tool) => {
@@ -234,6 +284,35 @@ mod tests {
}
}
#[test]
fn process_focus_retains_rejected_draft_and_guards_selection_until_panels_restore() {
let (mut app, f) = setup();
let original = app.document.snapshot();
let initial = app.selected;
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(
&mut app,
Event::Name(" unfinished contextual work ".into()),
);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
assert!(app.behavior.topology.focused);
let raw = app.behavior.recovery_snapshot().unwrap();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
context, f.nodes[1],
)));
assert_eq!(app.selected, initial);
form(&mut app, Event::Submit);
assert!(app.behavior.has_pending());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
assert!(!app.behavior.topology.focused);
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
}
#[test]
fn direct_process_append_empty_first_second_exchange_has_exact_source_history() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, next) = direct_tests::empty_exchange_fixture(layer);