675 lines
16 KiB
C++
675 lines
16 KiB
C++
/*
|
|
* Oak Video Editor - Non-Linear Video Editor
|
|
* Copyright (C) 2025 Olive CE Team
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
#include "keyframeview.h"
|
|
|
|
#include <QMouseEvent>
|
|
#include <QToolTip>
|
|
#include <QVBoxLayout>
|
|
|
|
#include "common/qtutils.h"
|
|
#include "dialog/keyframeproperties/keyframeproperties.h"
|
|
#include "keyframeviewundo.h"
|
|
#include "node/group/group.h"
|
|
#include "node/node.h"
|
|
#include "node/nodeundo.h"
|
|
#include "node/project/serializer/serializer.h"
|
|
#include "widget/menu/menu.h"
|
|
#include "widget/menu/menushared.h"
|
|
|
|
namespace olive
|
|
{
|
|
|
|
#define super TimeBasedView
|
|
|
|
KeyframeView::KeyframeView(QWidget *parent)
|
|
: super(parent)
|
|
, selection_manager_(this)
|
|
, autoselect_siblings_(true)
|
|
, max_scroll_(0)
|
|
, first_chance_mouse_event_(false)
|
|
{
|
|
setAlignment(Qt::AlignLeft | Qt::AlignTop);
|
|
SetDefaultDragMode(RubberBandDrag);
|
|
setContextMenuPolicy(Qt::CustomContextMenu);
|
|
|
|
connect(this, &KeyframeView::customContextMenuRequested, this,
|
|
&KeyframeView::ShowContextMenu);
|
|
}
|
|
|
|
void KeyframeView::DeleteSelected()
|
|
{
|
|
if (!selection_manager_.IsDragging()) {
|
|
MultiUndoCommand *command = new MultiUndoCommand();
|
|
|
|
foreach (NodeKeyframe *key, GetSelectedKeyframes()) {
|
|
command->add_child(new NodeParamRemoveKeyframeCommand(key));
|
|
}
|
|
|
|
Core::instance()->undo_stack()->push(
|
|
command,
|
|
tr("Deleted %1 Keyframe(s)").arg(GetSelectedKeyframes().size()));
|
|
}
|
|
}
|
|
|
|
KeyframeView::NodeConnections KeyframeView::AddKeyframesOfNode(Node *n)
|
|
{
|
|
NodeConnections map;
|
|
|
|
foreach (const QString &i, n->inputs()) {
|
|
map.insert(i, AddKeyframesOfInput(n, i));
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
KeyframeView::InputConnections
|
|
KeyframeView::AddKeyframesOfInput(Node *on, const QString &oinput)
|
|
{
|
|
InputConnections vec;
|
|
|
|
NodeInput resolved = NodeGroup::ResolveInput(NodeInput(on, oinput));
|
|
Node *n = resolved.node();
|
|
const QString &input = resolved.input();
|
|
|
|
if (n->IsInputKeyframable(input)) {
|
|
int arr_sz = n->InputArraySize(input);
|
|
vec.resize(arr_sz + 1);
|
|
for (int i = -1; i < arr_sz; i++) {
|
|
vec[i + 1] = AddKeyframesOfElement(NodeInput(n, input, i));
|
|
}
|
|
}
|
|
|
|
return vec;
|
|
}
|
|
|
|
KeyframeView::ElementConnections
|
|
KeyframeView::AddKeyframesOfElement(const NodeInput &input)
|
|
{
|
|
const QVector<NodeKeyframeTrack> &tracks =
|
|
input.node()->GetKeyframeTracks(input);
|
|
ElementConnections vec(tracks.size());
|
|
|
|
for (int i = 0; i < tracks.size(); i++) {
|
|
vec[i] = AddKeyframesOfTrack(NodeKeyframeTrackReference(input, i));
|
|
}
|
|
|
|
return vec;
|
|
}
|
|
|
|
KeyframeViewInputConnection *
|
|
KeyframeView::AddKeyframesOfTrack(const NodeKeyframeTrackReference &ref)
|
|
{
|
|
KeyframeViewInputConnection *track =
|
|
new KeyframeViewInputConnection(ref, this);
|
|
connect(track, &KeyframeViewInputConnection::RequireUpdate, this,
|
|
&KeyframeView::Redraw);
|
|
tracks_.append(track);
|
|
Redraw();
|
|
return track;
|
|
}
|
|
|
|
void KeyframeView::RemoveKeyframesOfTrack(
|
|
KeyframeViewInputConnection *connection)
|
|
{
|
|
if (tracks_.removeOne(connection)) {
|
|
foreach (NodeKeyframe *key, connection->GetKeyframes()) {
|
|
selection_manager_.Deselect(key);
|
|
}
|
|
delete connection;
|
|
Redraw();
|
|
emit SelectionChanged();
|
|
}
|
|
}
|
|
|
|
void KeyframeView::SelectAll()
|
|
{
|
|
foreach (KeyframeViewInputConnection *track, tracks_) {
|
|
foreach (NodeKeyframe *key, track->GetKeyframes()) {
|
|
SelectKeyframe(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
void KeyframeView::DeselectAll()
|
|
{
|
|
selection_manager_.ClearSelection();
|
|
|
|
Redraw();
|
|
}
|
|
|
|
void KeyframeView::Clear()
|
|
{
|
|
if (!tracks_.isEmpty()) {
|
|
qDeleteAll(tracks_);
|
|
tracks_.clear();
|
|
Redraw();
|
|
}
|
|
|
|
selection_manager_.ClearSelection();
|
|
}
|
|
|
|
void KeyframeView::SelectionManagerSelectEvent(void *obj)
|
|
{
|
|
if (autoselect_siblings_) {
|
|
NodeKeyframe *key = static_cast<NodeKeyframe *>(obj);
|
|
QVector<NodeKeyframe *> keys = key->parent()->GetKeyframesAtTime(
|
|
key->input(), key->time(), key->element());
|
|
foreach (NodeKeyframe *k, keys) {
|
|
if (k != key) {
|
|
SelectKeyframe(k);
|
|
}
|
|
}
|
|
}
|
|
|
|
emit SelectionChanged();
|
|
}
|
|
|
|
void KeyframeView::SelectionManagerDeselectEvent(void *obj)
|
|
{
|
|
if (autoselect_siblings_) {
|
|
NodeKeyframe *key = static_cast<NodeKeyframe *>(obj);
|
|
QVector<NodeKeyframe *> keys = key->parent()->GetKeyframesAtTime(
|
|
key->input(), key->time(), key->element());
|
|
foreach (NodeKeyframe *k, keys) {
|
|
if (k != key) {
|
|
DeselectKeyframe(k);
|
|
}
|
|
}
|
|
}
|
|
|
|
emit SelectionChanged();
|
|
}
|
|
|
|
bool KeyframeView::CopySelected(bool cut)
|
|
{
|
|
if (!selection_manager_.GetSelectedObjects().empty()) {
|
|
ProjectSerializer::SaveData sdata(ProjectSerializer::kOnlyKeyframes);
|
|
sdata.SetOnlySerializeKeyframes(
|
|
selection_manager_.GetSelectedObjects());
|
|
|
|
ProjectSerializer::Copy(sdata);
|
|
|
|
if (cut) {
|
|
DeleteSelected();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool KeyframeView::Paste(
|
|
std::function<Node *(const QString &)> find_node_function)
|
|
{
|
|
if (!GetViewerNode()) {
|
|
return false;
|
|
}
|
|
|
|
ProjectSerializer::Result res =
|
|
ProjectSerializer::Paste(ProjectSerializer::kOnlyKeyframes);
|
|
if (res == ProjectSerializer::kSuccess) {
|
|
const ProjectSerializer::SerializedKeyframes &keys =
|
|
res.GetLoadData().keyframes;
|
|
|
|
MultiUndoCommand *command = new MultiUndoCommand();
|
|
|
|
rational min = RATIONAL_MAX;
|
|
for (auto it = keys.cbegin(); it != keys.cend(); it++) {
|
|
for (NodeKeyframe *key : it.value()) {
|
|
min = std::min(min, key->time());
|
|
}
|
|
}
|
|
min -= GetViewerNode()->GetPlayhead();
|
|
|
|
for (auto it = keys.cbegin(); it != keys.cend(); it++) {
|
|
const QString &paste_id = it.key();
|
|
|
|
// Find a node with this ID
|
|
Node *node_with_id = find_node_function(paste_id);
|
|
|
|
if (node_with_id) {
|
|
for (NodeKeyframe *key : it.value()) {
|
|
// Adjust sequence time to node's time
|
|
rational t = key->time() - min;
|
|
t = GetAdjustedTime(GetTimeTarget(), node_with_id, t,
|
|
Node::kTransformTowardsInput);
|
|
key->set_time(t);
|
|
|
|
if (NodeKeyframe *existing =
|
|
node_with_id->GetKeyframeAtTimeOnTrack(
|
|
key->input(), key->time(), key->track(),
|
|
key->element())) {
|
|
command->add_child(
|
|
new NodeParamRemoveKeyframeCommand(existing));
|
|
}
|
|
|
|
command->add_child(
|
|
new NodeParamInsertKeyframeCommand(node_with_id, key));
|
|
}
|
|
} else {
|
|
qDeleteAll(it.value());
|
|
}
|
|
}
|
|
|
|
Core::instance()->undo_stack()->push(
|
|
command, tr("Pasted %1 Keyframe(s)").arg(keys.size()));
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
void KeyframeView::CatchUpScrollEvent()
|
|
{
|
|
super::CatchUpScrollEvent();
|
|
|
|
this->selection_manager_.ForceDragUpdate();
|
|
}
|
|
|
|
void KeyframeView::mousePressEvent(QMouseEvent *event)
|
|
{
|
|
NodeKeyframe *key_under_cursor =
|
|
selection_manager_.GetObjectAtPoint(event->pos());
|
|
|
|
if (HandPress(event) || (!key_under_cursor && PlayheadPress(event))) {
|
|
return;
|
|
}
|
|
|
|
// Do mouse press things
|
|
if (FirstChanceMousePress(event)) {
|
|
first_chance_mouse_event_ = true;
|
|
} else if (NodeKeyframe *initial_key =
|
|
selection_manager_.MousePress(event)) {
|
|
selection_manager_.DragStart(initial_key, event, this);
|
|
KeyframeDragStart(event);
|
|
} else {
|
|
selection_manager_.RubberBandStart(event);
|
|
}
|
|
|
|
// Update view
|
|
Redraw();
|
|
}
|
|
|
|
void KeyframeView::mouseMoveEvent(QMouseEvent *event)
|
|
{
|
|
if (HandMove(event) || PlayheadMove(event)) {
|
|
return;
|
|
}
|
|
|
|
if (first_chance_mouse_event_) {
|
|
FirstChanceMouseMove(event);
|
|
} else if (selection_manager_.IsDragging()) {
|
|
QString tip;
|
|
KeyframeDragMove(event, tip);
|
|
selection_manager_.DragMove(event->pos(), tip);
|
|
} else if (selection_manager_.IsRubberBanding()) {
|
|
selection_manager_.RubberBandMove(event->pos());
|
|
Redraw();
|
|
}
|
|
|
|
if (event->buttons()) {
|
|
// Signal cursor pos in case we should scroll to catch up to it
|
|
emit Dragged(event->pos().x(), event->pos().y());
|
|
}
|
|
}
|
|
|
|
void KeyframeView::mouseReleaseEvent(QMouseEvent *event)
|
|
{
|
|
if (HandRelease(event) || PlayheadRelease(event)) {
|
|
return;
|
|
}
|
|
|
|
if (first_chance_mouse_event_) {
|
|
FirstChanceMouseRelease(event);
|
|
first_chance_mouse_event_ = false;
|
|
} else if (selection_manager_.IsDragging()) {
|
|
MultiUndoCommand *command = new MultiUndoCommand();
|
|
selection_manager_.DragStop(command);
|
|
KeyframeDragRelease(event, command);
|
|
Core::instance()->undo_stack()->push(
|
|
command, tr("Moved %1 Keyframe(s)")
|
|
.arg(selection_manager_.GetSelectedObjects().size()));
|
|
} else if (selection_manager_.IsRubberBanding()) {
|
|
selection_manager_.RubberBandStop();
|
|
Redraw();
|
|
emit SelectionChanged();
|
|
}
|
|
|
|
emit Released();
|
|
}
|
|
|
|
int BinarySearchFirstKeyframeAfterOrAt(const QVector<NodeKeyframe *> &keys,
|
|
const rational &time)
|
|
{
|
|
int low = 0;
|
|
int high = keys.size() - 1;
|
|
|
|
while (low <= high) {
|
|
int mid = low + (high - low) / 2;
|
|
NodeKeyframe *test_key = keys.at(mid);
|
|
|
|
if (test_key->time() == time ||
|
|
(test_key->time() > time &&
|
|
(mid == 0 || keys.at(mid - 1)->time() < time))) {
|
|
return mid;
|
|
} else if (test_key->time() < time) {
|
|
low = mid + 1;
|
|
} else {
|
|
high = mid - 1;
|
|
}
|
|
}
|
|
|
|
return keys.size();
|
|
}
|
|
|
|
void KeyframeView::drawForeground(QPainter *painter, const QRectF &rect)
|
|
{
|
|
int key_sz = QtUtils::QFontMetricsWidth(fontMetrics(), "Oi");
|
|
int key_rad = key_sz / 2;
|
|
|
|
selection_manager_.ClearDrawnObjects();
|
|
|
|
painter->setRenderHint(QPainter::Antialiasing);
|
|
|
|
foreach (KeyframeViewInputConnection *track, tracks_) {
|
|
const QVector<NodeKeyframe *> &keys = track->GetKeyframes();
|
|
|
|
if (keys.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
if (!IsYAxisEnabled()) {
|
|
// Filter out if the keyframes are offscreen Y
|
|
qreal y = GetKeyframeSceneY(track, keys.first());
|
|
if (y + key_rad < rect.top() || y - key_rad >= rect.bottom()) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Find first keyframe to show with binary search
|
|
rational left_time = GetUnadjustedKeyframeTime(
|
|
keys.first(), SceneToTime(rect.left() - key_sz));
|
|
int using_index = BinarySearchFirstKeyframeAfterOrAt(keys, left_time);
|
|
|
|
rational next_key = RATIONAL_MIN;
|
|
NodeKeyframe::Type last_type = NodeKeyframe::kInvalid;
|
|
for (int i = using_index; i < keys.size(); i++) {
|
|
NodeKeyframe *key = keys.at(i);
|
|
|
|
if (key->time() < next_key && key->type() == last_type) {
|
|
// This key will be drawn at exactly the same location as the last one and therefore
|
|
// doesn't need to be drawn. See if the next one will be drawn.
|
|
i++;
|
|
if (i == keys.size()) {
|
|
break;
|
|
}
|
|
|
|
key = keys.at(i);
|
|
|
|
if (key->time() < next_key) {
|
|
// Next key still won't be drawn, so we'll switch to a binary search
|
|
i = BinarySearchFirstKeyframeAfterOrAt(keys, next_key);
|
|
|
|
if (i == keys.size()) {
|
|
break;
|
|
}
|
|
|
|
key = keys.at(i);
|
|
}
|
|
}
|
|
|
|
QRectF key_rect(-key_rad, -key_rad, key_sz, key_sz);
|
|
qreal key_x = GetKeyframeSceneX(key);
|
|
key_rect.translate(key_x, GetKeyframeSceneY(track, key));
|
|
|
|
if (key_rect.left() >= rect.right()) {
|
|
// Break after last keyframe
|
|
break;
|
|
}
|
|
|
|
DrawKeyframe(painter, key, track, key_rect);
|
|
|
|
next_key = GetUnadjustedKeyframeTime(key, SceneToTime(key_x + 1));
|
|
last_type = key->type();
|
|
}
|
|
}
|
|
|
|
super::drawForeground(painter, rect);
|
|
}
|
|
|
|
void KeyframeView::DrawKeyframe(QPainter *painter, NodeKeyframe *key,
|
|
KeyframeViewInputConnection *track,
|
|
const QRectF &key_rect)
|
|
{
|
|
painter->setPen(Qt::black);
|
|
|
|
if (IsKeyframeSelected(key)) {
|
|
painter->setBrush(palette().highlight());
|
|
} else {
|
|
painter->setBrush(track->GetBrush());
|
|
}
|
|
|
|
selection_manager_.DeclareDrawnObject(key, key_rect);
|
|
|
|
switch (key->type()) {
|
|
case NodeKeyframe::kInvalid:
|
|
break;
|
|
case NodeKeyframe::kLinear: {
|
|
QPointF points[] = { QPointF(key_rect.center().x(), key_rect.top()),
|
|
QPointF(key_rect.right(), key_rect.center().y()),
|
|
QPointF(key_rect.center().x(), key_rect.bottom()),
|
|
QPointF(key_rect.left(), key_rect.center().y()) };
|
|
|
|
painter->drawPolygon(points, 4);
|
|
break;
|
|
}
|
|
case NodeKeyframe::kBezier:
|
|
painter->drawEllipse(key_rect);
|
|
break;
|
|
case NodeKeyframe::kHold:
|
|
painter->drawRect(key_rect);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void KeyframeView::ScaleChangedEvent(const double &scale)
|
|
{
|
|
super::ScaleChangedEvent(scale);
|
|
|
|
Redraw();
|
|
}
|
|
|
|
void KeyframeView::TimeTargetChangedEvent(ViewerOutput *v)
|
|
{
|
|
Redraw();
|
|
}
|
|
|
|
void KeyframeView::TimebaseChangedEvent(const rational &timebase)
|
|
{
|
|
super::TimebaseChangedEvent(timebase);
|
|
|
|
selection_manager_.SetTimebase(timebase);
|
|
}
|
|
|
|
void KeyframeView::ContextMenuEvent(Menu &m)
|
|
{
|
|
Q_UNUSED(m)
|
|
}
|
|
|
|
void KeyframeView::SelectKeyframe(NodeKeyframe *key)
|
|
{
|
|
if (selection_manager_.Select(key)) {
|
|
Redraw();
|
|
|
|
emit SelectionChanged();
|
|
}
|
|
}
|
|
|
|
void KeyframeView::DeselectKeyframe(NodeKeyframe *key)
|
|
{
|
|
if (selection_manager_.Deselect(key)) {
|
|
Redraw();
|
|
|
|
emit SelectionChanged();
|
|
}
|
|
}
|
|
|
|
rational KeyframeView::GetUnadjustedKeyframeTime(NodeKeyframe *key,
|
|
const rational &time)
|
|
{
|
|
return GetAdjustedTime(GetTimeTarget(), key->parent(), time,
|
|
Node::kTransformTowardsInput);
|
|
}
|
|
|
|
rational KeyframeView::GetAdjustedKeyframeTime(NodeKeyframe *key)
|
|
{
|
|
return GetAdjustedTime(key->parent(), GetTimeTarget(), key->time(),
|
|
Node::kTransformTowardsOutput);
|
|
}
|
|
|
|
double KeyframeView::GetKeyframeSceneX(NodeKeyframe *key)
|
|
{
|
|
return TimeToScene(GetAdjustedKeyframeTime(key));
|
|
}
|
|
|
|
qreal KeyframeView::GetKeyframeSceneY(KeyframeViewInputConnection *track,
|
|
NodeKeyframe *key)
|
|
{
|
|
return mapFromGlobal(QPoint(0, track->GetKeyframeY())).y();
|
|
}
|
|
|
|
void KeyframeView::SceneRectUpdateEvent(QRectF &rect)
|
|
{
|
|
rect.setY(0);
|
|
rect.setHeight(max_scroll_);
|
|
}
|
|
|
|
rational KeyframeView::CalculateNewTimeFromScreen(const rational &old_time,
|
|
double cursor_diff)
|
|
{
|
|
return rational::fromDouble(old_time.toDouble() + cursor_diff);
|
|
}
|
|
|
|
void KeyframeView::ShowContextMenu()
|
|
{
|
|
Menu m;
|
|
|
|
MenuShared::instance()->AddItemsForEditMenu(&m, false);
|
|
|
|
QAction *linear_key_action = nullptr;
|
|
QAction *bezier_key_action = nullptr;
|
|
QAction *hold_key_action = nullptr;
|
|
|
|
if (!GetSelectedKeyframes().empty()) {
|
|
bool all_keys_are_same_type = true;
|
|
NodeKeyframe::Type type = GetSelectedKeyframes().front()->type();
|
|
|
|
for (size_t i = 1; i < GetSelectedKeyframes().size(); i++) {
|
|
NodeKeyframe *key_item = GetSelectedKeyframes().at(i);
|
|
NodeKeyframe *prev_item = GetSelectedKeyframes().at(i - 1);
|
|
|
|
if (key_item->type() != prev_item->type()) {
|
|
all_keys_are_same_type = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
m.addSeparator();
|
|
|
|
linear_key_action = m.addAction(tr("Linear"));
|
|
bezier_key_action = m.addAction(tr("Bezier"));
|
|
hold_key_action = m.addAction(tr("Hold"));
|
|
|
|
if (all_keys_are_same_type) {
|
|
switch (type) {
|
|
case NodeKeyframe::kInvalid:
|
|
break;
|
|
case NodeKeyframe::kLinear:
|
|
linear_key_action->setChecked(true);
|
|
break;
|
|
case NodeKeyframe::kBezier:
|
|
bezier_key_action->setChecked(true);
|
|
break;
|
|
case NodeKeyframe::kHold:
|
|
hold_key_action->setChecked(true);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
m.addSeparator();
|
|
|
|
ContextMenuEvent(m);
|
|
|
|
if (!GetSelectedKeyframes().empty()) {
|
|
m.addSeparator();
|
|
|
|
QAction *properties_action = m.addAction(tr("P&roperties"));
|
|
connect(properties_action, &QAction::triggered, this,
|
|
&KeyframeView::ShowKeyframePropertiesDialog);
|
|
}
|
|
|
|
QAction *selected = m.exec(QCursor::pos());
|
|
|
|
// Process keyframe type changes
|
|
if (selected) {
|
|
if (selected == linear_key_action || selected == bezier_key_action ||
|
|
selected == hold_key_action) {
|
|
NodeKeyframe::Type new_type;
|
|
|
|
if (selected == hold_key_action) {
|
|
new_type = NodeKeyframe::kHold;
|
|
} else if (selected == bezier_key_action) {
|
|
new_type = NodeKeyframe::kBezier;
|
|
} else {
|
|
new_type = NodeKeyframe::kLinear;
|
|
}
|
|
|
|
MultiUndoCommand *command = new MultiUndoCommand();
|
|
foreach (NodeKeyframe *item, GetSelectedKeyframes()) {
|
|
command->add_child(new KeyframeSetTypeCommand(item, new_type));
|
|
}
|
|
Core::instance()->undo_stack()->push(
|
|
command, tr("Set Type of %1 Keyframe(s)")
|
|
.arg(GetSelectedKeyframes().size()));
|
|
}
|
|
}
|
|
}
|
|
|
|
void KeyframeView::ShowKeyframePropertiesDialog()
|
|
{
|
|
if (!GetSelectedKeyframes().empty()) {
|
|
KeyframePropertiesDialog kd(GetSelectedKeyframes(), timebase(), this);
|
|
kd.exec();
|
|
}
|
|
}
|
|
|
|
void KeyframeView::UpdateRubberBandForScroll()
|
|
{
|
|
this->selection_manager_.ForceDragUpdate();
|
|
}
|
|
|
|
void KeyframeView::Redraw()
|
|
{
|
|
viewport()->update();
|
|
}
|
|
|
|
}
|