Files
ARCADIA-Studio/crates/oa-desktop/src/app/architecture_authoring.rs
T
Codex e2ecfea69b
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Checkpoint source 7 workflows and in-progress diagram authoring redesign
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.
2026-09-10 10:06:55 -07:00

684 lines
26 KiB
Rust

//! 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()
}