Integrate contextual source creation with lifecycle rendering

This commit is contained in:
2026-09-11 10:38:05 -07:00
parent 4da92ab481
commit 321897fd55
21 changed files with 4250 additions and 471 deletions
+70 -11
View File
@@ -854,8 +854,12 @@ impl App {
let scenario_selection = self
.behavior
.scenario
.created_selection(&command)
.or_else(|| self.behavior.topology.created_selection(&command));
.created_selection(self.project(), &command)
.or_else(|| {
self.behavior
.topology
.created_selection(self.project(), &command)
});
match self.document.execute(command) {
Ok(selected) => {
if let Some(created) = created {
@@ -1031,8 +1035,10 @@ impl App {
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
architecture_authoring::subscription(self),
behavior::creation::subscription(self.behavior.creation.has_pending())
.map(|()| Message::Escape),
behavior::creation::subscription(
self.behavior.creation.has_pending() || self.behavior.source_active(),
)
.map(|()| Message::Escape),
iced::event::listen_with(|event, status, _| {
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
key,
@@ -1519,6 +1525,37 @@ impl App {
return Task::none();
}
match mutation {
CanvasEvent::First(_, tool) => {
let empty = match tool {
scenario::Tool::Lifeline => !self
.project()
.children(context.scenario)
.any(|e| e.kind() == Kind::Lifeline),
scenario::Tool::Message | scenario::Tool::Action => {
self.project()
.children(context.scenario)
.any(|e| e.kind() == Kind::Lifeline)
&& !self
.project()
.children(context.scenario)
.any(|e| e.kind() == Kind::Occurrence)
}
_ => false,
};
if !empty {
self.status="This scenario already has content. Use its current authoring tools.".into();
return Task::none();
}
let mut state = std::mem::take(&mut self.behavior.scenario);
let result = state.update(
self.project(),
scenario::Event::Start(context.scenario, tool),
);
self.behavior.scenario = state;
if let Err(error) = result {
self.status = error;
}
}
CanvasEvent::BeginOccurrence {
tool,
slot,
@@ -1754,7 +1791,15 @@ impl App {
return self.update(Message::Add(child));
}
Message::Escape => {
if self.behavior.creation.has_pending() {
if self.behavior.source_back() {
self.status =
"Returned to the unchanged parent form; no source was created.".into();
return Task::none();
}
if self.behavior.source_active() {
self.behavior.acknowledge_commit();
self.status = "Behavior form cancelled; no staged source was created.".into();
} else if self.behavior.creation.has_pending() {
self.behavior.creation.acknowledge_commit();
self.status = "Behavior creation cancelled".into();
}
@@ -2321,10 +2366,13 @@ impl App {
self.status = "Apply or cancel the current draft before starting a new behavior. Your inputs are retained.".into();
return Task::none();
}
if !creating && event.starts_form() && !self.behavior.has_pending() && !self.apply()
if (event.starts_form() || (event.is_source() && !event.is_source_back()))
&& (self.source_dirty() || self.draft_dirty())
{
self.status="Apply or cancel the other editors before starting this form. Your inputs are retained.".into();
return Task::none();
}
let source_was_open = self.behavior.source_open();
if event.is_submit() && (self.source_dirty() || self.draft_dirty()) {
self.status = "Revert the other drafts before applying the behavior form. Your inputs are retained.".into();
return Task::none();
@@ -2342,6 +2390,11 @@ impl App {
}
}
Ok(None) => {
if !source_was_open && self.behavior.source_open() {
return iced::widget::operation::focus(
behavior::source_draft::NAME_INPUT,
);
}
if focus_name {
return iced::widget::operation::focus(behavior::creation::NAME_INPUT);
}
@@ -2537,13 +2590,19 @@ impl App {
Message::Undo | Message::Redo => {
let redo = matches!(message, Message::Redo);
if self.apply() {
if redo {
self.document.redo();
let changed = if redo {
self.document.redo()
} else {
self.document.undo();
self.document.undo()
};
if changed {
self.sync();
}
self.sync();
self.status = if redo {
self.status = if !changed && redo {
"Nothing to redo"
} else if !changed {
"Nothing to undo"
} else if redo {
"Model change redone"
} else {
"Model change undone"
+5
View File
@@ -214,6 +214,11 @@ impl App {
.is_some_and(architecture_authoring::State::inline)
{
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
} else if self.page == Page::Behavior && self.behavior.source_open() {
Task::batch([
lint,
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
])
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
Task::batch([
lint,
+72
View File
@@ -14,9 +14,12 @@ use iced::{
};
use oa_core::*;
use std::collections::BTreeMap;
#[cfg(test)]
mod contextual_source_tests;
pub(crate) mod creation;
pub(crate) mod scenario;
mod scenario_lifecycle;
pub(crate) mod source_draft;
pub(crate) mod state_canvas;
pub(crate) mod state_edit;
pub(crate) mod state_menu;
@@ -740,6 +743,29 @@ pub enum Event {
Submit,
}
impl Event {
pub fn is_source_back(&self) -> bool {
matches!(
self,
Self::Creation(creation::Event::Source(source_draft::Event::Edit(
_,
source_draft::Edit::Back
))) | Self::Scenario(scenario::Event::Source(source_draft::Event::Edit(
_,
source_draft::Edit::Back
))) | Self::Topology(topology::Event::Source(source_draft::Event::Edit(
_,
source_draft::Edit::Back
)))
)
}
pub fn is_source(&self) -> bool {
matches!(
self,
Self::Creation(creation::Event::Source(_))
| Self::Scenario(scenario::Event::Source(_))
| Self::Topology(topology::Event::Source(_))
)
}
pub fn starts_form(&self) -> bool {
matches!(
self,
@@ -870,6 +896,18 @@ impl State {
/// Clear the active form after its prepared command commits successfully.
/// Rejected commands must leave the form available for repair and retry.
pub fn source_active(&self) -> bool {
self.creation.source_active()
|| self.scenario.source_active()
|| self.topology.source_active()
}
pub fn source_open(&self) -> bool {
self.creation.source_open() || self.scenario.source_open() || self.topology.source_open()
}
pub fn source_back(&mut self) -> bool {
self.creation.source_back() || self.scenario.source_back() || self.topology.source_back()
}
pub fn acknowledge_commit(&mut self) {
self.creation.acknowledge_commit();
self.topology.acknowledge_commit();
@@ -917,6 +955,40 @@ pub fn view(app: &App) -> Element<'_, Message> {
if let Some(card) = app.behavior.creation.view(p) {
return card.map(|event| Message::Behavior(Event::Creation(event)));
}
if let Some(owner) = app.behavior.pending_owner() {
let kind = p.elements.get(&owner).map(oa_core::Element::kind);
let unavailable = p.layer_of(owner) != Some(app.layer)
|| (app.behavior.scenario.pending_owner().is_some() && kind != Some(Kind::Scenario))
|| (app.behavior.topology.pending_owner().is_some()
&& !matches!(kind, Some(Kind::Process | Kind::FunctionalChain)));
if unavailable {
let mut out=column![text("The pending behavior owner is unavailable").size(20),text("Your inputs are retained. Return from the source form or cancel this parent draft before choosing another behavior.").size(13)].spacing(12);
if let Some(name) = app
.behavior
.scenario
.pending_name()
.or_else(|| app.behavior.topology.pending_name())
{
out =
out.push(text(format!("Pending consumer name: {}", short(name, 80))).size(14));
}
if let Some(child) = app.behavior.scenario.source_view(p) {
out = out.push(child.map(|e| Message::Behavior(Event::Scenario(e))));
} else if let Some(child) = app.behavior.topology.source_view(p) {
out = out.push(child.map(|e| Message::Behavior(Event::Topology(e))));
} else {
let cancel = if app.behavior.scenario.pending_owner().is_some() {
Event::Scenario(scenario::Event::Cancel)
} else if app.behavior.topology.pending_owner().is_some() {
Event::Topology(topology::Event::Cancel)
} else {
Event::States(states::Event::Cancel)
};
out = out.push(button("Cancel retained draft").on_press(Message::Behavior(cancel)));
}
return container(out).padding(16).width(Fill).height(Fill).into();
}
}
let choices = p
.elements
.values()
File diff suppressed because it is too large Load Diff
+107 -6
View File
@@ -75,6 +75,7 @@ impl Picker {
}
#[derive(Debug, Clone)]
pub enum Event {
Source(super::source_draft::Event),
Open(Layer),
Choose(Family),
Name(String),
@@ -97,6 +98,11 @@ struct Browser {
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
#[serde(default)]
source: super::source_draft::State,
#[serde(default = "Id::new_v4")]
invocation: Id,
version: u32,
document: Id,
layer: Layer,
@@ -125,6 +131,17 @@ pub struct Created {
}
impl State {
pub fn source_active(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.engaged())
}
pub fn source_open(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.is_open())
}
pub fn source_back(&mut self) -> bool {
self.draft.as_mut().is_some_and(|d| d.source.back())
}
pub fn has_pending(&self) -> bool {
self.draft.is_some()
}
@@ -174,6 +191,49 @@ impl State {
.command(p)
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if let Event::Source(event) = event {
if self
.draft
.as_mut()
.ok_or("Start the consumer form first.")?
.source
.dismiss(&event)?
{
return Ok(None);
}
let d = self
.draft
.as_ref()
.ok_or("Start the consumer form first.")?;
if d.family != Some(Family::Machine) {
return Err("This reference does not support staged source creation.".into());
}
if d.document != p.id {
return Err("This behavior draft belongs to another document.".into());
}
let mut next = d.clone();
if let Some(id) = next.source.update(
p,
super::source_draft::Scope::Machine { layer: d.layer },
d.invocation,
event,
)? {
next.owner = Some(id);
let preview = next
.source
.preview(p, next.owner)?
.ok_or("New owner preview is missing.")?;
if !eligible(&preview, next.layer, Picker::Owner, id) {
return Err("This participant cannot own the machine.".into());
}
}
self.draft = Some(next);
return Ok(None);
}
if self.source_open() && !matches!(&event, Event::Cancel) {
return Err("Use the new source or go Back before editing the parent form.".into());
}
match event {
Event::Cancel => {
self.draft = None;
@@ -184,6 +244,8 @@ impl State {
return Err("Create or cancel the pending behavior first.".into());
}
self.draft = Some(Draft {
source: Default::default(),
invocation: Id::new_v4(),
version: 1,
document: p.id,
layer,
@@ -332,7 +394,7 @@ fn reference_context(p: &Project, e: &Element) -> String {
};
format!("{} · {} · {location}", e.kind(), e.alias)
}
fn reference_label(p: &Project, id: Id) -> String {
pub(super) fn reference_label(p: &Project, id: Id) -> String {
p.elements
.get(&id)
.map(|e| format!("{} · {}", e.name, reference_context(p, e)))
@@ -340,6 +402,15 @@ fn reference_label(p: &Project, id: Id) -> String {
}
impl Draft {
fn command(&self, p: &Project) -> Result<Command, String> {
self.source.check_scope(
super::source_draft::Scope::Machine { layer: self.layer },
self.owner,
)?;
let preview = self.source.preview(p, self.owner)?;
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
self.source.combine(p, self.owner, command)
}
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
if self.document != p.id {
return Err("This draft belongs to another document.".into());
}
@@ -475,6 +546,21 @@ impl State {
p: &'a Project,
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
let d = self.draft.as_ref()?;
if let Some(view) = d.source.view(p) {
return Some(
column![
text(format!(
"State machine name: {}",
crate::views::short(&d.name, 60)
))
.size(12),
view.map(Event::Source)
]
.spacing(6)
.height(Fill)
.into(),
);
}
let mut fields = column![].spacing(10);
let title = if let Some(family) = d.family {
fields = fields.push(
@@ -488,7 +574,7 @@ impl State {
Family::Sequence => {
fields = fields.push(
text(format!(
"Owned by the {} layer root. Involve existing work after creation.",
"Owned by the {} layer root. Add work and exchange contributions after creation.",
d.layer
))
.size(12),
@@ -531,7 +617,17 @@ impl State {
}
}
Family::Machine => {
if let Some(label) = d.source.label(d.owner) {
fields = fields.push(text(label).size(13)).push(text("Create and open commits the new owner and machine together, including the first view only when selected.").size(12));
}
fields = fields.push(picker_view(p, d, Picker::Owner));
fields = fields.push(
button("Create new owner…")
.on_press(Event::Source(super::source_draft::Event::Open(
d.invocation,
)))
.padding(7),
);
fields = fields.push(
checkbox(d.first_view)
.label("Create the first state view")
@@ -544,9 +640,9 @@ impl State {
} else {
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
let purpose = match family {
Family::Sequence => "A path through existing work and exchanges",
Family::Sequence => "A path through work and exchanges",
Family::Scenario => "Ordered interactions from an explicit viewpoint",
Family::Machine => "States and modes of an existing participant",
Family::Machine => "States and modes of an explicitly chosen participant",
};
fields = fields.push(
button(
@@ -598,7 +694,9 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
Picker::Owner => d.owner,
Picker::Context => d.context,
};
let staged_owner = picker == Picker::Owner && d.source.label(selected).is_some();
let title = match picker {
Picker::Owner if staged_owner => "Or choose an existing participant",
Picker::Owner => "Owned by participant",
Picker::Context => "Scenario context · optional",
};
@@ -609,14 +707,17 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
.padding(6)
]
.spacing(5);
if let Some(id) = selected {
if let Some(id) = selected
&& !staged_owner
{
out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12));
} else if picker == Picker::Context {
out = out.push(text("No context selected").size(12));
}
if options.is_empty() {
out = out.push(text(match picker {
Picker::Owner => "No matching participant. Refine the search, or cancel and create a participant in Architecture first.",
Picker::Owner if staged_owner => "No existing participants match this search. The new owner above remains selected.",
Picker::Owner => "No matching participant. Refine the search or choose Create new owner.",
Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.",
}).size(12));
}
+132 -3
View File
@@ -94,6 +94,7 @@ impl std::fmt::Display for CanvasTool {
}
#[derive(Debug, Clone)]
pub enum CanvasEvent {
First(Context, Tool),
Scrolled(Context, ScrollArea, iced::Vector),
Restored(Context, Id, ScrollArea, iced::Vector),
Viewport(Context, Size),
@@ -132,7 +133,8 @@ pub enum ScrollArea {
impl CanvasEvent {
pub fn context(&self) -> Context {
match self {
Self::Scrolled(context, _, _)
Self::First(context, _)
| Self::Scrolled(context, _, _)
| Self::Restored(context, ..)
| Self::Viewport(context, _)
| Self::Zoom(context, _)
@@ -162,6 +164,7 @@ impl std::fmt::Display for Operator {
}
#[derive(Debug, Clone)]
pub enum Event {
Source(super::source_draft::Event),
Start(Id, Tool),
Cancel,
Submit,
@@ -240,6 +243,13 @@ struct CallChoiceContext {
#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
#[serde(default)]
source: super::source_draft::State,
#[serde(default = "Id::new_v4")]
invocation: Id,
#[serde(default)]
source_origin: Option<(Id, oa_core::Element)>,
owner: Id,
parent: Id,
before: Option<Id>,
@@ -272,6 +282,28 @@ struct Draft {
choice_page: usize,
}
impl State {
pub(super) fn pending_name(&self) -> Option<&str> {
self.draft.as_ref().map(|d| d.name.as_str())
}
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
self.draft
.as_ref()?
.source
.view(p)
.map(|v| v.map(Event::Source))
}
pub fn source_active(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.engaged())
}
pub fn source_open(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.is_open())
}
pub fn source_back(&mut self) -> bool {
self.draft.as_mut().is_some_and(|d| d.source.back())
}
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
if self.presentation.identity != Some((document, owner)) {
self.presentation = workspace::Presentation {
@@ -452,8 +484,16 @@ impl State {
}
cached.as_ref().unwrap().1.clone()
}
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
let draft = self.draft.as_ref()?;
if self
.draft
.as_ref()
.is_some_and(|d| d.source.label(d.target).is_some())
&& self.prepare_pending(p).ok().as_ref() != Some(command)
{
return None;
}
fn created(command: &Command, parent: Id) -> Option<Id> {
match command {
Command::CreateElement(element) if element.parent == Some(parent) => {
@@ -568,6 +608,61 @@ impl State {
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if let Event::Source(event) = event {
if self
.draft
.as_mut()
.ok_or("Start the consumer form first.")?
.source
.dismiss(&event)?
{
return Ok(None);
}
let d = self
.draft
.as_ref()
.ok_or("Start the consumer form first.")?;
if d.tool != Tool::Lifeline {
return Err("This reference does not support staged source creation.".into());
}
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
*document != p.id || p.elements.get(&d.owner) != Some(owner)
}) {
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
}
if d.source_origin.is_none() && d.source.engaged() {
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
}
let mut next = d.clone();
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
{
next.source_origin = Some((
p.id,
p.elements
.get(&d.owner)
.ok_or("The consumer owner no longer exists.")?
.clone(),
));
}
if let Some(id) = next.source.update(
p,
super::source_draft::Scope::Lifeline {
owner: d.owner,
viewpoint: viewpoint(p, d.owner),
},
d.invocation,
event,
)? {
next.target = Some(id);
next.command(p)?;
}
self.draft = Some(next);
return Ok(None);
}
if self.source_open() && !matches!(&event, Event::Cancel) {
return Err("Use the new source or go Back before editing the parent form.".into());
}
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
return Err(PENDING_DRAFT.into());
}
@@ -577,6 +672,9 @@ impl State {
return Err("Choose a scenario.".into());
}
self.draft = Some(Draft {
source: Default::default(),
invocation: Id::new_v4(),
source_origin: Some((p.id, p.elements[&owner].clone())),
owner,
parent: owner,
before: None,
@@ -940,7 +1038,14 @@ fn valid_body_choice(p: &Project, owner: Id, body: OccurrenceBody) -> bool {
execute_batch(p, &[Command::CreateElement(e)]).is_ok()
}
fn sources(p: &Project, d: &Draft) -> Vec<Choice> {
let layer = p.elements[&d.owner].layer();
let Some(owner) = p
.elements
.get(&d.owner)
.filter(|e| e.kind() == Kind::Scenario)
else {
return vec![];
};
let layer = owner.layer();
let mut values: Vec<_> = p
.elements
.values()
@@ -1256,6 +1361,28 @@ impl Draft {
e
}
fn command(&self, p: &Project) -> Result<Command, String> {
if self.source.engaged()
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
*document != p.id || p.elements.get(&self.owner) != Some(owner)
})
{
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
}
self.source.check_scope(
super::source_draft::Scope::Lifeline {
owner: self.owner,
viewpoint: viewpoint(p, self.owner),
},
self.target,
)?;
let preview = self.source.preview(p, self.target)?;
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
self.source.combine(p, self.target, command)
}
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
if p.elements.get(&self.owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
return Err("The scenario no longer exists. Cancel or restore its owner.".into());
}
if !parents(p, self.owner).iter().any(|c| c.id == self.parent) {
return Err("Choose this scenario or one of its operands as destination.".into());
}
@@ -1266,6 +1393,8 @@ impl Draft {
.unwrap_or(self.tool.label());
let name = if self.name.trim().is_empty() {
default
} else if self.source.label(self.target).is_some() {
self.name.as_str()
} else {
self.name.trim()
};
@@ -168,6 +168,11 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
.wrap(),
text(if pending {
"Pending form · the highlighted sequence position is retained".to_owned()
} else if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) {
"Start with a lifeline representing existing or explicitly new work / participants."
.into()
} else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) {
"Choose a first message, action or another tool; no occurrence is selected yet.".into()
} else {
let guidance = match tool {
CanvasTool::Message(_) => {
@@ -186,6 +191,32 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
.size(11)
]
.spacing(6);
if !pending {
if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) {
header = header.push(
row![
text(format!("No lifelines · {:?}", viewpoint(p, owner))).size(12),
button("Add first lifeline").on_press_maybe(clean.then_some({
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Lifeline))
}))
]
.spacing(8),
);
} else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) {
header = header.push(
row![
text("No occurrences yet").size(12),
button("Add first message").on_press_maybe(clean.then_some({
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Message))
})),
button("Add first action").on_press_maybe(clean.then_some({
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Action))
}))
]
.spacing(8),
);
}
}
if limited {
header = header
.push(text("Minimum zoom reached · scroll to see the remaining scenario.").size(11));
@@ -295,272 +326,301 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
.as_ref()
.filter(|d| d.owner == owner)
{
let mut form = column![
text(d.tool.label()).size(16),
text_input("Name (defaults to selected source)", &d.name)
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12)
]
.spacing(9);
if d.tool != Tool::Lifeline {
form = form.push(label(format!(
"Insert: {}",
slot_label(
p,
Slot {
parent: d.parent,
before: d.before
}
)
)));
if d.before.is_some() {
form = form.push(
button("Append to this sequence instead").on_press(event(Event::AppendInstead)),
);
}
form = form.push(picker(
parents(p, owner),
Some(d.parent),
"Destination sequence / operand…",
Event::Parent,
));
}
if matches!(
d.tool,
Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation
) {
form = form.push(picker(
sources(p, d),
d.target,
"Choose a named source…",
Event::Target,
));
}
if d.tool == Tool::Message {
form = form.push(
pick_list(
vec![
MessageStyle::Asynchronous,
MessageStyle::Call,
MessageStyle::CallReply,
MessageStyle::Reply,
MessageStyle::Create,
MessageStyle::Delete,
MessageStyle::Timer,
],
Some(d.style),
|style| event(Event::Style(style)),
)
.width(Fill)
.padding(9)
.text_size(12),
);
if d.style == MessageStyle::Reply {
form = form.push(bounded_picker(
app.behavior.scenario.call_choices(p, context, d),
d,
d.call,
"Choose the earlier call…",
Event::Call,
if let Some(child) = d.source.view(p) {
form_panel = column![
text(format!(
"Consumer name: {}",
crate::views::short(&d.name, 60)
))
.size(12),
child.map(|e| event(Event::Source(e)))
]
.spacing(6)
.height(Fill)
.into();
} else {
let mut form = column![
text(d.tool.label()).size(16),
text_input("Name (defaults to selected source)", &d.name)
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12)
]
.spacing(9);
if d.tool != Tool::Lifeline {
form = form.push(label(format!(
"Insert: {}",
slot_label(
p,
Slot {
parent: d.parent,
before: d.before
}
)
)));
if d.before.is_some() {
form = form.push(
button("Append to this sequence instead")
.on_press(event(Event::AppendInstead)),
);
}
form = form.push(picker(
parents(p, owner),
Some(d.parent),
"Destination sequence / operand…",
Event::Parent,
));
if d.call.is_some() {
form = form.push(label(
}
if matches!(
d.tool,
Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation
) {
form = form.push(picker(
sources(p, d),
d.target,
"Choose a named source…",
Event::Target,
));
}
if d.tool == Tool::Lifeline {
if let Some(title) = d.source.label(d.target) {
form = form.push(label(title)).push(label(
"Create commits one new source and this lifeline together.",
));
}
if viewpoint(p, owner) != ScenarioViewpoint::MixedLegacy {
form = form.push(button("Create new source…").on_press(event(Event::Source(
super::super::source_draft::Event::Open(d.invocation),
))));
} else {
form=form.push(label("New source creation requires a strict viewpoint. Existing sources remain available."));
}
}
if d.tool == Tool::Message {
form = form.push(
pick_list(
vec![
MessageStyle::Asynchronous,
MessageStyle::Call,
MessageStyle::CallReply,
MessageStyle::Reply,
MessageStyle::Create,
MessageStyle::Delete,
MessageStyle::Timer,
],
Some(d.style),
|style| event(Event::Style(style)),
)
.width(Fill)
.padding(9)
.text_size(12),
);
if d.style == MessageStyle::Reply {
form = form.push(bounded_picker(
app.behavior.scenario.call_choices(p, context, d),
d,
d.call,
"Choose the earlier call…",
Event::Call,
));
if d.call.is_some() {
form = form.push(label(
"Reply reuses the call exchange in reverse and retains its correlation.",
));
}
} else if d.style != MessageStyle::Timer {
form = form.push(bounded_picker(
exchanges(p, d),
d,
d.exchange,
"Choose the exchange…",
Event::Exchange,
));
form = form.push(label(
"Apply checks that the message is valid at this insertion position.",
));
}
form = form.push(picker(
d.line_choices(p, true),
d.sender,
"Sending lifeline…",
Event::Sender,
));
if d.style != MessageStyle::Timer {
form = form.push(picker(
d.line_choices(p, false),
d.receiver,
"Receiving lifeline…",
Event::Receiver,
));
}
if d.style == MessageStyle::Timer {
form = form.push(
text_input("Duration / deadline, e.g. 30 s", &d.expression)
.on_input(|s| event(Event::Expression(s)))
.padding(9)
.size(12),
);
}
if p.layer_of(owner) == Some(Layer::Sa)
&& viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface
&& matches!(
d.style,
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
)
{
form = form.push(
iced::widget::checkbox(d.operation.is_some())
.label("Invoke a specific interface operation")
.on_toggle(|enabled| event(Event::OperationIntent(enabled))),
);
if let Some(operation) = &d.operation {
form = form
.push(bounded_picker(
crate::data_values::operation_choices(p, &d.proposed_message(p)),
d,
operation.allocation,
"Choose the exact interface operation allocation…",
Event::Operation,
))
.push(
button("Keep operation unresolved")
.on_press(event(Event::ClearOperation)),
);
if let Some(id) = operation.allocation {
form = form.push(label(crate::data_values::operation_identity(p, id)));
}
}
}
if let Some(description) =
crate::data_values::operation_label(p, &d.proposed_message(p))
{
form = form.push(label(description));
}
match d.signature(p) {
Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)),
Err(problem) => {
form = form.push(label(problem));
for (key, expression) in d.authored_values() {
form = form
.push(
text(format!(
"Retained value · {} / {}",
key.path
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(" → "),
p.named(key.field)
))
.size(11),
)
.push(
text_input("Value expression", expression)
.on_input({
let key = key.clone();
move |raw| event(Event::Value(key.clone(), raw))
})
.padding(8)
.size(12),
);
}
}
Ok(None) => {}
}
if d.style == MessageStyle::CallReply {
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
}
}
if matches!(d.tool, Tool::Action | Tool::Observation) {
form = form.push(picker(
lines(p, owner),
d.sender,
"Observed lifeline…",
Event::Sender,
));
}
if d.tool == Tool::Reuse
&& let Some(target) = d.target
{
for line in lines(p, target) {
let referenced = line.id;
let compatible: Vec<_> = lines(p, owner)
.into_iter()
.filter(|c| {
compatible_binding(p, referenced, c.id)
&& !d
.bindings
.iter()
.any(|(r, l)| *r != referenced && *l == c.id)
})
.collect();
if compatible.is_empty() {
form = form.push(label(format!(
"Add a compatible local lifeline before binding '{}'.",
p.named(referenced)
)));
}
form = form
.push(label(format!("Bind {}", p.named(referenced))))
.push(picker(
compatible,
d.bindings.get(&referenced).copied(),
"Compatible local lifeline…",
move |c| Event::Binding(referenced, c),
} else if d.style != MessageStyle::Timer {
form = form.push(bounded_picker(
exchanges(p, d),
d,
d.exchange,
"Choose the exchange…",
Event::Exchange,
));
form = form.push(label(
"Apply checks that the message is valid at this insertion position.",
));
}
form = form.push(picker(
d.line_choices(p, true),
d.sender,
"Sending lifeline…",
Event::Sender,
));
if d.style != MessageStyle::Timer {
form = form.push(picker(
d.line_choices(p, false),
d.receiver,
"Receiving lifeline…",
Event::Receiver,
));
}
if d.style == MessageStyle::Timer {
form = form.push(
text_input("Duration / deadline, e.g. 30 s", &d.expression)
.on_input(|s| event(Event::Expression(s)))
.padding(9)
.size(12),
);
}
if p.layer_of(owner) == Some(Layer::Sa)
&& viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface
&& matches!(
d.style,
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
)
{
form = form.push(
iced::widget::checkbox(d.operation.is_some())
.label("Invoke a specific interface operation")
.on_toggle(|enabled| event(Event::OperationIntent(enabled))),
);
if let Some(operation) = &d.operation {
form = form
.push(bounded_picker(
crate::data_values::operation_choices(p, &d.proposed_message(p)),
d,
operation.allocation,
"Choose the exact interface operation allocation…",
Event::Operation,
))
.push(
button("Keep operation unresolved")
.on_press(event(Event::ClearOperation)),
);
if let Some(id) = operation.allocation {
form = form.push(label(crate::data_values::operation_identity(p, id)));
}
}
}
if let Some(description) =
crate::data_values::operation_label(p, &d.proposed_message(p))
{
form = form.push(label(description));
}
match d.signature(p) {
Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)),
Err(problem) => {
form = form.push(label(problem));
for (key, expression) in d.authored_values() {
form = form
.push(
text(format!(
"Retained value · {} / {}",
key.path
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(" → "),
p.named(key.field)
))
.size(11),
)
.push(
text_input("Value expression", expression)
.on_input({
let key = key.clone();
move |raw| event(Event::Value(key.clone(), raw))
})
.padding(8)
.size(12),
);
}
}
Ok(None) => {}
}
if d.style == MessageStyle::CallReply {
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
}
}
form = form.push(label(
"Bindings are explicit, one-to-one and refer to existing lifelines.",
));
}
if d.tool == Tool::Fragment {
form = form.push(
pick_list(
vec![
Operator(FragmentOperator::Alternative),
Operator(FragmentOperator::Parallel),
Operator(FragmentOperator::Loop),
Operator(FragmentOperator::Optional),
],
Some(Operator(d.operator)),
|o| event(Event::Operator(o)),
)
.padding(9)
.text_size(12)
.width(Fill),
);
if d.operator != FragmentOperator::Parallel {
if matches!(d.tool, Tool::Action | Tool::Observation) {
form = form.push(picker(
lines(p, owner),
d.sender,
"Observed lifeline…",
Event::Sender,
));
}
if d.tool == Tool::Reuse
&& let Some(target) = d.target
{
for line in lines(p, target) {
let referenced = line.id;
let compatible: Vec<_> = lines(p, owner)
.into_iter()
.filter(|c| {
compatible_binding(p, referenced, c.id)
&& !d
.bindings
.iter()
.any(|(r, l)| *r != referenced && *l == c.id)
})
.collect();
if compatible.is_empty() {
form = form.push(label(format!(
"Add a compatible local lifeline before binding '{}'.",
p.named(referenced)
)));
}
form = form
.push(label(format!("Bind {}", p.named(referenced))))
.push(picker(
compatible,
d.bindings.get(&referenced).copied(),
"Compatible local lifeline…",
move |c| Event::Binding(referenced, c),
));
}
form = form.push(label(
"Bindings are explicit, one-to-one and refer to existing lifelines.",
));
}
if d.tool == Tool::Fragment {
form = form.push(
text_input("Condition / loop guard", &d.expression)
.on_input(|s| event(Event::Expression(s)))
.padding(9)
.size(12),
pick_list(
vec![
Operator(FragmentOperator::Alternative),
Operator(FragmentOperator::Parallel),
Operator(FragmentOperator::Loop),
Operator(FragmentOperator::Optional),
],
Some(Operator(d.operator)),
|o| event(Event::Operator(o)),
)
.padding(9)
.text_size(12)
.width(Fill),
);
if d.operator != FragmentOperator::Parallel {
form = form.push(
text_input("Condition / loop guard", &d.expression)
.on_input(|s| event(Event::Expression(s)))
.padding(9)
.size(12),
);
}
if d.operator == FragmentOperator::Alternative {
form = form.push(
text_input("Second alternative guard", &d.second_guard)
.on_input(|s| event(Event::SecondGuard(s)))
.padding(9)
.size(12),
);
}
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
}
if d.operator == FragmentOperator::Alternative {
form = form.push(
text_input("Second alternative guard", &d.second_guard)
.on_input(|s| event(Event::SecondGuard(s)))
.padding(9)
.size(12),
);
let readiness = d.command(p);
if let Err(error) = &readiness {
form = form.push(label(error.clone()));
}
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
let footer = row![
button("Create")
.padding(9)
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
action("Cancel", event(Event::Cancel))
]
.spacing(7);
form_panel = editor_panel(footer.into(), form.into());
}
let readiness = d.command(p);
if let Err(error) = &readiness {
form = form.push(label(error.clone()));
}
let footer = row![
button("Create")
.padding(9)
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
action("Cancel", event(Event::Cancel))
]
.spacing(7);
form_panel = editor_panel(footer.into(), form.into());
}
workspace_layout(header.into(), details, canvas, form_panel, pending)
}
File diff suppressed because it is too large Load Diff
+108 -2
View File
@@ -38,6 +38,7 @@ impl Tool {
}
#[derive(Debug, Clone)]
pub enum Event {
Source(super::source_draft::Event),
Start(Id, Tool, Option<Id>),
Cancel,
Submit,
@@ -90,15 +91,22 @@ pub struct State {
selected_context: Option<oa_core::topology_context::ContextAddress>,
shelf: Option<Shelf>,
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Browser {
search: String,
page: usize,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
#[serde(default)]
source: super::source_draft::State,
#[serde(default = "Id::new_v4")]
invocation: Id,
#[serde(default)]
source_origin: Option<(Id, oa_core::Element)>,
#[serde(with = "recovery_insertion")]
insertion: Insertion,
fixed: bool,
@@ -117,6 +125,28 @@ struct Draft {
contextual: Option<contextual::Draft>,
}
impl State {
pub(super) fn pending_name(&self) -> Option<&str> {
self.draft.as_ref().map(|d| d.name.as_str())
}
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
self.draft
.as_ref()?
.source
.view(p)
.map(|v| v.map(Event::Source))
}
pub fn source_active(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.engaged())
}
pub fn source_open(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.is_open())
}
pub fn source_back(&mut self) -> bool {
self.draft.as_mut().is_some_and(|d| d.source.back())
}
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
self.draft
.as_ref()
@@ -152,6 +182,58 @@ impl State {
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if let Event::Source(event) = event {
if self
.draft
.as_mut()
.ok_or("Start the consumer form first.")?
.source
.dismiss(&event)?
{
return Ok(None);
}
let d = self
.draft
.as_ref()
.ok_or("Start the consumer form first.")?;
if d.tool != Tool::Work {
return Err("This reference does not support staged source creation.".into());
}
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
*document != p.id || p.elements.get(&d.owner) != Some(owner)
}) {
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
}
if d.source_origin.is_none() && d.source.engaged() {
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
}
let mut next = d.clone();
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
{
next.source_origin = Some((
p.id,
p.elements
.get(&d.owner)
.ok_or("The consumer owner no longer exists.")?
.clone(),
));
}
if let Some(id) = next.source.update(
p,
super::source_draft::Scope::Work { owner: d.owner },
d.invocation,
event,
)? {
next.target = Some(id);
next.command(p)?;
}
self.draft = Some(next);
return Ok(None);
}
if self.source_open() && !matches!(&event, Event::Cancel) {
return Err("Use the new source or go Back before editing the parent form.".into());
}
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
return Err(PENDING_DRAFT.into());
}
@@ -184,6 +266,9 @@ impl State {
}
});
self.draft = Some(Draft {
source: Default::default(),
invocation: Id::new_v4(),
source_origin: Some((p.id, p.elements[&owner].clone())),
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
@@ -508,6 +593,22 @@ fn link(p: &Project, source: Id, target: Id, guard: &str) -> Command {
}
impl Draft {
fn command(&self, p: &Project) -> Result<Command, String> {
if self.source.engaged()
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
*document != p.id || p.elements.get(&self.owner) != Some(owner)
})
{
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
}
self.source.check_scope(
super::source_draft::Scope::Work { owner: self.owner },
self.target,
)?;
let preview = self.source.preview(p, self.target)?;
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
self.source.combine(p, self.target, command)
}
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
let owner = p
.elements
.get(&self.owner)
@@ -615,6 +716,8 @@ impl Draft {
});
let name = if self.name.trim().is_empty() {
default_name
} else if self.source.label(self.target).is_some() {
self.name.as_str()
} else {
self.name.trim()
};
@@ -1587,6 +1690,9 @@ mod tests {
}
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
Draft {
source: Default::default(),
invocation: Id::new_v4(),
source_origin: None,
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
@@ -33,7 +33,7 @@ pub enum Event {
Search(String),
Page(usize),
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Draft {
edit: Option<Id>,
@@ -206,8 +206,16 @@ impl State {
}
None
}
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
let owner = self.pending_owner()?;
if self
.draft
.as_ref()
.is_some_and(|d| d.source.label(d.target).is_some())
&& self.prepare_pending(p).ok().as_ref() != Some(command)
{
return None;
}
if let Command::CreateRelation(r) | Command::EditRelation(_, r) = command
&& matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller == owner)
{
+225 -184
View File
@@ -369,6 +369,25 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
.width(Fill)
.height(Fill)
.style(style::card);
if !pending && !p.children(owner).any(|e| is_contribution(p, owner, e.id)) {
let title = if p.layer_of(owner) == Some(Layer::Oa) {
"Add first activity contribution"
} else {
"Add first function contribution"
};
header = header.push(button(title).on_press_maybe(
(!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty()).then(
|| {
Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: Tool::Work,
control: ControlKind::Choice,
insertion: Insertion::default(),
})
},
),
));
}
let details: Element<'_, Message> = match state.shelf {
Some(Shelf::Tools) => controls.into(),
_ => navigation.push(text("Full qualified identities and definition actions appear here for the selected contribution or link.").size(11)).into(),
@@ -383,201 +402,223 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
};
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
if let Some(contextual) = &d.contextual {
form = form
.push(
text_input("Link name (retain current when blank)", &d.name)
.on_input(|v| event(Event::Name(v)))
.padding(8)
.size(12),
)
.push(super::contextual::view(p, owner, contextual));
if let Some(child) = d.source.view(p) {
form_panel = column![
text(format!(
"Consumer name: {}",
crate::views::short(&d.name, 60)
))
.size(12),
child.map(|e| event(Event::Source(e)))
]
.spacing(6)
.height(Fill)
.into();
} else {
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
guard.as_str()
} else {
""
};
form = form.push(label(format!(
"Insert on {} → {} · preserve path identity and condition [{}]",
p.named(link.source()),
p.named(link.target()),
guard
)));
} else if d.fixed && ordered {
form = form.push(label(match (d.before, d.after) {
(Some(before), Some(after)) => format!(
"Insert between {} → {} · preserve the ordered path",
p.named(before),
p.named(after)
),
(Some(before), None) => format!("Continue after {}", p.named(before)),
(None, Some(after)) => format!("Insert before {}", p.named(after)),
_ => "Start the ordered path".into(),
}));
} else if d.fixed
&& d.tool == Tool::Exchange
&& let Some(before) = d.before
{
form = form.push(label(format!(
"Append after {} → new exchange · Create adds an explicit sequence link",
p.named(before)
)));
} else if d.fixed && d.tool != Tool::Connect {
form = form.push(label(
d.insertion
.before
.map_or("Append an unconnected contribution".into(), |id| {
format!("Unconnected contribution before {}", p.named(id))
}),
));
}
if !legacy_order(p, owner).is_empty() {
form = form.push(label(
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
if let Some(contextual) = &d.contextual {
form = form
.push(
text_input("Link name (retain current when blank)", &d.name)
.on_input(|v| event(Event::Name(v)))
.padding(8)
.size(12),
)
.push(super::contextual::view(p, owner, contextual));
} else {
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
guard.as_str()
} else {
""
};
form = form.push(label(format!(
"Insert on {} → {} · preserve path identity and condition [{}]",
p.named(link.source()),
p.named(link.target()),
guard
)));
} else if d.fixed && ordered {
form = form.push(label(match (d.before, d.after) {
(Some(before), Some(after)) => format!(
"Insert between {} → {} · preserve the ordered path",
p.named(before),
p.named(after)
),
(Some(before), None) => format!("Continue after {}", p.named(before)),
(None, Some(after)) => format!("Insert before {}", p.named(after)),
_ => "Start the ordered path".into(),
}));
} else if d.fixed
&& d.tool == Tool::Exchange
&& let Some(before) = d.before
{
form = form.push(label(format!(
"Append after {} → new exchange · Create adds an explicit sequence link",
p.named(before)
)));
} else if d.fixed && d.tool != Tool::Connect {
form = form.push(label(
d.insertion
.before
.map_or("Append an unconnected contribution".into(), |id| {
format!("Unconnected contribution before {}", p.named(id))
}),
));
}
if d.tool == Tool::Work {
if let Some(title) = d.source.label(d.target) {
form = form.push(label(title)).push(label("Create commits the new work and contribution together at the chosen path position."));
}
form = form.push(button("Create new source…").on_press(event(Event::Source(
super::super::source_draft::Event::Open(d.invocation),
))));
}
if !legacy_order(p, owner).is_empty() {
form = form.push(label(
"This edit preserves the ordered path as explicit links in the same undo step.",
));
}
if matches!(
d.tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
) {
let choices = targets(p, owner, d.tool)
.into_iter()
.filter(|c| compatible_source(p, d, c.id))
.collect();
if d.tool == Tool::Branch {
form = form.push(label("First path work"));
}
form = form.push(picker(
choices,
d,
0,
d.target,
if matches!(
d.tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
) {
let choices = targets(p, owner, d.tool)
.into_iter()
.filter(|c| compatible_source(p, d, c.id))
.collect();
if d.tool == Tool::Branch {
"Choose first path work…"
form = form.push(label("First path work"));
}
form = form.push(picker(
choices,
d,
0,
d.target,
if d.tool == Tool::Branch {
"Choose first path work…"
} else {
"Choose existing source…"
},
Event::Target,
));
}
if d.tool != Tool::Connect {
form = form.push(
text_input("Contribution name (defaults to source)", &d.name)
.on_input(|v| event(Event::Name(v)))
.padding(8)
.size(12),
);
}
if matches!(d.tool, Tool::Control | Tool::Branch) {
let kinds = if d.tool == Tool::Branch {
vec![ControlKind::Choice, ControlKind::Fork]
} else {
"Choose existing source…"
},
Event::Target,
));
}
if d.tool != Tool::Connect {
form = form.push(
text_input("Contribution name (defaults to source)", &d.name)
.on_input(|v| event(Event::Name(v)))
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop,
]
};
form = form.push(
pick_list(
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
Some(ControlChoice(d.control)),
|v| event(Event::Control(v)),
)
.padding(8)
.text_size(12),
);
}
if d.tool == Tool::Branch {
form = form.push(label("Second path work")).push(picker(
targets(p, owner, Tool::Work),
d,
1,
d.alternate,
"Second path work…",
Event::Alternate,
));
if d.control != ControlKind::Fork {
form = form
.push(label("First path guard"))
.push(
text_input("First path guard", &d.guard)
.on_input(|v| event(Event::Guard(v)))
.padding(8),
)
.push(label("Second path guard"))
.push(
text_input("Second path guard (e.g. else)", &d.alternate_guard)
.on_input(|v| event(Event::AlternateGuard(v)))
.padding(8),
);
}
}
if d.fixed {
if let (Some(a), Some(b)) = (d.before, d.after) {
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
}
} else {
form = form
.push(label("Preceding contribution"))
.push(picker(
contributions(p, owner),
d,
2,
d.before,
"After contribution…",
Event::Before,
))
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
.push(label("Following contribution"))
.push(picker(
contributions(p, owner),
d,
3,
d.after,
"Before contribution…",
Event::After,
))
.push(button("Clear following").on_press(event(Event::ClearAfter)));
}
if d.tool != Tool::Branch
&& d.insertion.link.is_none()
&& (d.before.is_some() || d.tool == Tool::Connect)
{
form = form.push(
text_input(
"Path condition (empty remains a Choice / Loop warning)",
&d.guard,
)
.on_input(|v| event(Event::Guard(v)))
.padding(8)
.size(12),
);
);
}
}
if matches!(d.tool, Tool::Control | Tool::Branch) {
let kinds = if d.tool == Tool::Branch {
vec![ControlKind::Choice, ControlKind::Fork]
let valid = match d.command(p) {
Ok(_) => true,
Err(problem) => {
form = form.push(label(problem));
false
}
};
let actions = row![
button(if d.contextual.is_some() {
"Apply link"
} else {
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop,
]
};
form = form.push(
pick_list(
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
Some(ControlChoice(d.control)),
|v| event(Event::Control(v)),
)
.padding(8)
.text_size(12),
);
}
if d.tool == Tool::Branch {
form = form.push(label("Second path work")).push(picker(
targets(p, owner, Tool::Work),
d,
1,
d.alternate,
"Second path work…",
Event::Alternate,
));
if d.control != ControlKind::Fork {
form = form
.push(label("First path guard"))
.push(
text_input("First path guard", &d.guard)
.on_input(|v| event(Event::Guard(v)))
.padding(8),
)
.push(label("Second path guard"))
.push(
text_input("Second path guard (e.g. else)", &d.alternate_guard)
.on_input(|v| event(Event::AlternateGuard(v)))
.padding(8),
);
}
}
if d.fixed {
if let (Some(a), Some(b)) = (d.before, d.after) {
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
}
} else {
form = form
.push(label("Preceding contribution"))
.push(picker(
contributions(p, owner),
d,
2,
d.before,
"After contribution…",
Event::Before,
))
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
.push(label("Following contribution"))
.push(picker(
contributions(p, owner),
d,
3,
d.after,
"Before contribution…",
Event::After,
))
.push(button("Clear following").on_press(event(Event::ClearAfter)));
}
if d.tool != Tool::Branch
&& d.insertion.link.is_none()
&& (d.before.is_some() || d.tool == Tool::Connect)
{
form = form.push(
text_input(
"Path condition (empty remains a Choice / Loop warning)",
&d.guard,
)
.on_input(|v| event(Event::Guard(v)))
.padding(8)
.size(12),
);
}
"Create"
})
.on_press_maybe(valid.then(|| event(Event::Submit))),
button("Cancel").on_press(event(Event::Cancel))
]
.spacing(7);
form_panel = editor_panel(actions.into(), form.into());
}
let valid = match d.command(p) {
Ok(_) => true,
Err(problem) => {
form = form.push(label(problem));
false
}
};
let actions = row![
button(if d.contextual.is_some() {
"Apply link"
} else {
"Create"
})
.on_press_maybe(valid.then(|| event(Event::Submit))),
button("Cancel").on_press(event(Event::Cancel))
]
.spacing(7);
form_panel = editor_panel(actions.into(), form.into());
}
panes(diagram, form_panel, pending)
}
+6 -5
View File
@@ -445,9 +445,9 @@ fn article(topic: Topic) -> Article {
walkthrough(
"Describe a path through exchanges",
&[
"First create the activities/functions and their exchanges. Check the source and target work for every exchange.",
"A blank path offers Add first activity/function contribution. You can use existing work or stage a new source in that form; exchanges still need explicit compatible source and target work.",
"Open Behavior & scenarios → New. Choose Operational process in OA or Functional chain in SA, enter its exact name, then Create and open. The new behavior belongs at the layer root and opens immediately; Undo removes the creation as one edit.",
"Open Tools and choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
"Open Tools and choose Work or Exchange, then a highlighted position. In Work, Create new source keeps the contribution name and position while you name its Activity/Function and choose its semantic parent. Use this new source stages it; Create commits source and contribution in one Undo. An adjacent exchange may require a particular existing work identity.",
"In Tools, choose Sequence link and click or drag from the preceding contribution to the following contribution. Choose Tools → Select / reorder to drag a selected path endpoint and reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.",
"Select a contribution, then add another after it. Tools → Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions. Fit overview uses the visible canvas area; 100% keeps working scale with scrollbars. If the minimum zoom is reached, scroll to inspect the remainder.",
"Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.",
@@ -490,7 +490,7 @@ fn article(topic: Topic) -> Article {
"Create a scenario",
&[
"Open Behavior & scenarios → New → Scenario and enter its name. Explicitly choose Activities or Participants in OA, or Functions or System boundary in SA. Add optional context only when needed: this is a reference, while the scenario itself belongs at the layer root. Create and open starts the empty scenario.",
"Use + Lifeline and choose the participant or work it represents. Choices follow the scenario's viewpoint: operational activities, operational participants, system functions or system boundary participants.",
"Use Add first lifeline on an empty scenario, or + Lifeline. Choose existing work/participant or Create new source. The explicit viewpoint limits its kinds; choose participant kinds deliberately. Source name and lifeline name are separate, and the source semantic parent is independent of the scenario. No allocation or capability involvement is inferred.",
"Choose a message style in the tool selector. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. Complete the exchange and details in the form, which retains that sequence position. Escape cancels the gesture.",
"Call + correlated reply creates both occurrences in one edit. Reply to existing call offers valid unanswered calls. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
"Choose Add action, Observe state, Reuse scenario or Add fragment and click the intended sequence position. Clicking an action or observation on a lifeline also selects that lifeline. Complete any remaining choices before Create.",
@@ -551,7 +551,8 @@ fn article(topic: Topic) -> Article {
walkthrough(
"Build a state machine",
&[
"In Behavior & scenarios → New → State machine, enter its name and search for the existing same-layer participant that owns it. Candidate labels show parent context and alias to distinguish repeated names. If the participant does not exist yet, cancel and create it in Architecture first.",
"In Behavior & scenarios → New → State machine, enter its name and search for the existing same-layer participant that owns it. Candidate labels show parent context and alias to distinguish repeated names.",
"If the owner does not exist, Create new owner retains the machine name and first-view option. Choose its participant kind and semantic parent explicitly. Use this new source returns without changing the model; Create and open commits owner, machine and optional view together.",
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.",
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
@@ -909,7 +910,7 @@ fn article(topic: Topic) -> Article {
),
explain(
"A new behavior is still a draft",
"Behavior → New keeps the exact name, owner and viewpoint choices outside the model until Create and open succeeds. Cancel or Escape discards only this draft; if a dialog is open, the first Escape dismisses that dialog. New refuses other unfinished property, source or behavior drafts without applying them. Recovery restores the creation form in its original layer, including rejected raw text, for correction or cancellation.",
"Inside a source subform, Back or the first Escape returns to the unchanged parent. Use this new source stages the prerequisite only. Canceling the parent discards it; choosing an existing reference excludes the staged source from creation. Recovery retains both forms and their reserved identities. An open dialog still receives Escape first. Behavior → New keeps the exact name, owner and viewpoint choices outside the model until Create and open succeeds. Cancel or Escape discards only this draft; if a dialog is open, the first Escape dismisses that dialog. New refuses other unfinished property, source or behavior drafts without applying them. Recovery restores the creation form in its original layer, including rejected raw text, for correction or cancellation.",
),
explain(
"Aliases and references",