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
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:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user