767 lines
29 KiB
Rust
767 lines
29 KiB
Rust
//! Local behavior creation. A draft is not a model object: only its validated
|
|
//! batch may cross Document::execute, and acknowledgement follows success.
|
|
use iced::{
|
|
Element as UiElement, Fill,
|
|
widget::{button, checkbox, column, container, row, scrollable, text, text_input},
|
|
};
|
|
use oa_core::*;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
const PAGE_SIZE: usize = 6;
|
|
pub const NAME_INPUT: &str = "behavior-creation-name";
|
|
|
|
// A focused text input consumes Escape. Preserve the application's modal
|
|
// priority by emitting its ordinary Escape message from this narrow bridge.
|
|
pub fn subscription(active: bool) -> iced::Subscription<()> {
|
|
if active {
|
|
iced::event::listen_with(captured_escape)
|
|
} else {
|
|
iced::Subscription::none()
|
|
}
|
|
}
|
|
|
|
fn captured_escape(
|
|
event: iced::Event,
|
|
status: iced::event::Status,
|
|
_: iced::window::Id,
|
|
) -> Option<()> {
|
|
(status == iced::event::Status::Captured
|
|
&& matches!(
|
|
event,
|
|
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
|
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
|
..
|
|
})
|
|
))
|
|
.then_some(())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Family {
|
|
Sequence,
|
|
Scenario,
|
|
Machine,
|
|
}
|
|
impl Family {
|
|
fn kind(self, layer: Layer) -> Kind {
|
|
match (self, layer) {
|
|
(Self::Sequence, Layer::Oa) => Kind::Process,
|
|
(Self::Sequence, Layer::Sa) => Kind::FunctionalChain,
|
|
(Self::Scenario, _) => Kind::Scenario,
|
|
(Self::Machine, _) => Kind::StateMachine,
|
|
}
|
|
}
|
|
fn title(self, layer: Layer) -> &'static str {
|
|
match (self, layer) {
|
|
(Self::Sequence, Layer::Oa) => "Operational process",
|
|
(Self::Sequence, Layer::Sa) => "Functional chain",
|
|
(Self::Scenario, _) => "Scenario",
|
|
(Self::Machine, _) => "State machine",
|
|
}
|
|
}
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Picker {
|
|
Owner,
|
|
Context,
|
|
}
|
|
impl Picker {
|
|
fn index(self) -> usize {
|
|
match self {
|
|
Self::Owner => 0,
|
|
Self::Context => 1,
|
|
}
|
|
}
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub enum Event {
|
|
Source(super::source_draft::Event),
|
|
Open(Layer),
|
|
Choose(Family),
|
|
Name(String),
|
|
Owner(Id),
|
|
Viewpoint(ScenarioViewpoint),
|
|
Context(Option<Id>),
|
|
ContextOptions(bool),
|
|
FirstView(bool),
|
|
Search(Picker, String),
|
|
Page(Picker, usize),
|
|
Cancel,
|
|
Submit,
|
|
}
|
|
#[derive(Clone, Default, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct Browser {
|
|
query: String,
|
|
page: usize,
|
|
}
|
|
#[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,
|
|
family: Option<Family>,
|
|
name: String,
|
|
owner: Option<Id>,
|
|
viewpoint: Option<ScenarioViewpoint>,
|
|
context: Option<Id>,
|
|
#[serde(default)]
|
|
context_open: bool,
|
|
first_view: bool,
|
|
element: Id,
|
|
diagram: Id,
|
|
placement: Id,
|
|
browsers: [Browser; 2],
|
|
}
|
|
#[derive(Default)]
|
|
pub struct State {
|
|
draft: Option<Draft>,
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Created {
|
|
pub behavior: Id,
|
|
pub layer: Layer,
|
|
pub diagram: Option<Id>,
|
|
}
|
|
|
|
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()
|
|
}
|
|
pub fn pending_layer(&self) -> Option<Layer> {
|
|
self.draft.as_ref().map(|d| d.layer)
|
|
}
|
|
/// Capture this before successful acknowledgement; never select these IDs
|
|
/// on mere planning or a rejected source/model transaction.
|
|
pub fn created(&self) -> Option<Created> {
|
|
let d = self.draft.as_ref()?;
|
|
d.family.map(|family| Created {
|
|
behavior: d.element,
|
|
layer: d.layer,
|
|
diagram: (family == Family::Machine && d.first_view).then_some(d.diagram),
|
|
})
|
|
}
|
|
/// Match before execution, then apply this target only after success.
|
|
/// Identity alone is insufficient: changed bodies or trailing operations
|
|
/// must not inherit the prepared creation's selection/opening contract.
|
|
pub fn created_by(&self, p: &Project, command: &Command) -> Option<Created> {
|
|
let target = self.created()?;
|
|
(self.prepare_pending(p).ok().as_ref() == Some(command)).then_some(target)
|
|
}
|
|
pub fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
|
self.draft
|
|
.as_ref()
|
|
.map(serde_json::to_value)
|
|
.transpose()
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
pub fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
|
|
let draft: Draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
|
if draft.version != 1 {
|
|
return Err("Unsupported behavior creation draft version.".into());
|
|
}
|
|
// Raw values and stale references remain editable; planning validates
|
|
// against the current document rather than silently fixing the draft.
|
|
Ok(Self { draft: Some(draft) })
|
|
}
|
|
pub fn acknowledge_commit(&mut self) {
|
|
self.draft = None;
|
|
}
|
|
pub fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
|
|
self.draft
|
|
.as_ref()
|
|
.ok_or("Choose New to start a behavior.")?
|
|
.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;
|
|
return Ok(None);
|
|
}
|
|
Event::Open(layer) => {
|
|
if self.has_pending() {
|
|
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,
|
|
family: None,
|
|
name: String::new(),
|
|
owner: None,
|
|
viewpoint: None,
|
|
context: None,
|
|
context_open: false,
|
|
first_view: false,
|
|
element: Id::new_v4(),
|
|
diagram: Id::new_v4(),
|
|
placement: Id::new_v4(),
|
|
browsers: Default::default(),
|
|
});
|
|
return Ok(None);
|
|
}
|
|
Event::Submit => return self.prepare_pending(p).map(Some),
|
|
_ => {}
|
|
}
|
|
let d = self
|
|
.draft
|
|
.as_mut()
|
|
.ok_or("Choose New to start a behavior.")?;
|
|
if d.document != p.id {
|
|
return Err("This draft belongs to another document. Cancel it to start here.".into());
|
|
}
|
|
match event {
|
|
Event::Choose(family) => {
|
|
if d.family.is_some_and(|old| old != family) {
|
|
return Err(
|
|
"Cancel this draft before choosing a different behavior family.".into(),
|
|
);
|
|
}
|
|
d.family = Some(family);
|
|
}
|
|
Event::Name(name) => d.name = name,
|
|
Event::Owner(id) if d.family == Some(Family::Machine) => {
|
|
if !eligible(p, d.layer, Picker::Owner, id) {
|
|
return Err("Choose an existing participant in this layer.".into());
|
|
}
|
|
d.owner = Some(id);
|
|
}
|
|
Event::Viewpoint(viewpoint) if d.family == Some(Family::Scenario) => {
|
|
if !viewpoints(d.layer).contains(&viewpoint) {
|
|
return Err("Choose a scenario viewpoint from this layer.".into());
|
|
}
|
|
d.viewpoint = Some(viewpoint);
|
|
}
|
|
Event::Context(id) if d.family == Some(Family::Scenario) => {
|
|
if id.is_some_and(|id| !eligible(p, d.layer, Picker::Context, id)) {
|
|
return Err("Choose an existing same-layer scenario context.".into());
|
|
}
|
|
d.context = id;
|
|
}
|
|
Event::ContextOptions(open) if d.family == Some(Family::Scenario) => {
|
|
d.context_open = open;
|
|
}
|
|
Event::FirstView(value) if d.family == Some(Family::Machine) => d.first_view = value,
|
|
Event::Search(picker, query) => d.browsers[picker.index()] = Browser { query, page: 0 },
|
|
Event::Page(picker, page) => d.browsers[picker.index()].page = page,
|
|
Event::Cancel | Event::Open(_) | Event::Submit => unreachable!(),
|
|
_ => return Err("That field does not apply to this behavior family.".into()),
|
|
}
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
fn viewpoints(layer: Layer) -> [ScenarioViewpoint; 2] {
|
|
match layer {
|
|
Layer::Oa => [
|
|
ScenarioViewpoint::OperationalActivity,
|
|
ScenarioViewpoint::ParticipantInteraction,
|
|
],
|
|
Layer::Sa => [
|
|
ScenarioViewpoint::Functional,
|
|
ScenarioViewpoint::BoundaryInterface,
|
|
],
|
|
}
|
|
}
|
|
fn viewpoint_title(viewpoint: ScenarioViewpoint) -> &'static str {
|
|
match viewpoint {
|
|
ScenarioViewpoint::OperationalActivity => "Activities · operational exchanges",
|
|
ScenarioViewpoint::ParticipantInteraction => "Participants · interactions",
|
|
ScenarioViewpoint::Functional => "Functions · functional exchanges",
|
|
ScenarioViewpoint::BoundaryInterface => "System boundary · interface exchanges",
|
|
ScenarioViewpoint::MixedLegacy => "Legacy mixed viewpoint",
|
|
}
|
|
}
|
|
fn eligible(p: &Project, layer: Layer, picker: Picker, id: Id) -> bool {
|
|
p.elements.get(&id).is_some_and(|e| {
|
|
e.layer() == layer
|
|
&& match picker {
|
|
Picker::Owner => validation::parent_permits(e.kind(), Kind::StateMachine),
|
|
Picker::Context => {
|
|
e.kind().is_sequence()
|
|
|| e.kind().is_work()
|
|
|| e.kind().is_performer()
|
|
|| matches!(
|
|
e.kind(),
|
|
Kind::Capability
|
|
| Kind::SystemCapability
|
|
| Kind::OperationalMission
|
|
| Kind::Mission
|
|
)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
fn choices<'a>(p: &'a Project, layer: Layer, picker: Picker, query: &str) -> Vec<&'a Element> {
|
|
let query = query.trim().to_lowercase();
|
|
let mut matches: Vec<_> = p
|
|
.elements
|
|
.values()
|
|
.filter(|e| eligible(p, layer, picker, e.id))
|
|
.filter(|e| query.is_empty() || reference_label(p, e.id).to_lowercase().contains(&query))
|
|
.collect();
|
|
matches.sort_by(|a, b| {
|
|
a.name
|
|
.to_lowercase()
|
|
.cmp(&b.name.to_lowercase())
|
|
.then(a.id.cmp(&b.id))
|
|
});
|
|
matches
|
|
}
|
|
|
|
fn reference_context(p: &Project, e: &Element) -> String {
|
|
let mut names = Vec::new();
|
|
let mut parent = e.parent;
|
|
let mut seen = std::collections::BTreeSet::new();
|
|
while let Some(id) = parent {
|
|
if !seen.insert(id) {
|
|
break;
|
|
}
|
|
let Some(ancestor) = p.elements.get(&id) else {
|
|
break;
|
|
};
|
|
names.push(ancestor.name.as_str());
|
|
parent = ancestor.parent;
|
|
}
|
|
names.reverse();
|
|
let location = if names.is_empty() {
|
|
format!("{} root", e.layer())
|
|
} else {
|
|
format!("{} / {}", e.layer(), names.join(" / "))
|
|
};
|
|
format!("{} · {} · {location}", e.kind(), e.alias)
|
|
}
|
|
pub(super) fn reference_label(p: &Project, id: Id) -> String {
|
|
p.elements
|
|
.get(&id)
|
|
.map(|e| format!("{} · {}", e.name, reference_context(p, e)))
|
|
.unwrap_or_else(|| "Missing element — choose a replacement".into())
|
|
}
|
|
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());
|
|
}
|
|
let family = self
|
|
.family
|
|
.ok_or("Choose the behavior you want to create.")?;
|
|
if self.name.trim().is_empty() {
|
|
return Err("Enter the behavior's name before creating it.".into());
|
|
}
|
|
if [self.element, self.diagram, self.placement]
|
|
.into_iter()
|
|
.any(|id| p.contains(id))
|
|
|| self.element == self.diagram
|
|
|| self.element == self.placement
|
|
|| self.diagram == self.placement
|
|
{
|
|
return Err(
|
|
"A reserved creation identity is already in use. Cancel and start a fresh draft."
|
|
.into(),
|
|
);
|
|
}
|
|
let kind = family.kind(self.layer);
|
|
let parent = if family == Family::Machine {
|
|
Some(
|
|
self.owner
|
|
.filter(|id| eligible(p, self.layer, Picker::Owner, *id))
|
|
.ok_or("Choose the participant that owns this state machine.")?,
|
|
)
|
|
} else {
|
|
if self.owner.is_some() {
|
|
return Err("Processes and scenarios belong at the layer root; context is a separate reference.".into());
|
|
}
|
|
None
|
|
};
|
|
if family != Family::Machine && self.first_view {
|
|
return Err("A state view can only accompany a state machine.".into());
|
|
}
|
|
let mut body = ElementBody::new(kind, self.layer)
|
|
.ok_or("This behavior is unavailable in this layer.")?;
|
|
if family == Family::Scenario {
|
|
let viewpoint = self
|
|
.viewpoint
|
|
.filter(|v| viewpoints(self.layer).contains(v))
|
|
.ok_or("Choose the scenario's viewpoint explicitly.")?;
|
|
if self
|
|
.context
|
|
.is_some_and(|id| !eligible(p, self.layer, Picker::Context, id))
|
|
{
|
|
return Err(
|
|
"The selected scenario context is missing or no longer compatible.".into(),
|
|
);
|
|
}
|
|
body = ElementBody::shared_in(
|
|
self.layer,
|
|
SharedBody::Scenario {
|
|
viewpoint,
|
|
context: self.context,
|
|
precondition: String::new(),
|
|
postcondition: String::new(),
|
|
steps: Vec::new(),
|
|
},
|
|
);
|
|
} else if self.viewpoint.is_some() || self.context.is_some() {
|
|
return Err(
|
|
"Scenario viewpoint and context cannot be applied to another behavior family."
|
|
.into(),
|
|
);
|
|
}
|
|
let element = Element {
|
|
id: self.element,
|
|
alias: p.fresh_alias(&self.name, "behavior", self.element),
|
|
name: self.name.clone(),
|
|
description: String::new(),
|
|
parent,
|
|
body,
|
|
};
|
|
let mut commands = vec![Command::CreateElement(element)];
|
|
if self.first_view {
|
|
let working = execute_batch(p, &commands).map_err(|e| e.to_string())?;
|
|
// Reuse the state editor's geometry contract. A new machine has one
|
|
// root placement; reserve both presentation IDs across retries.
|
|
let plan = super::state_edit::create_view(&working, self.element)
|
|
.map_err(|e| e.to_string())?;
|
|
let Command::Batch(mut view_commands) = plan.command else {
|
|
return Err("Unexpected state-view plan.".into());
|
|
};
|
|
if view_commands.len() != 2 {
|
|
return Err("Unexpected additional operation in the first state-view plan.".into());
|
|
}
|
|
let Some(Command::Layout(layout)) = view_commands.pop() else {
|
|
return Err("State-view plan must finish with its layout operation.".into());
|
|
};
|
|
let Some(Command::CreateDiagram(mut diagram)) = view_commands.pop() else {
|
|
return Err("State-view plan is missing its diagram.".into());
|
|
};
|
|
if diagram.id != layout || diagram.nodes.len() != 1 {
|
|
return Err("A first state view must contain only its new machine.".into());
|
|
}
|
|
let mut node = diagram
|
|
.nodes
|
|
.into_values()
|
|
.next()
|
|
.ok_or("State-view root is missing.")?;
|
|
if node.element != self.element {
|
|
return Err("State-view root differs from the new machine.".into());
|
|
}
|
|
diagram.id = self.diagram;
|
|
diagram.alias = working.fresh_alias(&diagram.name, "state_view", self.diagram);
|
|
node.id = self.placement;
|
|
node.alias = working.fresh_alias(
|
|
&format!("{} placement", self.name),
|
|
"placement",
|
|
self.placement,
|
|
);
|
|
diagram.nodes = [(node.id, node)].into_iter().collect();
|
|
commands.push(Command::CreateDiagram(diagram));
|
|
commands.push(Command::Layout(self.diagram));
|
|
}
|
|
let command = Command::Batch(commands);
|
|
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
|
Ok(command)
|
|
}
|
|
}
|
|
|
|
impl State {
|
|
/// Parent maps these local events into the existing guarded Behavior route.
|
|
/// The card's fields scroll; Create/Cancel stay outside that scroll region.
|
|
pub fn view<'a>(&'a self, p: &'a Project) -> Option<UiElement<'a, Event>> {
|
|
self.view_with(p)
|
|
}
|
|
fn view_with<'a, R: iced::advanced::text::Renderer + 'a>(
|
|
&'a self,
|
|
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(
|
|
text_input("Exact behavior name", &d.name)
|
|
.id(NAME_INPUT)
|
|
.on_input(Event::Name)
|
|
.on_submit(Event::Submit)
|
|
.padding(8),
|
|
);
|
|
match family {
|
|
Family::Sequence => {
|
|
fields = fields.push(
|
|
text(format!(
|
|
"Owned by the {} layer root. Add work and exchange contributions after creation.",
|
|
d.layer
|
|
))
|
|
.size(12),
|
|
);
|
|
}
|
|
Family::Scenario => {
|
|
fields = fields.push(
|
|
text(format!(
|
|
"Owned by the {} layer root. Choose what the lifelines represent:",
|
|
d.layer
|
|
))
|
|
.size(12),
|
|
);
|
|
for viewpoint in viewpoints(d.layer) {
|
|
fields = fields.push(
|
|
button(text(viewpoint_title(viewpoint)).size(13))
|
|
.style(if d.viewpoint == Some(viewpoint) {
|
|
button::primary
|
|
} else {
|
|
button::secondary
|
|
})
|
|
.on_press(Event::Viewpoint(viewpoint))
|
|
.padding(7),
|
|
);
|
|
}
|
|
let context_label = if d.context_open {
|
|
"Hide optional context".into()
|
|
} else if let Some(id) = d.context {
|
|
format!("Context: {} · Change…", reference_label(p, id))
|
|
} else {
|
|
"Add optional context…".into()
|
|
};
|
|
fields = fields.push(
|
|
button(text(context_label).size(12))
|
|
.on_press(Event::ContextOptions(!d.context_open))
|
|
.padding(6),
|
|
);
|
|
if d.context_open {
|
|
fields = fields.push(picker_view(p, d, Picker::Context));
|
|
}
|
|
}
|
|
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")
|
|
.on_toggle(Event::FirstView),
|
|
);
|
|
fields = fields.push(text("The machine starts without regions or states. Add those explicitly in its workspace.").size(12));
|
|
}
|
|
}
|
|
format!("New {}", family.title(d.layer))
|
|
} else {
|
|
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
|
|
let purpose = match family {
|
|
Family::Sequence => "A path through work and exchanges",
|
|
Family::Scenario => "Ordered interactions from an explicit viewpoint",
|
|
Family::Machine => "States and modes of an explicitly chosen participant",
|
|
};
|
|
fields = fields.push(
|
|
button(
|
|
column![text(family.title(d.layer)).size(15), text(purpose).size(12)]
|
|
.spacing(3),
|
|
)
|
|
.on_press(Event::Choose(family))
|
|
.padding(10)
|
|
.width(Fill),
|
|
);
|
|
}
|
|
format!("New behavior · {}", d.layer)
|
|
};
|
|
let mut actions =
|
|
row![button("Cancel · Esc").on_press(Event::Cancel).padding(8)].spacing(8);
|
|
if d.family.is_some() {
|
|
actions = actions.push(button("Create and open").on_press(Event::Submit).padding(8));
|
|
}
|
|
Some(
|
|
container(
|
|
column![
|
|
text(title).size(20),
|
|
scrollable(fields)
|
|
.id("behavior-creation-fields")
|
|
.height(Fill),
|
|
container(actions).id("behavior-creation-actions")
|
|
]
|
|
.spacing(12),
|
|
)
|
|
.padding(18)
|
|
.max_width(680)
|
|
.height(Fill)
|
|
.style(crate::style::card)
|
|
.into(),
|
|
)
|
|
}
|
|
}
|
|
fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
|
|
p: &'a Project,
|
|
d: &'a Draft,
|
|
picker: Picker,
|
|
) -> UiElement<'a, Event, iced::Theme, R> {
|
|
let browser = &d.browsers[picker.index()];
|
|
let options = choices(p, d.layer, picker, &browser.query);
|
|
let page = browser
|
|
.page
|
|
.min(options.len().saturating_sub(1) / PAGE_SIZE);
|
|
let selected = match picker {
|
|
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",
|
|
};
|
|
let mut out = column![
|
|
text(title).size(13),
|
|
text_input("Search names, aliases or parent context", &browser.query)
|
|
.on_input(move |query| Event::Search(picker, query))
|
|
.padding(6)
|
|
]
|
|
.spacing(5);
|
|
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 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));
|
|
}
|
|
for e in options.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
|
let message = match picker {
|
|
Picker::Owner => Event::Owner(e.id),
|
|
Picker::Context => Event::Context(Some(e.id)),
|
|
};
|
|
out = out.push(
|
|
container(
|
|
button(
|
|
column![
|
|
text(&e.name).size(13),
|
|
text(reference_context(p, e)).size(11)
|
|
]
|
|
.spacing(2),
|
|
)
|
|
.style(if selected == Some(e.id) {
|
|
button::primary
|
|
} else {
|
|
button::secondary
|
|
})
|
|
.on_press(message)
|
|
.padding(6)
|
|
.width(Fill),
|
|
)
|
|
.id(format!("behavior-creation-candidate-{}", e.id)),
|
|
);
|
|
}
|
|
let mut navigation = row![text(format!("{} matches", options.len())).size(11)].spacing(8);
|
|
if page > 0 {
|
|
navigation = navigation.push(button("Previous").on_press(Event::Page(picker, page - 1)));
|
|
}
|
|
if (page + 1) * PAGE_SIZE < options.len() {
|
|
navigation = navigation.push(button("Next").on_press(Event::Page(picker, page + 1)));
|
|
}
|
|
if picker == Picker::Context && selected.is_some() {
|
|
navigation = navigation.push(button("Clear context").on_press(Event::Context(None)));
|
|
}
|
|
out.push(navigation).into()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod app_tests;
|
|
#[cfg(test)]
|
|
mod tests;
|