697 lines
25 KiB
Rust
697 lines
25 KiB
Rust
//! Scenario presentation. Layout/camera controls never construct model commands.
|
|
use super::super::{form_session, scenario_scroll};
|
|
use super::*;
|
|
use iced::widget::{Space, column, sensor};
|
|
|
|
pub(crate) const NAME_INPUT: &str = "scenario-form-name";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Shelf {
|
|
Selection,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub(super) struct Presentation {
|
|
pub(super) identity: Option<(Id, Id)>,
|
|
pub(super) viewport: Option<Size>,
|
|
pub(super) focused: bool,
|
|
pub(super) shelf: Option<Shelf>,
|
|
pub(super) pan: iced::Vector,
|
|
pub(super) zoom: Option<f32>,
|
|
pub(super) fit: bool,
|
|
}
|
|
const MIN_SCALE: f32 = 0.05;
|
|
impl Presentation {
|
|
pub(super) fn viewport(&mut self, size: Size) -> bool {
|
|
if !size.width.is_finite()
|
|
|| !size.height.is_finite()
|
|
|| size.width <= 0.
|
|
|| size.height <= 0.
|
|
|| self.viewport == Some(size)
|
|
{
|
|
return false;
|
|
}
|
|
self.viewport = Some(size);
|
|
true
|
|
}
|
|
pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
|
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
|
|
return false;
|
|
}
|
|
let next = scale.map(|value| value.clamp(MIN_SCALE, 3.));
|
|
let changed = self.zoom != next || self.fit != scale.is_none();
|
|
self.zoom = next;
|
|
self.fit = scale.is_none();
|
|
changed
|
|
}
|
|
fn fit_scale(&self, scene: Size) -> Option<f32> {
|
|
let viewport = self.viewport?;
|
|
// Fit uses the actual mounted viewport with a small visual gutter.
|
|
Some(
|
|
((viewport.width - 16.).max(1.) / scene.width.max(1.))
|
|
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
|
|
)
|
|
}
|
|
pub(super) fn scale(&self, scene: Size) -> f32 {
|
|
if self.fit {
|
|
self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.)
|
|
} else {
|
|
self.zoom.unwrap_or(1.)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
|
|
let p = app.project();
|
|
let state = &app.behavior.scenario;
|
|
let presentation = &state.presentation;
|
|
let pending = state
|
|
.draft
|
|
.as_ref()
|
|
.is_some_and(|draft| draft.owner == owner);
|
|
let context = app.scenario_context().expect("visible scenario context");
|
|
let tool = state.canvas_tool(owner);
|
|
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
|
let mut scene = Scene::sequence(p, owner, app.selected);
|
|
let scale = presentation.scale(scene.size);
|
|
scene.scale = scale;
|
|
scene.camera_pan = presentation.pan;
|
|
scene.interaction = Some((context, tool, clean));
|
|
scene.pending_placement = state.pending_placement(owner);
|
|
let scope = super::super::canvas_workspace::Scope {
|
|
document: context.document,
|
|
owner,
|
|
revision: context.revision,
|
|
epoch: app.camera_epoch,
|
|
};
|
|
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
|
|
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
|
|
let canvas = container(
|
|
sensor(
|
|
super::super::canvas_workspace::Viewport::new(
|
|
canvas::Canvas::new(scene).width(Fill).height(Fill),
|
|
scope,
|
|
scale,
|
|
keyboard,
|
|
camera,
|
|
)
|
|
.pan_background(tool == CanvasTool::Select),
|
|
)
|
|
.key((context.document, owner))
|
|
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
|
|
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
|
|
)
|
|
.id("scenario-viewport")
|
|
.width(Fill)
|
|
.height(Fill)
|
|
.style(style::card)
|
|
.into();
|
|
let mut navigation = row![
|
|
out.width(Fill),
|
|
super::super::canvas_workspace::Control::new(
|
|
button(
|
|
text(if presentation.focused {
|
|
"Restore panels"
|
|
} else {
|
|
"Focus canvas"
|
|
})
|
|
.size(12)
|
|
)
|
|
.padding(6)
|
|
.on_press(super::super::canvas_workspace::intent(
|
|
scope,
|
|
"Panels",
|
|
Message::ScenarioCanvas(CanvasEvent::Focus(context))
|
|
)),
|
|
scope,
|
|
"Panels",
|
|
keyboard,
|
|
Some(super::super::canvas_workspace::intent(
|
|
scope,
|
|
"Panels",
|
|
Message::ScenarioCanvas(CanvasEvent::Focus(context))
|
|
)),
|
|
camera
|
|
)
|
|
]
|
|
.spacing(6);
|
|
if state.can_back(p, context) {
|
|
navigation =
|
|
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(
|
|
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
|
|
));
|
|
}
|
|
let header = column![
|
|
navigation,
|
|
super::super::canvas_workspace::controls(
|
|
scope,
|
|
keyboard,
|
|
scale,
|
|
Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)),
|
|
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Camera(
|
|
context,
|
|
scope.epoch,
|
|
super::super::canvas_workspace::Event::Focus
|
|
))),
|
|
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
|
|
camera
|
|
)
|
|
]
|
|
.spacing(4);
|
|
let entries = [
|
|
("S", "Select / reorder occurrences", CanvasTool::Select),
|
|
(
|
|
"L",
|
|
"Lifeline: click to choose represented work or participant",
|
|
CanvasTool::Add(Tool::Lifeline),
|
|
),
|
|
(
|
|
"C/R",
|
|
"Call and correlated reply",
|
|
CanvasTool::Message(MessageStyle::CallReply),
|
|
),
|
|
(
|
|
"M",
|
|
"Asynchronous message",
|
|
CanvasTool::Message(MessageStyle::Asynchronous),
|
|
),
|
|
(
|
|
"C",
|
|
"Synchronous call",
|
|
CanvasTool::Message(MessageStyle::Call),
|
|
),
|
|
(
|
|
"R",
|
|
"Reply to existing call",
|
|
CanvasTool::Message(MessageStyle::Reply),
|
|
),
|
|
(
|
|
"+L",
|
|
"Create participant",
|
|
CanvasTool::Message(MessageStyle::Create),
|
|
),
|
|
(
|
|
"-L",
|
|
"Delete participant",
|
|
CanvasTool::Message(MessageStyle::Delete),
|
|
),
|
|
("T", "Local timer", CanvasTool::Message(MessageStyle::Timer)),
|
|
(
|
|
"A",
|
|
"Action: click a sequence position",
|
|
CanvasTool::Add(Tool::Action),
|
|
),
|
|
(
|
|
"St",
|
|
"State observation: click a sequence position",
|
|
CanvasTool::Add(Tool::Observation),
|
|
),
|
|
(
|
|
"ref",
|
|
"Reuse scenario: click a sequence position",
|
|
CanvasTool::Add(Tool::Reuse),
|
|
),
|
|
(
|
|
"alt",
|
|
"Fragment: click a sequence position",
|
|
CanvasTool::Add(Tool::Fragment),
|
|
),
|
|
];
|
|
let palette = super::super::canvas_workspace::palette(
|
|
scope,
|
|
keyboard,
|
|
camera,
|
|
entries.into_iter().map(|(glyph, label, choice)| {
|
|
(
|
|
glyph,
|
|
label,
|
|
choice == tool,
|
|
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
|
|
context, choice,
|
|
))),
|
|
)
|
|
}),
|
|
);
|
|
let mut details = column![
|
|
text(format!("Selected: {}", p.named(app.selected))).size(12),
|
|
button(text("Properties").size(12))
|
|
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected)))
|
|
]
|
|
.spacing(6);
|
|
if referenced_scenario(p, owner, app.selected).is_some() {
|
|
details = details.push(button(text("Open definition").size(12)).on_press_maybe(
|
|
clean.then_some(Message::ScenarioCanvas(CanvasEvent::OpenUse(
|
|
context,
|
|
app.selected,
|
|
))),
|
|
));
|
|
}
|
|
let details = details_pane(
|
|
context,
|
|
state.scroll_restore(context, ScrollArea::Page),
|
|
sequence_rows(p, context, owner, details, clean).into(),
|
|
);
|
|
let mut form_panel = editor_panel(
|
|
Id::nil(),
|
|
Space::new().height(0).into(),
|
|
Space::new().height(0).into(),
|
|
);
|
|
if let Some(d) = app
|
|
.behavior
|
|
.scenario
|
|
.draft
|
|
.as_ref()
|
|
.filter(|d| d.owner == owner)
|
|
{
|
|
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)
|
|
.id(focus::name_id(
|
|
state.focus_stamp(context).expect("visible parent form")
|
|
))
|
|
.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::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),
|
|
));
|
|
}
|
|
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 {
|
|
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."));
|
|
}
|
|
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);
|
|
let session = state.form_session().expect("visible scenario draft");
|
|
let mount = state.form_mount().expect("visible parent form");
|
|
form_panel = sensor(editor_panel(session, footer.into(), form.into()))
|
|
.key(mount)
|
|
.on_show(move |_| Message::ScenarioCanvas(CanvasEvent::FormShown(context, mount)))
|
|
.into();
|
|
}
|
|
}
|
|
let editor = if pending {
|
|
Some(form_panel)
|
|
} else {
|
|
presentation.shelf.map(|_| details)
|
|
};
|
|
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
|
}
|
|
|
|
fn sequence_action<'a>(
|
|
title: &str,
|
|
message: Message,
|
|
enabled: bool,
|
|
) -> iced::widget::Button<'a, Message> {
|
|
button(text(title.to_owned()).size(12))
|
|
.padding(8)
|
|
.style(style::nav)
|
|
.on_press_maybe(enabled.then_some(message))
|
|
}
|
|
|
|
fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>(
|
|
context: Context,
|
|
restore: Option<ScrollRestore>,
|
|
details: iced::Element<'a, Message, Theme, R>,
|
|
) -> iced::Element<'a, Message, Theme, R> {
|
|
scenario_scroll::observe(
|
|
context,
|
|
ScrollArea::Page,
|
|
false,
|
|
restore,
|
|
scrollable(details)
|
|
.id("scenario-details")
|
|
.width(Fill)
|
|
.height(Fill),
|
|
)
|
|
}
|
|
|
|
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
|
|
session: Id,
|
|
actions: iced::Element<'a, Message, Theme, R>,
|
|
fields: iced::Element<'a, Message, Theme, R>,
|
|
) -> iced::Element<'a, Message, Theme, R> {
|
|
container(
|
|
column![
|
|
container(actions).id("scenario-actions").width(Fill),
|
|
form_session::reset_on_change(
|
|
session,
|
|
scrollable(fields)
|
|
.id("scenario-form")
|
|
.width(Fill)
|
|
.height(Fill),
|
|
),
|
|
]
|
|
.spacing(6),
|
|
)
|
|
.padding(10)
|
|
.width(Fill)
|
|
.height(Fill)
|
|
.style(style::card)
|
|
.into()
|
|
}
|
|
|
|
#[cfg(all(test, debug_assertions))]
|
|
mod tests;
|
|
|
|
fn sequence_rows<'a>(
|
|
p: &'a Project,
|
|
context: Context,
|
|
owner: Id,
|
|
mut out: Column<'a, Message>,
|
|
enabled: bool,
|
|
) -> Column<'a, Message> {
|
|
if let Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::Fragment { branches, .. },
|
|
..
|
|
}) = p.elements.get(&owner).and_then(|e| e.shared())
|
|
{
|
|
for branch in branches {
|
|
out = out.push(sequence_action(
|
|
&format!("Operand: {}", p.named(*branch)),
|
|
Message::Select(*branch),
|
|
enabled,
|
|
));
|
|
out = sequence_rows(p, context, *branch, out, enabled);
|
|
}
|
|
}
|
|
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
|
|
for (i, id) in steps.iter().enumerate() {
|
|
out = out.push(
|
|
row![
|
|
sequence_action(
|
|
&format!("{}. {}", i + 1, p.named(*id)),
|
|
Message::Select(*id),
|
|
enabled
|
|
)
|
|
.width(Fill),
|
|
button("↑")
|
|
.padding(8)
|
|
.on_press_maybe((enabled && i > 0).then_some(Message::Command(
|
|
Command::MoveStep(owner, i, i.saturating_sub(1))
|
|
))),
|
|
button("↓").padding(8).on_press_maybe(
|
|
(enabled && i + 1 < steps.len())
|
|
.then_some(Message::Command(Command::MoveStep(owner, i, i + 1)))
|
|
)
|
|
]
|
|
.spacing(6),
|
|
);
|
|
if let Some(SharedBody::Occurrence {
|
|
body:
|
|
OccurrenceBody::ScenarioUse {
|
|
scenario: Some(target),
|
|
..
|
|
},
|
|
..
|
|
}) = p.elements[id].shared()
|
|
{
|
|
out = out.push(sequence_action(
|
|
&format!("Open {} →", p.named(*target)),
|
|
Message::ScenarioCanvas(CanvasEvent::OpenUse(context, *id)),
|
|
enabled,
|
|
));
|
|
}
|
|
out = sequence_rows(p, context, *id, out, enabled);
|
|
}
|
|
}
|
|
out
|
|
}
|