diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -125,6 +125,7 @@ add_subdirectory(standardoutputview) add_subdirectory(switchtobuddy) add_subdirectory(testview) +add_subdirectory(scratchpad) ecm_optional_add_subdirectory(classbrowser) ecm_optional_add_subdirectory(executeplasmoid) ecm_optional_add_subdirectory(ghprovider) diff --git a/plugins/scratchpad/CMakeLists.txt b/plugins/scratchpad/CMakeLists.txt new file mode 100644 --- /dev/null +++ b/plugins/scratchpad/CMakeLists.txt @@ -0,0 +1,27 @@ +add_definitions(-DTRANSLATION_DOMAIN=\"kdevscratchpad\") +set(scratchpad_SRCS + scratchpad.cpp + scratchpadview.cpp + scratchpadjob.cpp +) + +ki18n_wrap_ui(scratchpad_SRCS scratchpadview.ui) + +declare_qt_logging_category(scratchpad_SRCS + TYPE PLUGIN + IDENTIFIER PLUGIN_SCRATCHPAD + CATEGORY_BASENAME "scratchpad" +) + +kdevplatform_add_plugin(kdevscratchpad + JSON scratchpad.json + SOURCES ${scratchpad_SRCS} +) + +target_link_libraries(kdevscratchpad + KDev::Interfaces + KDev::Util + KDev::DefinesAndIncludesManager + KDev::OutputView + kdevcompilerprovider +) diff --git a/plugins/scratchpad/scratchpad.h b/plugins/scratchpad/scratchpad.h new file mode 100644 --- /dev/null +++ b/plugins/scratchpad/scratchpad.h @@ -0,0 +1,69 @@ +/* This file is part of KDevelop + * + * Copyright 2018 Amish K. Naidu + * + * 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 2 + * 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, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +#ifndef SCRATCHPAD_H +#define SCRATCHPAD_H + +#include + +#include + +class ScratchpadToolViewFactory; + +class QStandardItemModel; +class QModelIndex; +class QFileInfo; +class QString; + +class Scratchpad + : public KDevelop::IPlugin +{ + Q_OBJECT + +public: + Scratchpad(QObject* parent, const QVariantList& args); + + QStandardItemModel* model(); + + static QString dataDirectory(); + + enum ExtraRoles { + FullPathRole = Qt::UserRole + 1, + }; + +public Q_SLOTS: + void openScratch(const QModelIndex& index); + void runScratch(const QModelIndex& scratchIndex, const QString& compiler, const QString& command); + void removeScratch(const QModelIndex& index); + void createScratch(); + void renameScratch(const QModelIndex& index, const QString& previousName); + +Q_SIGNALS: + void actionFailed(const QString& message); + +private: + void addFileToModel(const QFileInfo& fileInfo); + + ScratchpadToolViewFactory* m_factory; + QStandardItemModel* m_model; + QFileIconProvider m_iconProvider; +}; + +#endif // SCRATCHPAD_H diff --git a/plugins/scratchpad/scratchpad.cpp b/plugins/scratchpad/scratchpad.cpp new file mode 100644 --- /dev/null +++ b/plugins/scratchpad/scratchpad.cpp @@ -0,0 +1,207 @@ +/* This file is part of KDevelop + * + * Copyright 2018 Amish K. Naidu + * + * 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 2 + * 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, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +#include "scratchpad.h" +#include "scratchpadview.h" +#include "scratchpadjob.h" + +#include + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +#include + +K_PLUGIN_FACTORY_WITH_JSON(ScratchpadFactory, "scratchpad.json", registerPlugin(); ) + +class ScratchpadToolViewFactory + : public KDevelop::IToolViewFactory +{ +public: + explicit ScratchpadToolViewFactory(Scratchpad* plugin) + : m_plugin(plugin) + {} + + QWidget* create(QWidget* parent = nullptr) override + { + return new ScratchpadView(parent, m_plugin); + } + + Qt::DockWidgetArea defaultPosition() override + { + return Qt::LeftDockWidgetArea; + } + + QString id() const override + { + return QStringLiteral("org.kdevelop.scratchpad"); + } + +private: + Scratchpad* const m_plugin; +}; + +Scratchpad::Scratchpad(QObject* parent, const QVariantList& args) + : KDevelop::IPlugin(QStringLiteral("scratchpad"), parent) + , m_factory(new ScratchpadToolViewFactory(this)) + , m_model(new QStandardItemModel(this)) +{ + Q_UNUSED(args); + + qCDebug(PLUGIN_SCRATCHPAD) << "Scratchpad plugin is loaded!"; + + core()->uiController()->addToolView(i18n("Scratchpad"), m_factory); + + const QDir dataDir(dataDirectory()); + if (!dataDir.exists()) { + qCDebug(PLUGIN_SCRATCHPAD) << "Creating directory" << dataDir; + dataDir.mkpath(QStringLiteral(".")); + } + + const QFileInfoList scratches = dataDir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot); + qCDebug(PLUGIN_SCRATCHPAD) << dataDirectory() << scratches; + + for (const auto& fileInfo : scratches) { + addFileToModel(fileInfo); + + // TODO if scratch is open (happens when restarting), set pretty name, below code doesn't work +// auto* document = core()->documentController()->documentForUrl(QUrl::fromLocalFile(fileInfo.absoluteFilePath())); +// if (document) { +// document->setPrettyName(i18n("scratch:%1", fileInfo.fileName())); +// } + } +} + +QStandardItemModel* Scratchpad::model() +{ + return m_model; +} + +QString Scratchpad::dataDirectory() +{ + const static QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + QStringLiteral("/scratch/"); + return dir; +} + + +void Scratchpad::openScratch(const QModelIndex& index) +{ + const QUrl scratchUrl = QUrl::fromLocalFile(index.data(FullPathRole).toString()); + auto* const document = core()->documentController()->openDocument(scratchUrl); + document->setPrettyName(i18n("scratch:%1", index.data().toString())); +} + +void Scratchpad::runScratch(const QModelIndex& index, const QString& compiler, const QString& command) +{ + qCDebug(PLUGIN_SCRATCHPAD) << "run" << index.data().toString(); + + auto* job = new ScratchpadJob(m_model->itemFromIndex(index), compiler, command, this); + core()->runController()->registerJob(job); +} + +void Scratchpad::removeScratch(const QModelIndex& index) +{ + const QString path = index.data(FullPathRole).toString(); + if (auto* document = core()->documentController()->documentForUrl(QUrl::fromLocalFile(path))) { + document->close(); + } + + if (QFile::remove(path)) { + qCDebug(PLUGIN_SCRATCHPAD) << "removed" << index.data(FullPathRole); + m_model->removeRow(index.row()); + } else { + emit actionFailed(i18n("Failed to remove scratch: %1", index.data().toString())); + } +} + +void Scratchpad::createScratch() +{ + QString name = QStringLiteral("scratch1.cpp"); + for (int i = 2; !m_model->findItems(name).isEmpty(); ++i) { + name = QStringLiteral("scratch%1.cpp").arg(i); + } + + QFile file(dataDirectory() + name); + if (file.open(QIODevice::NewOnly)) { // create a new file + file.close(); + } + + if (file.exists()) { + addFileToModel(file); + } else { + emit actionFailed(i18n("Failed to create new scratch")); + } +} + +void Scratchpad::renameScratch(const QModelIndex& index, const QString& previousName) +{ + if (index.data().toString().contains(QLatin1Char('/'))) { + m_model->setData(index, previousName); // undo + emit actionFailed(i18n("Failed to rename scratch: Names must not include '/'")); + return; + } + + const QString previousPath = dataDirectory() + previousName; + const QString newPath = dataDirectory() + index.data().toString(); + if (previousPath == newPath) { + return; + } + + if (QFile::rename(previousPath, newPath)) { + qCDebug(PLUGIN_SCRATCHPAD) << "renamed" << previousPath << "to" << newPath; + + m_model->setData(index, newPath, Scratchpad::FullPathRole); + m_model->itemFromIndex(index)->setIcon(m_iconProvider.icon(QFileInfo(newPath))); + + // close old and re-open the closed document + if (auto* document = core()->documentController()->documentForUrl(QUrl::fromLocalFile(previousPath))) { + // is there a better way ? + document->close(); + document = core()->documentController()->openDocument(QUrl::fromLocalFile(newPath)); + document->setPrettyName(i18n("scratch:%1", index.data().toString())); + } + } else { + qCWarning(PLUGIN_SCRATCHPAD) << "failed renaming" << previousPath << "to" << newPath; + // rollback + m_model->setData(index, previousName); + emit actionFailed(i18n("Failed renaming scratch.")); + } +} + + +void Scratchpad::addFileToModel(const QFileInfo& fileInfo) +{ + auto* const item = new QStandardItem(m_iconProvider.icon(fileInfo), fileInfo.fileName()); + item->setData(fileInfo.absoluteFilePath()); + m_model->appendRow(item); +} + +#include "scratchpad.moc" diff --git a/plugins/scratchpad/scratchpad.json b/plugins/scratchpad/scratchpad.json new file mode 100644 --- /dev/null +++ b/plugins/scratchpad/scratchpad.json @@ -0,0 +1,12 @@ +{ + "KPlugin": { + "Description": "Easily create and run code scratches or notes", + "Id": "scratchpad", + "Name": "Scratchpad", + "ServiceTypes": [ + "KDevelop/Plugin" + ] + }, + "X-KDevelop-Category": "Global", + "X-KDevelop-Mode": "GUI" +} diff --git a/plugins/scratchpad/scratchpadjob.h b/plugins/scratchpad/scratchpadjob.h new file mode 100644 --- /dev/null +++ b/plugins/scratchpad/scratchpadjob.h @@ -0,0 +1,60 @@ +/* This file is part of KDevelop + * + * Copyright 2018 Amish K. Naidu + * + * 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 2 + * 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, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +#ifndef SCRATCHPADJOB_H +#define SCRATCHPADJOB_H + +#include + +#include + + +namespace KDevelop { +class OutputModel; +class ProcessLineMaker; +} +class KProcess; +class QStandardItem; + +class ScratchpadJob + : public KDevelop::OutputJob +{ + Q_OBJECT + +public: + ScratchpadJob(const QStandardItem* item, const QString& compiler, + const QString& command, QObject* parent); + + void start() override; + bool doKill() override; + +private Q_SLOTS: + void processFinished(int exitCode, QProcess::ExitStatus status); + void processError(QProcess::ProcessError error); + +private: + KDevelop::OutputModel* outputModel(); + + const QStandardItem* const m_item; + KProcess* m_process; + KDevelop::ProcessLineMaker* m_lineMaker; +}; + +#endif // SCRATCHPADJOB_H diff --git a/plugins/scratchpad/scratchpadjob.cpp b/plugins/scratchpad/scratchpadjob.cpp new file mode 100644 --- /dev/null +++ b/plugins/scratchpad/scratchpadjob.cpp @@ -0,0 +1,105 @@ +/* This file is part of KDevelop + * + * Copyright 2018 Amish K. Naidu + * + * 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 2 + * 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, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +#include "scratchpadjob.h" +#include "scratchpad.h" + +#include + +#include +#include + +#include +#include + +#include +#include + +ScratchpadJob::ScratchpadJob(const QStandardItem* item, const QString& compiler, + const QString& command, QObject* parent) + : KDevelop::OutputJob(parent) + , m_item(item) + , m_process(new KProcess(this)) + , m_lineMaker(new KDevelop::ProcessLineMaker(m_process, this)) +{ + qCDebug(PLUGIN_SCRATCHPAD) << "Creating job for" << item->text(); + + QString substituted = command; + substituted.replace(QStringLiteral("$cc"), compiler); + substituted.replace(QStringLiteral("$f"), item->data(Scratchpad::FullPathRole).toString()); + + if (!substituted.isEmpty()) { + m_process->setShellCommand(substituted); + setCapabilities(Killable); + + setStandardToolView(KDevelop::IOutputView::RunView); + setTitle(i18n("scratch:%1", item->text())); + + auto* model = new KDevelop::OutputModel(this); + setModel(model); + + connect(m_lineMaker, &KDevelop::ProcessLineMaker::receivedStdoutLines, + model, &KDevelop::OutputModel::appendLines); + connect(m_lineMaker, &KDevelop::ProcessLineMaker::receivedStderrLines, + model, &KDevelop::OutputModel::appendLines); + m_process->setOutputChannelMode(KProcess::MergedChannels); + connect(m_process, QOverload::of(&KProcess::finished), + this, &ScratchpadJob::processFinished); + connect(m_process, &KProcess::errorOccurred, this, &ScratchpadJob::processError); + } else { + qCCritical(PLUGIN_SCRATCHPAD) << "Empty command in scratch job."; + deleteLater(); + } +} + +void ScratchpadJob::start() +{ + startOutput(); + outputModel()->appendLine(i18n("Running %1...", m_process->program().join(QLatin1Char(' ')))); + m_process->start(); +} + +bool ScratchpadJob::doKill() +{ + qCDebug(PLUGIN_SCRATCHPAD) << "killing process"; + m_process->kill(); + return true; +} + +void ScratchpadJob::processFinished(int exitCode, QProcess::ExitStatus) +{ + qCDebug(PLUGIN_SCRATCHPAD) << "finished process"; + m_lineMaker->flushBuffers(); + outputModel()->appendLine(i18n("Process finished with exit code %1.", exitCode)); + emitResult(); +} + +void ScratchpadJob::processError(QProcess::ProcessError error) +{ + qCDebug(PLUGIN_SCRATCHPAD) << "process encountered error" << error; + outputModel()->appendLine(i18n("Process encountered error: %1.", + QLatin1String(QMetaEnum::fromType().valueToKey(error)))); + emitResult(); +} + +KDevelop::OutputModel* ScratchpadJob::outputModel() +{ + return static_cast(model()); +} diff --git a/plugins/scratchpad/scratchpadview.h b/plugins/scratchpad/scratchpadview.h new file mode 100644 --- /dev/null +++ b/plugins/scratchpad/scratchpadview.h @@ -0,0 +1,52 @@ +/* This file is part of KDevelop + * + * Copyright 2018 Amish K. Naidu + * + * 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 2 + * 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, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +#ifndef SCRATCHPADVIEW_H +#define SCRATCHPADVIEW_H + +#include + +#include "ui_scratchpadview.h" + +class Scratchpad; + +class QModelIndex; +class QLineEdit; + +class ScratchpadView + : public QWidget + , public Ui::ScratchpadBaseView +{ + Q_OBJECT + +public: + ScratchpadView(QWidget* parent, Scratchpad* scratchpad); + +private Q_SLOTS: + void runSelectedScratch(); + void saveCommandToConfig(); + +private: + void setupActions(); + Scratchpad* m_scratchpad; + QLineEdit* m_filter = nullptr; +}; + +#endif // SCRATCHPADVIEW_H diff --git a/plugins/scratchpad/scratchpadview.cpp b/plugins/scratchpad/scratchpadview.cpp new file mode 100644 --- /dev/null +++ b/plugins/scratchpad/scratchpadview.cpp @@ -0,0 +1,178 @@ +/* This file is part of KDevelop + * + * Copyright 2018 Amish K. Naidu + * + * 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 2 + * 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, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +#include "scratchpadview.h" +#include "scratchpad.h" + +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +// Use a delegate because the dataChanged signal doesn't tell us the previous name +class FileRenameDelegate + : public QStyledItemDelegate +{ +public: + FileRenameDelegate(QObject* parent, Scratchpad* scratchpad) + : QStyledItemDelegate(parent) + , m_scratchpad(scratchpad) + { + } + + void setModelData(QWidget* editor, QAbstractItemModel* model, const QModelIndex& index) const override + { + const QString previousName = index.data().toString(); + QStyledItemDelegate::setModelData(editor, model, index); + const auto* proxyModel = static_cast(model); + m_scratchpad->renameScratch(proxyModel->mapToSource(index), previousName); + } +private: + Scratchpad* m_scratchpad; +}; + +namespace { +QModelIndex currentSourceIndex(const QAbstractItemView* view) +{ + const auto* model = static_cast(view->model()); + return model->mapToSource(view->currentIndex()); +} +} + +ScratchpadView::ScratchpadView(QWidget* parent, Scratchpad* scratchpad) + : QWidget(parent) + , m_scratchpad(scratchpad) +{ + setupUi(this); + + setupActions(); + + setWindowTitle(i18n("Scratchpad")); + setWindowIcon(QIcon::fromTheme(QStringLiteral("note"))); + + auto* const modelProxy = new QSortFilterProxyModel(this); + modelProxy->setSourceModel(m_scratchpad->model()); + modelProxy->setFilterCaseSensitivity(Qt::CaseInsensitive); + modelProxy->setSortCaseSensitivity(Qt::CaseInsensitive); + modelProxy->setSortRole(Qt::DisplayRole); + connect(m_filter, &QLineEdit::textEdited, + modelProxy, &QSortFilterProxyModel::setFilterWildcard); + + scratchTree->setModel(modelProxy); + scratchTree->setItemDelegate(new FileRenameDelegate(this, m_scratchpad)); + + connect(scratchTree, &QListView::activated, scratchpad, &Scratchpad::openScratch); + + connect(m_scratchpad, &Scratchpad::actionFailed, [this](const QString& message) { + KMessageBox::sorry(this, message); + }); + + // TODO update when settings are changed + const auto* compilerProvider = SettingsManager::globalInstance()->provider(); + for (const auto compiler : compilerProvider->compilers()) { + qCDebug(PLUGIN_SCRATCHPAD) << "Added compiler" << compiler->name() << compiler->path(); + compilerBox->addItem(compiler->name(), compiler->path()); + } + + // TODO cross-platform and other languages + auto config = KSharedConfig::openConfig()->group("Scratchpad"); + auto commandTemplate = config.readEntry("command", QStringLiteral("$cc -lstdc++ -o /tmp/a.out $f && /tmp/a.out")); + commandWidget->setText(commandTemplate); + connect(commandWidget, &QLineEdit::returnPressed, this, &ScratchpadView::runSelectedScratch); + connect(commandWidget, &QLineEdit::returnPressed, this, &ScratchpadView::saveCommandToConfig); + + // change active scratch when changing document + connect(KDevelop::ICore::self()->documentController(), &KDevelop::IDocumentController::documentActivated, + [this](const KDevelop::IDocument* document) { + if (document->url().isLocalFile()) { + const auto* model = scratchTree->model(); + const auto index = model->match(model->index(0, 0), Scratchpad::FullPathRole, + document->url().toLocalFile()).value({}); + if (index.isValid()) { + scratchTree->setCurrentIndex(index); + } + } + }); +} + +void ScratchpadView::setupActions() +{ + QAction* action = new QAction(QIcon::fromTheme(QStringLiteral("list-add")), i18n("New Scratch"), this); + connect(action, &QAction::triggered, m_scratchpad, &Scratchpad::createScratch); + addAction(action); + + action = new QAction(QIcon::fromTheme(QStringLiteral("list-remove")), i18n("Remove Scratch"), this); + connect(action, &QAction::triggered, [this] { + if (scratchTree->currentIndex().isValid()) { + m_scratchpad->removeScratch(currentSourceIndex(scratchTree)); + } + }); + addAction(action); + + action = new QAction(QIcon::fromTheme(QStringLiteral("edit-rename")), i18n("Rename Scratch"), this); + connect(action, &QAction::triggered, [this] { + if (scratchTree->currentIndex().isValid()) { + scratchTree->edit(scratchTree->currentIndex()); + } + }); + addAction(action); + + action = new QAction(QIcon::fromTheme(QStringLiteral("media-playback-start")), i18n("Run Scratch"), this); + connect(action, &QAction::triggered, this, &ScratchpadView::runSelectedScratch); + addAction(action); + + m_filter = new QLineEdit(this); + m_filter->setPlaceholderText(i18n("Filter...")); + auto filterAction = new QWidgetAction(this); + filterAction->setDefaultWidget(m_filter); + addAction(filterAction); +} + +void ScratchpadView::runSelectedScratch() +{ + if (scratchTree->currentIndex().isValid()) { + m_scratchpad->runScratch(currentSourceIndex(scratchTree), + compilerBox->currentData().toString(), + commandWidget->text()); + } +} + +void ScratchpadView::saveCommandToConfig() +{ + auto config = KSharedConfig::openConfig()->group("Scratchpad"); + config.writeEntry("command", commandWidget->text()); +} + diff --git a/plugins/scratchpad/scratchpadview.ui b/plugins/scratchpad/scratchpadview.ui new file mode 100644 --- /dev/null +++ b/plugins/scratchpad/scratchpadview.ui @@ -0,0 +1,50 @@ + + + Robert Gruber <rgruber@users.salomon.at> + ScratchpadBaseView + + + + 0 + 0 + 232 + 389 + + + + + 2 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + + + + + + scratchTree + + + +