From 5ec52baa3fd984bf452efccb41e618b47c112028 Mon Sep 17 00:00:00 2001 From: wheaney <42350981+wheaney@users.noreply.github.com> Date: Sat, 20 Sep 2025 17:44:00 -0700 Subject: [PATCH] Add support for custom resolutions Add build-time check for qt6-quick3d --- kwin/CMakeLists.txt | 28 +-- kwin/cmake/info.cmake | 6 +- kwin/cmake/qtversion.cmake | 7 - kwin/src/kcm/CMakeLists.txt | 13 +- kwin/src/kcm/breezydesktopeffectkcm.cpp | 260 ++++++++++++++++++++---- kwin/src/kcm/breezydesktopeffectkcm.ui | 42 +++- kwin/src/kcm/customresolutiondialog.cpp | 20 ++ kwin/src/kcm/customresolutiondialog.h | 18 ++ kwin/src/kcm/customresolutiondialog.ui | 209 +++++++++++++++++++ kwin/src/kcm/virtualdisplayrow.cpp | 27 +++ kwin/src/kcm/virtualdisplayrow.h | 21 ++ kwin/src/kcm/virtualdisplayrow.ui | 56 +++++ 12 files changed, 631 insertions(+), 76 deletions(-) delete mode 100644 kwin/cmake/qtversion.cmake create mode 100644 kwin/src/kcm/customresolutiondialog.cpp create mode 100644 kwin/src/kcm/customresolutiondialog.h create mode 100644 kwin/src/kcm/customresolutiondialog.ui create mode 100644 kwin/src/kcm/virtualdisplayrow.cpp create mode 100644 kwin/src/kcm/virtualdisplayrow.h create mode 100644 kwin/src/kcm/virtualdisplayrow.ui diff --git a/kwin/CMakeLists.txt b/kwin/CMakeLists.txt index c3e8411..00d4381 100644 --- a/kwin/CMakeLists.txt +++ b/kwin/CMakeLists.txt @@ -4,10 +4,6 @@ project(breezy_desktop VERSION 0.0.1 LANGUAGES CXX) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) -set(CMAKE_CXX_STANDARD 20) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) - include(cmake/default-vars.cmake) find_package(ECM "5.100" REQUIRED NO_MODULE) @@ -16,16 +12,13 @@ set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH} ${ECM_KDE_MODULE_DIR} ) -include(cmake/qtversion.cmake) include(FeatureSummary) include(KDEInstallDirs) include(KDECMakeSettings) include(KDECompilerSettings NO_POLICY_SCOPE) -include(cmake/qtversion.cmake) - # required frameworks by Core -find_package(KF${QT_MAJOR_VERSION} ${KF_MIN_VERSION} REQUIRED COMPONENTS +find_package(KF6 REQUIRED COMPONENTS Config ConfigWidgets CoreAddons @@ -36,16 +29,25 @@ find_package(KF${QT_MAJOR_VERSION} ${KF_MIN_VERSION} REQUIRED COMPONENTS XmlGui ) -if(${QT_MAJOR_VERSION} EQUAL 6) - find_package(KF${QT_MAJOR_VERSION} ${KF_MIN_VERSION} REQUIRED COMPONENTS KCMUtils) - find_package(KWin REQUIRED COMPONENTS kwineffects) - message(STATUS "Found KWin Version: ${KWin_VERSION}") -endif () +find_package(KWin REQUIRED COMPONENTS kwineffects) +message(STATUS "Found KWin Version: ${KWin_VERSION}") include(cmake/info.cmake) find_package(epoxy REQUIRED) find_package(XCB REQUIRED COMPONENTS XCB) find_package(KWinDBusInterface CONFIG REQUIRED) +find_library(QT6_QUICK3D_LIB + NAMES Qt6Quick3D Qt6Quick3D.so Qt6Quick3D.so.6 + PATH_SUFFIXES lib +) +if(NOT QT6_QUICK3D_LIB) + message(FATAL_ERROR "Qt6 Quick3D runtime library not found (QtQuick3D). Please install the Qt6 Quick3D runtime.") +endif() +set_package_properties(Qt6Quick3D PROPERTIES + TYPE RUNTIME + PURPOSE "Required at runtime for 3D rendering (Qt Quick 3D)." +) + add_subdirectory(src) ki18n_install(po) diff --git a/kwin/cmake/info.cmake b/kwin/cmake/info.cmake index c965381..54afa0e 100644 --- a/kwin/cmake/info.cmake +++ b/kwin/cmake/info.cmake @@ -1,8 +1,4 @@ -if(${KF_MIN_VERSION} EQUAL 6) - set(KWIN_EFFECT_INCLUDE_FILE "/usr/include/kwin/effect/effect.h") -else () - set(KWIN_EFFECT_INCLUDE_FILE "/usr/include/kwineffects.h") -endif () +set(KWIN_EFFECT_INCLUDE_FILE "/usr/include/kwin/effect/effect.h") execute_process( COMMAND sh -c "grep '#define KWIN_EFFECT_API_VERSION_MINOR' ${KWIN_EFFECT_INCLUDE_FILE} | awk '{print \$NF}'" OUTPUT_VARIABLE KWIN_EFFECT_API_VERSION_MINOR OUTPUT_STRIP_TRAILING_WHITESPACE diff --git a/kwin/cmake/qtversion.cmake b/kwin/cmake/qtversion.cmake deleted file mode 100644 index eb5ec71..0000000 --- a/kwin/cmake/qtversion.cmake +++ /dev/null @@ -1,7 +0,0 @@ -find_package(KF6 QUIET COMPONENTS ConfigWidgets) - -if(${KF6_FOUND} EQUAL 0) - set(QT_MIN_VERSION "5.15") - set(QT_MAJOR_VERSION 5) - set(KF_MIN_VERSION "5.78") -endif () diff --git a/kwin/src/kcm/CMakeLists.txt b/kwin/src/kcm/CMakeLists.txt index 6911077..b9cd832 100644 --- a/kwin/src/kcm/CMakeLists.txt +++ b/kwin/src/kcm/CMakeLists.txt @@ -1,5 +1,14 @@ -set(breezy_desktop_config_SOURCES breezydesktopeffectkcm.cpp labeledslider.cpp) -ki18n_wrap_ui(breezy_desktop_config_SOURCES breezydesktopeffectkcm.ui) +set(breezy_desktop_config_SOURCES + breezydesktopeffectkcm.cpp + labeledslider.cpp + customresolutiondialog.cpp + virtualdisplayrow.cpp +) +ki18n_wrap_ui(breezy_desktop_config_SOURCES + breezydesktopeffectkcm.ui + customresolutiondialog.ui + virtualdisplayrow.ui +) qt_add_dbus_interface(breezy_desktop_config_SOURCES ${KWIN_EFFECTS_INTERFACE} kwineffects_interface) kcoreaddons_add_plugin(breezy_desktop_config INSTALL_NAMESPACE "plasma/kcms" SOURCES ${breezy_desktop_config_SOURCES}) diff --git a/kwin/src/kcm/breezydesktopeffectkcm.cpp b/kwin/src/kcm/breezydesktopeffectkcm.cpp index 12ad2b8..fc34e0f 100644 --- a/kwin/src/kcm/breezydesktopeffectkcm.cpp +++ b/kwin/src/kcm/breezydesktopeffectkcm.cpp @@ -3,6 +3,8 @@ #include "breezydesktopconfig.h" #include "labeledslider.h" #include "xrdriveripc.h" +#include "customresolutiondialog.h" +#include "virtualdisplayrow.h" #include @@ -11,6 +13,7 @@ #include #include #include +#include #include #include @@ -35,11 +38,150 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include Q_LOGGING_CATEGORY(KWIN_XR, "kwin.xr") static const char EFFECT_GROUP[] = "Effect-breezy_desktop"; +namespace { +// Roles for QComboBox items +constexpr int ROLE_SIZE = Qt::UserRole + 1; // QVariant::fromValue(QSize) +constexpr int ROLE_IS_CUSTOM = Qt::UserRole + 2; // bool +constexpr int ROLE_IS_ADD_CUSTOM = Qt::UserRole + 3; // bool + +QString stateDirPath() +{ + const QString fallback = QDir::homePath() + QStringLiteral("/.local/state"); + const QString base = qEnvironmentVariable("XDG_STATE_HOME", fallback); + return QDir::cleanPath(base + QStringLiteral("/breezy_kwin")); +} + +QString customResolutionsFilePath() +{ + return stateDirPath() + QStringLiteral("/custom_resolutions.json"); +} + +QStringList loadCustomResolutions() +{ + QFile f(customResolutionsFilePath()); + if (!f.exists()) return {}; + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return {}; + const QByteArray data = f.readAll(); + f.close(); + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (!doc.isArray()) return {}; + QStringList out; + const QJsonArray arr = doc.array(); + for (const QJsonValue &v : arr) { + if (!v.isString()) continue; + const QString s = v.toString().trimmed(); + if (s.isEmpty()) continue; + if (!out.contains(s)) out << s; // dedupe while reading to keep UI clean + } + return out; +} + +void saveCustomResolutions(const QStringList &list) +{ + QDir().mkpath(stateDirPath()); + QFile f(customResolutionsFilePath()); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) return; + QJsonArray arr; + for (const QString &s : list) arr.push_back(s); + const QJsonDocument doc(arr); + f.write(doc.toJson(QJsonDocument::Compact)); + f.close(); +} + +bool parseResString(const QString &text, int &w, int &h) +{ + const QString t = text.trimmed().toLower(); + const QString xChar = QString::fromUtf8("x"); + const QString multChar = QString::fromUtf8("×"); + QString s = t; + s.replace(multChar, xChar); + const QStringList parts = s.split(QLatin1Char('x'), Qt::SkipEmptyParts); + if (parts.size() != 2) return false; + bool okW=false, okH=false; + int ww = parts[0].toInt(&okW); + int hh = parts[1].toInt(&okH); + if (!okW || !okH) return false; + if (ww < 320 || hh < 200) return false; + if (ww > 32768 || hh > 32768) return false; + w = ww; h = hh; return true; +} + +void addResolutionItem(QComboBox *combo, QString label, QSize resolution, bool isCustom, bool isAddCustom) { + combo->addItem(label); + combo->setItemData(combo->count()-1, QVariant::fromValue(resolution), ROLE_SIZE); + combo->setItemData(combo->count()-1, isCustom, ROLE_IS_CUSTOM); + combo->setItemData(combo->count()-1, isAddCustom, ROLE_IS_ADD_CUSTOM); +} + +void populateResolutionCombo(QComboBox *combo, const QStringList &custom) +{ + if (!combo) return; + combo->clear(); + + addResolutionItem(combo, QStringLiteral("1080p"), QSize(1920,1080), false, false); + addResolutionItem(combo, QStringLiteral("1440p"), QSize(2560,1440), false, false); + + for (const QString &res : custom) { + int w=0,h=0; + if (!parseResString(res, w, h)) continue; + const QString label = QStringLiteral("%1x%2").arg(w).arg(h); + addResolutionItem(combo, label, QSize(w,h), true, false); + } + + addResolutionItem(combo, QObject::tr("Add custom…"), QSize(), false, true); + + combo->setCurrentIndex(0); +} + +bool isCustomIndex(const QComboBox *combo, int index) +{ + if (!combo || index < 0 || index >= combo->count()) return false; + return combo->itemData(index, ROLE_IS_CUSTOM).toBool(); +} + +bool isAddCustomIndex(const QComboBox *combo, int index) +{ + if (!combo || index < 0 || index >= combo->count()) return false; + return combo->itemData(index, ROLE_IS_ADD_CUSTOM).toBool(); +} + +QSize sizeForIndex(const QComboBox *combo, int index) +{ + if (!combo || index < 0 || index >= combo->count()) return {}; + QVariant v = combo->itemData(index, ROLE_SIZE); + if (!v.isValid()) return {}; + return v.toSize(); +} + +bool showCustomResolutionDialog(QWidget *parent, int &outW, int &outH) +{ + CustomResolutionDialog dlg(parent); + const int res = dlg.exec(); + if (res == QDialog::Accepted) { + outW = dlg.widthValue(); + outH = dlg.heightValue(); + return true; + } + return false; +} + +} + void addShortcutAction(KActionCollection *collection, const BreezyShortcuts::Shortcut &shortcut) { QAction *action = collection->addAction(shortcut.actionName); @@ -76,6 +218,76 @@ BreezyDesktopEffectConfig::BreezyDesktopEffectConfig(QObject *parent, const KPlu chk->setVisible(true); chk->setEnabled(true); } + + // Initialize the resolution picker controls + if (auto combo = widget()->findChild(QStringLiteral("comboAddVirtualDisplay"))) { + QStringList custom = loadCustomResolutions(); + populateResolutionCombo(combo, custom); + + auto removeBtn = widget()->findChild(QStringLiteral("buttonRemoveCustomResolution")); + auto addBtn = widget()->findChild(QStringLiteral("buttonAddVirtualDisplay")); + + combo->setProperty("lastResIndex", 0); + + auto updateRemoveUi = [combo, removeBtn, addBtn]() { + if (!removeBtn) return; + const bool customSel = isCustomIndex(combo, combo->currentIndex()); + removeBtn->setEnabled(customSel); + removeBtn->setVisible(customSel); + if (addBtn) addBtn->setEnabled(!isAddCustomIndex(combo, combo->currentIndex())); + }; + + connect(combo, qOverload(&QComboBox::currentIndexChanged), this, [this, combo, updateRemoveUi]() { + const int idx = combo->currentIndex(); + if (isAddCustomIndex(combo, idx)) { + const int oldIdx = combo->property("lastResIndex").toInt(); + int w = 1920, h = 1080; + if (showCustomResolutionDialog(widget(), w, h)) { + const QString label = QStringLiteral("%1x%2").arg(w).arg(h); + QStringList custom = loadCustomResolutions(); + if (!custom.contains(label)) { + custom << label; + saveCustomResolutions(custom); + } + populateResolutionCombo(combo, custom); + const int newIndex = combo->findText(label); + if (newIndex >= 0) combo->setCurrentIndex(newIndex); + combo->setProperty("lastResIndex", combo->currentIndex()); + } else { + // Revert to previous selection if dialog cancelled + combo->setCurrentIndex(oldIdx); + } + } else { + combo->setProperty("lastResIndex", idx); + } + updateRemoveUi(); + }); + updateRemoveUi(); + + if (removeBtn) { + connect(removeBtn, &QPushButton::clicked, this, [this, combo]() { + const int idx = combo->currentIndex(); + if (!isCustomIndex(combo, idx)) return; + const QString label = combo->itemText(idx); + QStringList custom = loadCustomResolutions(); + custom.removeAll(label); + saveCustomResolutions(custom); + populateResolutionCombo(combo, custom); + }); + } + + + if (addBtn) { + connect(addBtn, &QPushButton::clicked, this, [this, combo]() { + const int idx = combo->currentIndex(); + const QSize sz = sizeForIndex(combo, idx); + if (sz.isValid()) { + auto list = dbusAddVirtualDisplay(sz.width(), sz.height()); + renderVirtualDisplays(list); + } + }); + } + } } m_statePollTimer.setInterval(2000); @@ -171,19 +383,7 @@ BreezyDesktopEffectConfig::BreezyDesktopEffectConfig(QObject *parent, const KPlu } } - // Wire Add Virtual Display buttons via DBus to the effect - if (auto btn1080p = widget()->findChild("buttonAdd1080p")) { - connect(btn1080p, &QPushButton::clicked, this, [this]() { - auto list = dbusAddVirtualDisplay(1920, 1080); - renderVirtualDisplays(list); - }); - } - if (auto btn1440p = widget()->findChild("buttonAdd1440p")) { - connect(btn1440p, &QPushButton::clicked, this, [this]() { - auto list = dbusAddVirtualDisplay(2560, 1440); - renderVirtualDisplays(list); - }); - } + // Resolution picker wiring handled above in Wayland section if (auto lookAheadOverrideSlider = widget()->findChild("kcfg_LookAheadOverride")) { lookAheadOverrideSlider->setValueText(-1, i18n("Default")); } @@ -381,38 +581,12 @@ void BreezyDesktopEffectConfig::renderVirtualDisplays(const QVariantList &rows) const int w = unwrapValue(row.value(QStringLiteral("width"))).toInt(); const int h = unwrapValue(row.value(QStringLiteral("height"))).toInt(); - QWidget *rowWidget = new QWidget(listContainer); - auto *hl = new QHBoxLayout(rowWidget); - hl->setContentsMargins(0, 0, 0, 0); - - auto *spacer = new QWidget(rowWidget); - spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - hl->addWidget(spacer, 1); - - auto *icon = new QLabel(rowWidget); - icon->setPixmap(QIcon::fromTheme(QStringLiteral("video-display-symbolic")).pixmap(16, 16)); - icon->setContentsMargins(8, 0, 8, 0); - hl->addWidget(icon, 0); - - auto *idLabel = new QLabel(QStringLiteral("%1").arg(id), rowWidget); - idLabel->setContentsMargins(8, 0, 8, 0); - hl->addWidget(idLabel, 0); - - auto *resLabel = new QLabel(QStringLiteral("%1x%2").arg(w).arg(h), rowWidget); - resLabel->setContentsMargins(8, 0, 8, 0); - hl->addWidget(resLabel, 0); - - auto *removeBtn = new QPushButton(rowWidget); - removeBtn->setIcon(QIcon::fromTheme(QStringLiteral("user-trash-symbolic"))); - removeBtn->setToolTip(QStringLiteral("Remove virtual display")); - removeBtn->setObjectName(QStringLiteral("remove-virtual-display")); - hl->addWidget(removeBtn, 0); - - connect(removeBtn, &QPushButton::clicked, this, [this, id]() { - auto list = dbusRemoveVirtualDisplay(id); + auto *rowWidget = new VirtualDisplayRow(listContainer); + rowWidget->setInfo(id, w, h); + connect(rowWidget, &VirtualDisplayRow::removeRequested, this, [this](const QString &vid) { + auto list = dbusRemoveVirtualDisplay(vid); renderVirtualDisplays(list); }); - listLayout->addWidget(rowWidget); } } diff --git a/kwin/src/kcm/breezydesktopeffectkcm.ui b/kwin/src/kcm/breezydesktopeffectkcm.ui index 61a1ea9..ab2c0cf 100644 --- a/kwin/src/kcm/breezydesktopeffectkcm.ui +++ b/kwin/src/kcm/breezydesktopeffectkcm.ui @@ -219,17 +219,47 @@ - - - + 1080p + + + + 1080p + + + + + 1440p + + + + + Add custom… + + + + + + + + Remove custom resolution + + + + + true + + false + + + false - - - + 1440p + + + + true diff --git a/kwin/src/kcm/customresolutiondialog.cpp b/kwin/src/kcm/customresolutiondialog.cpp new file mode 100644 index 0000000..b7f98a5 --- /dev/null +++ b/kwin/src/kcm/customresolutiondialog.cpp @@ -0,0 +1,20 @@ +#include "customresolutiondialog.h" +#include "ui_customresolutiondialog.h" + +CustomResolutionDialog::CustomResolutionDialog(QWidget *parent) + : QDialog(parent), ui(new Ui::CustomResolutionDialog) +{ + ui->setupUi(this); +} + +CustomResolutionDialog::~CustomResolutionDialog() { + delete ui; +} + +int CustomResolutionDialog::widthValue() const { + return ui->sliderWidth->value(); +} + +int CustomResolutionDialog::heightValue() const { + return ui->sliderHeight->value(); +} diff --git a/kwin/src/kcm/customresolutiondialog.h b/kwin/src/kcm/customresolutiondialog.h new file mode 100644 index 0000000..10a483d --- /dev/null +++ b/kwin/src/kcm/customresolutiondialog.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace Ui { class CustomResolutionDialog; } + +class CustomResolutionDialog : public QDialog { + Q_OBJECT +public: + explicit CustomResolutionDialog(QWidget *parent = nullptr); + ~CustomResolutionDialog() override; + + int widthValue() const; + int heightValue() const; + +private: + Ui::CustomResolutionDialog *ui; +}; diff --git a/kwin/src/kcm/customresolutiondialog.ui b/kwin/src/kcm/customresolutiondialog.ui new file mode 100644 index 0000000..3683327 --- /dev/null +++ b/kwin/src/kcm/customresolutiondialog.ui @@ -0,0 +1,209 @@ + + + CustomResolutionDialog + + + Add custom resolution + + + + + + + + Width + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + 640 + + + 3840 + + + 2 + + + 10 + + + 1920 + + + + + + + 50 + + + Qt::AlignRight|Qt::AlignVCenter + + + 1920 + + + + + + + + + + Height + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + 480 + + + 2160 + + + 2 + + + 10 + + + 1080 + + + + + + + 50 + + + Qt::AlignRight|Qt::AlignVCenter + + + 1080 + + + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + sliderWidth + valueChanged(int) + labelWidthValue + setNum(int) + + + 264 + 50 + + + 475 + 50 + + + + + sliderHeight + valueChanged(int) + labelHeightValue + setNum(int) + + + 264 + 100 + + + 475 + 100 + + + + + buttonBox + accepted() + CustomResolutionDialog + accept() + + + 316 + 185 + + + 179 + 93 + + + + + buttonBox + rejected() + CustomResolutionDialog + reject() + + + 421 + 185 + + + 244 + 93 + + + + + diff --git a/kwin/src/kcm/virtualdisplayrow.cpp b/kwin/src/kcm/virtualdisplayrow.cpp new file mode 100644 index 0000000..e642b1d --- /dev/null +++ b/kwin/src/kcm/virtualdisplayrow.cpp @@ -0,0 +1,27 @@ +#include "virtualdisplayrow.h" +#include "ui_virtualdisplayrow.h" + +#include + +VirtualDisplayRow::VirtualDisplayRow(QWidget *parent) + : QWidget(parent), ui(new Ui::VirtualDisplayRow) +{ + ui->setupUi(this); + // Set themed icons at runtime to honor system theme + ui->icon->setPixmap(QIcon::fromTheme(QStringLiteral("video-display-symbolic")).pixmap(16, 16)); + ui->buttonRemove->setIcon(QIcon::fromTheme(QStringLiteral("user-trash-symbolic"))); + + connect(ui->buttonRemove, &QPushButton::clicked, this, [this]() { + Q_EMIT removeRequested(m_id); + }); +} + +VirtualDisplayRow::~VirtualDisplayRow() { + delete ui; +} + +void VirtualDisplayRow::setInfo(const QString &id, int w, int h) { + m_id = id; + ui->labelId->setText(id); + ui->labelRes->setText(QStringLiteral("%1x%2").arg(w).arg(h)); +} diff --git a/kwin/src/kcm/virtualdisplayrow.h b/kwin/src/kcm/virtualdisplayrow.h new file mode 100644 index 0000000..92be208 --- /dev/null +++ b/kwin/src/kcm/virtualdisplayrow.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace Ui { class VirtualDisplayRow; } + +class VirtualDisplayRow : public QWidget { + Q_OBJECT +public: + explicit VirtualDisplayRow(QWidget *parent = nullptr); + ~VirtualDisplayRow() override; + + void setInfo(const QString &id, int w, int h); + +Q_SIGNALS: + void removeRequested(const QString &id); + +private: + Ui::VirtualDisplayRow *ui; + QString m_id; +}; diff --git a/kwin/src/kcm/virtualdisplayrow.ui b/kwin/src/kcm/virtualdisplayrow.ui new file mode 100644 index 0000000..0a8bd09 --- /dev/null +++ b/kwin/src/kcm/virtualdisplayrow.ui @@ -0,0 +1,56 @@ + + + VirtualDisplayRow + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + + + + + + + ID + + + + + + + WxH + + + + + + + Remove virtual display + + + + + + + +