diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 3d91245..a7ed6d1 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -30,6 +30,8 @@ qt_add_library(quickshell-core STATIC elapsedtimer.cpp desktopentry.cpp objectrepeater.cpp + platformmenu.cpp + qsmenu.cpp ) set_source_files_properties(main.cpp PROPERTIES COMPILE_DEFINITIONS GIT_REVISION="${GIT_REVISION}") diff --git a/src/core/generation.cpp b/src/core/generation.cpp index 2ca7a77..5f21a19 100644 --- a/src/core/generation.cpp +++ b/src/core/generation.cpp @@ -8,12 +8,17 @@ #include #include #include +#include +#include #include #include #include +#include #include #include #include +#include +#include #include #include "iconimageprovider.hpp" @@ -308,6 +313,90 @@ EngineGeneration* EngineGeneration::currentGeneration() { } else return nullptr; } +// QMenu re-calls pixmap() every time the mouse moves so its important to cache it. +class PixmapCacheIconEngine: public QIconEngine { + void paint( + QPainter* /*unused*/, + const QRect& /*unused*/, + QIcon::Mode /*unused*/, + QIcon::State /*unused*/ + ) override { + qFatal( + ) << "Unexpected icon paint request bypassed pixmap method. Please report this as a bug."; + } + + QPixmap pixmap(const QSize& size, QIcon::Mode /*unused*/, QIcon::State /*unused*/) override { + if (this->lastPixmap.isNull() || size != this->lastSize) { + this->lastPixmap = this->createPixmap(size); + this->lastSize = size; + } + + return this->lastPixmap; + } + + virtual QPixmap createPixmap(const QSize& size) = 0; + +private: + QSize lastSize; + QPixmap lastPixmap; +}; + +class ImageProviderIconEngine: public PixmapCacheIconEngine { +public: + explicit ImageProviderIconEngine(QQuickImageProvider* provider, QString id) + : provider(provider) + , id(std::move(id)) {} + + QPixmap createPixmap(const QSize& size) override { + if (this->provider->imageType() == QQmlImageProviderBase::Pixmap) { + return this->provider->requestPixmap(this->id, nullptr, size); + } else if (this->provider->imageType() == QQmlImageProviderBase::Image) { + auto image = this->provider->requestImage(this->id, nullptr, size); + return QPixmap::fromImage(image); + } else { + qFatal() << "Unexpected ImageProviderIconEngine image type" << this->provider->imageType(); + return QPixmap(); // never reached, satisfies lint + } + } + + [[nodiscard]] QIconEngine* clone() const override { + return new ImageProviderIconEngine(this->provider, this->id); + } + +private: + QQuickImageProvider* provider; + QString id; +}; + +QIcon EngineGeneration::iconByUrl(const QUrl& url) const { + if (url.isEmpty()) return QIcon(); + + auto scheme = url.scheme(); + if (scheme == "image") { + auto providerName = url.authority(); + auto path = url.path(); + if (!path.isEmpty()) path = path.sliced(1); + + auto* provider = qobject_cast(this->engine->imageProvider(providerName)); + + if (provider == nullptr) { + qWarning() << "iconByUrl failed: no provider found for" << url; + return QIcon(); + } + + if (provider->imageType() == QQmlImageProviderBase::Pixmap + || provider->imageType() == QQmlImageProviderBase::Image) + { + return QIcon(new ImageProviderIconEngine(provider, path)); + } + + } else { + qWarning() << "iconByUrl failed: unsupported scheme" << scheme << "in path" << url; + } + + return QIcon(); +} + EngineGeneration* EngineGeneration::findEngineGeneration(QQmlEngine* engine) { return g_generations.value(engine); } diff --git a/src/core/generation.hpp b/src/core/generation.hpp index 9bcb8b6..5486375 100644 --- a/src/core/generation.hpp +++ b/src/core/generation.hpp @@ -3,11 +3,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include "incubator.hpp" #include "qsintercept.hpp" @@ -40,6 +42,8 @@ public: // otherwise null. static EngineGeneration* currentGeneration(); + [[nodiscard]] QIcon iconByUrl(const QUrl& url) const; + RootWrapper* wrapper = nullptr; QDir rootPath; QmlScanner scanner; diff --git a/src/core/module.md b/src/core/module.md index 73ede34..c70b487 100644 --- a/src/core/module.md +++ b/src/core/module.md @@ -22,5 +22,6 @@ headers = [ "elapsedtimer.hpp", "desktopentry.hpp", "objectrepeater.hpp", + "qsmenu.hpp" ] ----- diff --git a/src/core/platformmenu.cpp b/src/core/platformmenu.cpp new file mode 100644 index 0000000..a2f8f81 --- /dev/null +++ b/src/core/platformmenu.cpp @@ -0,0 +1,276 @@ +#include "platformmenu.hpp" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "generation.hpp" +#include "proxywindow.hpp" +#include "qsmenu.hpp" +#include "windowinterface.hpp" + +namespace qs::menu::platform { + +namespace { +QVector> CREATION_HOOKS; // NOLINT +PlatformMenuQMenu* ACTIVE_MENU = nullptr; // NOLINT +} // namespace + +PlatformMenuQMenu::~PlatformMenuQMenu() { + if (this == ACTIVE_MENU) { + ACTIVE_MENU = nullptr; + } +} + +void PlatformMenuQMenu::setVisible(bool visible) { + if (visible) { + for (auto& hook: CREATION_HOOKS) { + hook(this); + } + } else { + if (this == ACTIVE_MENU) { + ACTIVE_MENU = nullptr; + } + } + + this->QMenu::setVisible(visible); +} + +PlatformMenuEntry::PlatformMenuEntry(QsMenuEntry* menu): QObject(menu), menu(menu) { + this->relayout(); + + // clang-format off + QObject::connect(menu, &QsMenuEntry::enabledChanged, this, &PlatformMenuEntry::onEnabledChanged); + QObject::connect(menu, &QsMenuEntry::textChanged, this, &PlatformMenuEntry::onTextChanged); + QObject::connect(menu, &QsMenuEntry::iconChanged, this, &PlatformMenuEntry::onIconChanged); + QObject::connect(menu, &QsMenuEntry::buttonTypeChanged, this, &PlatformMenuEntry::onButtonTypeChanged); + QObject::connect(menu, &QsMenuEntry::checkStateChanged, this, &PlatformMenuEntry::onCheckStateChanged); + QObject::connect(menu, &QsMenuEntry::hasChildrenChanged, this, &PlatformMenuEntry::relayoutParent); + // clang-format on +} + +PlatformMenuEntry::~PlatformMenuEntry() { + this->clearChildren(); + delete this->qaction; + delete this->qmenu; +} + +void PlatformMenuEntry::registerCreationHook(std::function hook) { + CREATION_HOOKS.push_back(std::move(hook)); +} + +bool PlatformMenuEntry::display(QObject* parentWindow, int relativeX, int relativeY) { + QWindow* window = nullptr; + + if (this->qmenu == nullptr) { + qCritical() << "Cannot display PlatformMenuEntry as it is not a menu."; + return false; + } else if (parentWindow == nullptr) { + qCritical() << "Cannot display PlatformMenuEntry with null parent window."; + return false; + } else if (auto* proxy = qobject_cast(parentWindow)) { + window = proxy->backingWindow(); + } else if (auto* interface = qobject_cast(parentWindow)) { + window = interface->proxyWindow()->backingWindow(); + } else { + qCritical() << "PlatformMenuEntry.display() must be called with a window."; + return false; + } + + if (window == nullptr) { + qCritical() << "Cannot display PlatformMenuEntry from a parent window that is not visible."; + return false; + } + + if (ACTIVE_MENU && this->qmenu != ACTIVE_MENU) { + ACTIVE_MENU->close(); + } + + ACTIVE_MENU = this->qmenu; + + auto point = window->mapToGlobal(QPoint(relativeX, relativeY)); + + this->qmenu->createWinId(); + this->qmenu->windowHandle()->setTransientParent(window); + + this->qmenu->popup(point); + + return true; +} + +void PlatformMenuEntry::relayout() { + if (this->menu->hasChildren()) { + delete this->qaction; + this->qaction = nullptr; + + if (this->qmenu == nullptr) { + this->qmenu = new PlatformMenuQMenu(); + QObject::connect(this->qmenu, &QMenu::aboutToShow, this, &PlatformMenuEntry::onAboutToShow); + QObject::connect(this->qmenu, &QMenu::aboutToHide, this, &PlatformMenuEntry::onAboutToHide); + } else { + this->clearChildren(); + } + + this->qmenu->setTitle(this->menu->text()); + + auto icon = this->menu->icon(); + if (!icon.isEmpty()) { + auto* generation = EngineGeneration::currentGeneration(); + this->qmenu->setIcon(generation->iconByUrl(this->menu->icon())); + } + + auto children = this->menu->children(); + auto len = children.count(&children); + for (auto i = 0; i < len; i++) { + auto* child = children.at(&children, i); + + auto* instance = new PlatformMenuEntry(child); + QObject::connect(instance, &QObject::destroyed, this, &PlatformMenuEntry::onChildDestroyed); + + QObject::connect( + instance, + &PlatformMenuEntry::relayoutParent, + this, + &PlatformMenuEntry::relayout + ); + + this->childEntries.push_back(instance); + instance->addToQMenu(this->qmenu); + } + } else if (!this->menu->isSeparator()) { + this->clearChildren(); + delete this->qmenu; + this->qmenu = nullptr; + + if (this->qaction == nullptr) { + this->qaction = new QAction(this); + + QObject::connect( + this->qaction, + &QAction::triggered, + this, + &PlatformMenuEntry::onActionTriggered + ); + } + + this->qaction->setText(this->menu->text()); + + auto icon = this->menu->icon(); + if (!icon.isEmpty()) { + auto* generation = EngineGeneration::currentGeneration(); + this->qaction->setIcon(generation->iconByUrl(this->menu->icon())); + } + + this->qaction->setEnabled(this->menu->enabled()); + this->qaction->setCheckable(this->menu->buttonType() != QsMenuButtonType::None); + + if (this->menu->buttonType() == QsMenuButtonType::RadioButton) { + if (!this->qactiongroup) this->qactiongroup = new QActionGroup(this); + this->qaction->setActionGroup(this->qactiongroup); + } + + this->qaction->setChecked(this->menu->checkState() != Qt::Unchecked); + } else { + delete this->qmenu; + delete this->qaction; + this->qmenu = nullptr; + this->qaction = nullptr; + } +} + +void PlatformMenuEntry::onAboutToShow() { this->menu->ref(); } + +void PlatformMenuEntry::onAboutToHide() { + this->menu->unref(); + emit this->closed(); +} + +void PlatformMenuEntry::onActionTriggered() { + auto* action = qobject_cast(this->sender()->parent()); + emit action->menu->triggered(); +} + +void PlatformMenuEntry::onChildDestroyed() { this->childEntries.removeOne(this->sender()); } + +void PlatformMenuEntry::onEnabledChanged() { + if (this->qaction != nullptr) { + this->qaction->setEnabled(this->menu->enabled()); + } +} + +void PlatformMenuEntry::onTextChanged() { + if (this->qmenu != nullptr) { + this->qmenu->setTitle(this->menu->text()); + } else if (this->qaction != nullptr) { + this->qaction->setText(this->menu->text()); + } +} + +void PlatformMenuEntry::onIconChanged() { + if (this->qmenu == nullptr && this->qaction == nullptr) return; + + auto iconName = this->menu->icon(); + QIcon icon; + + if (!iconName.isEmpty()) { + auto* generation = EngineGeneration::currentGeneration(); + icon = generation->iconByUrl(iconName); + } + + if (this->qmenu != nullptr) { + this->qmenu->setIcon(icon); + } else if (this->qaction != nullptr) { + this->qaction->setIcon(icon); + } +} + +void PlatformMenuEntry::onButtonTypeChanged() { + if (this->qaction != nullptr) { + QActionGroup* group = nullptr; + + if (this->menu->buttonType() == QsMenuButtonType::RadioButton) { + if (!this->qactiongroup) this->qactiongroup = new QActionGroup(this); + group = this->qactiongroup; + } + + this->qaction->setActionGroup(group); + } +} + +void PlatformMenuEntry::onCheckStateChanged() { + if (this->qaction != nullptr) { + this->qaction->setChecked(this->menu->checkState() != Qt::Unchecked); + } +} + +void PlatformMenuEntry::clearChildren() { + for (auto* child: this->childEntries) { + delete child; + } + + this->childEntries.clear(); +} + +void PlatformMenuEntry::addToQMenu(PlatformMenuQMenu* menu) { + if (this->qmenu != nullptr) { + menu->addMenu(this->qmenu); + this->qmenu->containingMenu = menu; + } else if (this->qaction != nullptr) { + menu->addAction(this->qaction); + } else { + menu->addSeparator(); + } +} + +} // namespace qs::menu::platform diff --git a/src/core/platformmenu.hpp b/src/core/platformmenu.hpp new file mode 100644 index 0000000..5c18a57 --- /dev/null +++ b/src/core/platformmenu.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "qsmenu.hpp" + +namespace qs::menu::platform { + +class PlatformMenuQMenu: public QMenu { +public: + explicit PlatformMenuQMenu() = default; + ~PlatformMenuQMenu() override; + Q_DISABLE_COPY_MOVE(PlatformMenuQMenu); + + void setVisible(bool visible) override; + + PlatformMenuQMenu* containingMenu = nullptr; +}; + +class PlatformMenuEntry: public QObject { + Q_OBJECT; + +public: + explicit PlatformMenuEntry(QsMenuEntry* menu); + ~PlatformMenuEntry() override; + Q_DISABLE_COPY_MOVE(PlatformMenuEntry); + + bool display(QObject* parentWindow, int relativeX, int relativeY); + + static void registerCreationHook(std::function hook); + +signals: + void closed(); + void relayoutParent(); + +public slots: + void relayout(); + +private slots: + void onAboutToShow(); + void onAboutToHide(); + void onActionTriggered(); + void onChildDestroyed(); + void onEnabledChanged(); + void onTextChanged(); + void onIconChanged(); + void onButtonTypeChanged(); + void onCheckStateChanged(); + +private: + void clearChildren(); + void addToQMenu(PlatformMenuQMenu* menu); + + QsMenuEntry* menu; + PlatformMenuQMenu* qmenu = nullptr; + QAction* qaction = nullptr; + QActionGroup* qactiongroup = nullptr; + QVector childEntries; +}; + +} // namespace qs::menu::platform diff --git a/src/core/qsmenu.cpp b/src/core/qsmenu.cpp new file mode 100644 index 0000000..e7eed3c --- /dev/null +++ b/src/core/qsmenu.cpp @@ -0,0 +1,95 @@ +#include "qsmenu.hpp" + +#include +#include +#include +#include +#include + +#include "platformmenu.hpp" + +using namespace qs::menu::platform; + +namespace qs::menu { + +QString QsMenuButtonType::toString(QsMenuButtonType::Enum value) { + switch (value) { + case QsMenuButtonType::None: return "None"; + case QsMenuButtonType::CheckBox: return "CheckBox"; + case QsMenuButtonType::RadioButton: return "RadioButton"; + default: return "Invalid button type"; + } +} + +void QsMenuEntry::display(QObject* parentWindow, int relativeX, int relativeY) { + auto* platform = new PlatformMenuEntry(this); + + QObject::connect(platform, &PlatformMenuEntry::closed, platform, [=]() { + platform->deleteLater(); + }); + + auto success = platform->display(parentWindow, relativeX, relativeY); + if (!success) delete platform; +} + +QQmlListProperty QsMenuEntry::emptyChildren(QObject* parent) { + return QQmlListProperty( + parent, + nullptr, + &QsMenuEntry::childCount, + &QsMenuEntry::childAt + ); +} + +void QsMenuEntry::ref() { + this->refcount++; + if (this->refcount == 1) emit this->opened(); +} + +void QsMenuEntry::unref() { + this->refcount--; + if (this->refcount == 0) emit this->closed(); +} + +QQmlListProperty QsMenuEntry::children() { return QsMenuEntry::emptyChildren(this); } + +QsMenuEntry* QsMenuOpener::menu() const { return this->mMenu; } + +void QsMenuOpener::setMenu(QsMenuEntry* menu) { + if (menu == this->mMenu) return; + + if (this->mMenu != nullptr) { + this->mMenu->unref(); + QObject::disconnect(this->mMenu, nullptr, this, nullptr); + } + + this->mMenu = menu; + + if (menu != nullptr) { + QObject::connect(menu, &QObject::destroyed, this, &QsMenuOpener::onMenuDestroyed); + QObject::connect(menu, &QsMenuEntry::childrenChanged, this, &QsMenuOpener::childrenChanged); + menu->ref(); + } + + emit this->menuChanged(); + emit this->childrenChanged(); +} + +void QsMenuOpener::onMenuDestroyed() { + this->mMenu = nullptr; + emit this->menuChanged(); + emit this->childrenChanged(); +} + +QQmlListProperty QsMenuOpener::children() { + return this->mMenu ? this->mMenu->children() : QsMenuEntry::emptyChildren(this); +} + +qsizetype QsMenuEntry::childCount(QQmlListProperty* /*property*/) { return 0; } + +QsMenuEntry* +QsMenuEntry::childAt(QQmlListProperty* /*property*/, qsizetype /*index*/) { + return nullptr; +} + +} // namespace qs::menu diff --git a/src/core/qsmenu.hpp b/src/core/qsmenu.hpp new file mode 100644 index 0000000..a5f3822 --- /dev/null +++ b/src/core/qsmenu.hpp @@ -0,0 +1,140 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "doc.hpp" + +namespace qs::menu { + +class QsMenuButtonType: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum { + /// This menu item does not have a checkbox or a radiobutton associated with it. + None = 0, + /// This menu item should draw a checkbox. + CheckBox = 1, + /// This menu item should draw a radiobutton. + RadioButton = 2, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(QsMenuButtonType::Enum value); +}; + +class QsMenuEntry: public QObject { + Q_OBJECT; + /// If this menu item should be rendered as a separator between other items. + /// + /// No other properties have a meaningful value when `isSeparator` is true. + Q_PROPERTY(bool isSeparator READ isSeparator NOTIFY isSeparatorChanged); + Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged); + /// Text of the menu item. + Q_PROPERTY(QString text READ text NOTIFY textChanged); + /// Url of the menu item's icon or `""` if it doesn't have one. + /// + /// This can be passed to [Image.source](https://doc.qt.io/qt-6/qml-qtquick-image.html#source-prop) + /// as shown below. + /// + /// ```qml + /// Image { + /// source: menuItem.icon + /// // To get the best image quality, set the image source size to the same size + /// // as the rendered image. + /// sourceSize.width: width + /// sourceSize.height: height + /// } + /// ``` + Q_PROPERTY(QString icon READ icon NOTIFY iconChanged); + /// If this menu item has an associated checkbox or radiobutton. + Q_PROPERTY(QsMenuButtonType::Enum buttonType READ buttonType NOTIFY buttonTypeChanged); + /// The check state of the checkbox or radiobutton if applicable, as a + /// [Qt.CheckState](https://doc.qt.io/qt-6/qt.html#CheckState-enum). + Q_PROPERTY(Qt::CheckState checkState READ checkState NOTIFY checkStateChanged); + /// If this menu item has children that can be accessed through a [QsMenuOpener](../qsmenuopener). + Q_PROPERTY(bool hasChildren READ hasChildren NOTIFY hasChildrenChanged); + QML_ELEMENT; + QML_UNCREATABLE("QsMenuEntry cannot be directly created"); + +public: + explicit QsMenuEntry(QObject* parent = nullptr): QObject(parent) {} + + /// Display a platform menu at the given location relative to the parent window. + Q_INVOKABLE void display(QObject* parentWindow, qint32 relativeX, qint32 relativeY); + + [[nodiscard]] virtual bool isSeparator() const { return false; } + [[nodiscard]] virtual bool enabled() const { return true; } + [[nodiscard]] virtual QString text() const { return ""; } + [[nodiscard]] virtual QString icon() const { return ""; } + [[nodiscard]] virtual QsMenuButtonType::Enum buttonType() const { return QsMenuButtonType::None; } + [[nodiscard]] virtual Qt::CheckState checkState() const { return Qt::Unchecked; } + [[nodiscard]] virtual bool hasChildren() const { return false; } + + void ref(); + void unref(); + + [[nodiscard]] virtual QQmlListProperty children(); + + static QQmlListProperty emptyChildren(QObject* parent); + +signals: + /// Send a trigger/click signal to the menu entry. + void triggered(); + + QSDOC_HIDE void opened(); + QSDOC_HIDE void closed(); + + void isSeparatorChanged(); + void enabledChanged(); + void textChanged(); + void iconChanged(); + void buttonTypeChanged(); + void checkStateChanged(); + void hasChildrenChanged(); + QSDOC_HIDE void childrenChanged(); + +private: + static qsizetype childCount(QQmlListProperty* property); + static QsMenuEntry* childAt(QQmlListProperty* property, qsizetype index); + + qsizetype refcount = 0; +}; + +///! Provides access to children of a QsMenuEntry +class QsMenuOpener: public QObject { + Q_OBJECT; + /// The menu to retrieve children from. + Q_PROPERTY(QsMenuEntry* menu READ menu WRITE setMenu NOTIFY menuChanged); + /// The children of the given menu. + Q_PROPERTY(QQmlListProperty children READ children NOTIFY childrenChanged); + QML_ELEMENT; + +public: + explicit QsMenuOpener(QObject* parent = nullptr): QObject(parent) {} + + [[nodiscard]] QsMenuEntry* menu() const; + void setMenu(QsMenuEntry* menu); + + [[nodiscard]] QQmlListProperty children(); + +signals: + void menuChanged(); + void childrenChanged(); + +private slots: + void onMenuDestroyed(); + +private: + QsMenuEntry* mMenu = nullptr; +}; + +} // namespace qs::menu diff --git a/src/dbus/dbusmenu/dbusmenu.cpp b/src/dbus/dbusmenu/dbusmenu.cpp index 7484d84..ae68ecd 100644 --- a/src/dbus/dbusmenu/dbusmenu.cpp +++ b/src/dbus/dbusmenu/dbusmenu.cpp @@ -21,19 +21,26 @@ #include #include "../../core/iconimageprovider.hpp" +#include "../../core/qsmenu.hpp" #include "../../dbus/properties.hpp" #include "dbus_menu.h" #include "dbus_menu_types.hpp" Q_LOGGING_CATEGORY(logDbusMenu, "quickshell.dbus.dbusmenu", QtWarningMsg); +using namespace qs::menu; + namespace qs::dbus::dbusmenu { DBusMenuItem::DBusMenuItem(qint32 id, DBusMenu* menu, DBusMenuItem* parentMenu) - : QObject(menu) + : QsMenuEntry(menu) , id(id) , menu(menu) , parentMenu(parentMenu) { + QObject::connect(this, &QsMenuEntry::opened, this, &DBusMenuItem::sendOpened); + QObject::connect(this, &QsMenuEntry::closed, this, &DBusMenuItem::sendClosed); + QObject::connect(this, &QsMenuEntry::triggered, this, &DBusMenuItem::sendTriggered); + QObject::connect( &this->menu->iconThemePath, &AbstractDBusProperty::changed, @@ -42,20 +49,13 @@ DBusMenuItem::DBusMenuItem(qint32 id, DBusMenu* menu, DBusMenuItem* parentMenu) ); } -void DBusMenuItem::click() { - if (this->displayChildren) { - this->setShowChildren(!this->mShowChildren); - } else { - this->menu->sendEvent(this->id, "clicked"); - } -} - -void DBusMenuItem::hover() const { this->menu->sendEvent(this->id, "hovered"); } +void DBusMenuItem::sendOpened() const { this->menu->sendEvent(this->id, "opened"); } +void DBusMenuItem::sendClosed() const { this->menu->sendEvent(this->id, "closed"); } +void DBusMenuItem::sendTriggered() const { this->menu->sendEvent(this->id, "clicked"); } DBusMenu* DBusMenuItem::menuHandle() const { return this->menu; } -QString DBusMenuItem::label() const { return this->mLabel; } -QString DBusMenuItem::cleanLabel() const { return this->mCleanLabel; } bool DBusMenuItem::enabled() const { return this->mEnabled; } +QString DBusMenuItem::text() const { return this->mCleanLabel; } QString DBusMenuItem::icon() const { if (!this->iconName.isEmpty()) { @@ -68,23 +68,20 @@ QString DBusMenuItem::icon() const { } else return nullptr; } -ToggleButtonType::Enum DBusMenuItem::toggleType() const { return this->mToggleType; }; +QsMenuButtonType::Enum DBusMenuItem::buttonType() const { return this->mButtonType; }; Qt::CheckState DBusMenuItem::checkState() const { return this->mCheckState; } bool DBusMenuItem::isSeparator() const { return this->mSeparator; } bool DBusMenuItem::isShowingChildren() const { return this->mShowChildren && this->childrenLoaded; } -void DBusMenuItem::setShowChildren(bool showChildren) { +void DBusMenuItem::setShowChildrenRecursive(bool showChildren) { if (showChildren == this->mShowChildren) return; this->mShowChildren = showChildren; this->childrenLoaded = false; if (showChildren) { - this->menu->prepareToShow(this->id, true); + this->menu->prepareToShow(this->id, -1); } else { - this->menu->sendEvent(this->id, "closed"); - emit this->showingChildrenChanged(); - if (!this->mChildren.isEmpty()) { for (auto child: this->mChildren) { this->menu->removeRecursive(child); @@ -96,10 +93,15 @@ void DBusMenuItem::setShowChildren(bool showChildren) { } } +void DBusMenuItem::updateLayout() const { + if (!this->isShowingChildren()) return; + this->menu->updateLayout(this->id, -1); +} + bool DBusMenuItem::hasChildren() const { return this->displayChildren; } -QQmlListProperty DBusMenuItem::children() { - return QQmlListProperty( +QQmlListProperty DBusMenuItem::children() { + return QQmlListProperty( this, nullptr, &DBusMenuItem::childrenCount, @@ -107,11 +109,11 @@ QQmlListProperty DBusMenuItem::children() { ); } -qsizetype DBusMenuItem::childrenCount(QQmlListProperty* property) { +qsizetype DBusMenuItem::childrenCount(QQmlListProperty* property) { return reinterpret_cast(property->object)->enabledChildren.count(); // NOLINT } -DBusMenuItem* DBusMenuItem::childAt(QQmlListProperty* property, qsizetype index) { +QsMenuEntry* DBusMenuItem::childAt(QQmlListProperty* property, qsizetype index) { auto* item = reinterpret_cast(property->object); // NOLINT return item->menu->items.value(item->enabledChildren.at(index)); } @@ -124,30 +126,30 @@ void DBusMenuItem::updateProperties(const QVariantMap& properties, const QString return; } - auto originalLabel = this->mLabel; + auto originalText = this->mText; //auto originalMnemonic = this->mnemonic; auto originalEnabled = this->mEnabled; auto originalVisible = this->visible; auto originalIconName = this->iconName; auto* originalImage = this->image; auto originalIsSeparator = this->mSeparator; - auto originalToggleType = this->mToggleType; + auto originalButtonType = this->mButtonType; auto originalToggleState = this->mCheckState; auto originalDisplayChildren = this->displayChildren; auto label = properties.value("label"); if (label.canConvert()) { auto text = label.value(); - this->mLabel = text; + this->mText = text; this->mCleanLabel = text; //this->mnemonic = QChar(); - for (auto i = 0; i < this->mLabel.length() - 1;) { - if (this->mLabel.at(i) == '_') { + for (auto i = 0; i < this->mText.length() - 1;) { + if (this->mText.at(i) == '_') { //if (this->mnemonic == QChar()) this->mnemonic = this->mLabel.at(i + 1); - this->mLabel.remove(i, 1); - this->mLabel.insert(i + 1, ""); - this->mLabel.insert(i, ""); + this->mText.remove(i, 1); + this->mText.insert(i + 1, ""); + this->mText.insert(i, ""); i += 8; } else { i++; @@ -160,7 +162,7 @@ void DBusMenuItem::updateProperties(const QVariantMap& properties, const QString } } } else if (removed.isEmpty() || removed.contains("label")) { - this->mLabel = ""; + this->mText = ""; //this->mnemonic = QChar(); } @@ -208,15 +210,15 @@ void DBusMenuItem::updateProperties(const QVariantMap& properties, const QString if (toggleType.canConvert()) { auto toggleTypeStr = toggleType.value(); - if (toggleTypeStr == "") this->mToggleType = ToggleButtonType::None; - else if (toggleTypeStr == "checkmark") this->mToggleType = ToggleButtonType::CheckBox; - else if (toggleTypeStr == "radio") this->mToggleType = ToggleButtonType::RadioButton; + if (toggleTypeStr == "") this->mButtonType = QsMenuButtonType::None; + else if (toggleTypeStr == "checkmark") this->mButtonType = QsMenuButtonType::CheckBox; + else if (toggleTypeStr == "radio") this->mButtonType = QsMenuButtonType::RadioButton; else { qCWarning(logDbusMenu) << "Unrecognized toggle type" << toggleTypeStr << "for" << this; - this->mToggleType = ToggleButtonType::None; + this->mButtonType = QsMenuButtonType::None; } } else if (removed.isEmpty() || removed.contains("toggle-type")) { - this->mToggleType = ToggleButtonType::None; + this->mButtonType = QsMenuButtonType::None; } auto toggleState = properties.value("toggle-state"); @@ -227,7 +229,7 @@ void DBusMenuItem::updateProperties(const QVariantMap& properties, const QString else if (toggleStateInt == 1) this->mCheckState = Qt::Checked; else this->mCheckState = Qt::PartiallyChecked; } else if (removed.isEmpty() || removed.contains("toggle-state")) { - this->mCheckState = Qt::PartiallyChecked; + this->mCheckState = Qt::Unchecked; } auto childrenDisplay = properties.value("children-display"); @@ -245,14 +247,14 @@ void DBusMenuItem::updateProperties(const QVariantMap& properties, const QString this->displayChildren = false; } - if (this->mLabel != originalLabel) emit this->labelChanged(); + if (this->mText != originalText) emit this->textChanged(); //if (this->mnemonic != originalMnemonic) emit this->labelChanged(); if (this->mEnabled != originalEnabled) emit this->enabledChanged(); if (this->visible != originalVisible && this->parentMenu != nullptr) this->parentMenu->onChildrenUpdated(); - if (this->mToggleType != originalToggleType) emit this->toggleTypeChanged(); + if (this->mButtonType != originalButtonType) emit this->buttonTypeChanged(); if (this->mCheckState != originalToggleState) emit this->checkStateChanged(); - if (this->mSeparator != originalIsSeparator) emit this->separatorChanged(); + if (this->mSeparator != originalIsSeparator) emit this->isSeparatorChanged(); if (this->displayChildren != originalDisplayChildren) emit this->hasChildrenChanged(); if (this->iconName != originalIconName || this->image != originalImage) { @@ -263,11 +265,11 @@ void DBusMenuItem::updateProperties(const QVariantMap& properties, const QString emit this->iconChanged(); } - qCDebug(logDbusMenu).nospace() << "Updated properties of " << this << " { label=" << this->mLabel + qCDebug(logDbusMenu).nospace() << "Updated properties of " << this << " { label=" << this->mText << ", enabled=" << this->mEnabled << ", visible=" << this->visible << ", iconName=" << this->iconName << ", iconData=" << this->image << ", separator=" << this->mSeparator - << ", toggleType=" << this->mToggleType + << ", toggleType=" << this->mButtonType << ", toggleState=" << this->mCheckState << ", displayChildren=" << this->displayChildren << " }"; } @@ -291,20 +293,7 @@ QDebug operator<<(QDebug debug, DBusMenuItem* item) { auto saver = QDebugStateSaver(debug); debug.nospace() << "DBusMenuItem(" << static_cast(item) << ", id=" << item->id - << ", label=" << item->mLabel << ", menu=" << item->menu << ")"; - return debug; -} - -QDebug operator<<(QDebug debug, const ToggleButtonType::Enum& toggleType) { - auto saver = QDebugStateSaver(debug); - debug.nospace() << "ToggleType::"; - - switch (toggleType) { - case ToggleButtonType::None: debug << "None"; break; - case ToggleButtonType::CheckBox: debug << "Checkbox"; break; - case ToggleButtonType::RadioButton: debug << "Radiobutton"; break; - } - + << ", label=" << item->mText << ", menu=" << item->menu << ")"; return debug; } @@ -334,19 +323,18 @@ DBusMenu::DBusMenu(const QString& service, const QString& path, QObject* parent) this->properties.updateAllViaGetAll(); } -void DBusMenu::prepareToShow(qint32 item, bool sendOpened) { +void DBusMenu::prepareToShow(qint32 item, qint32 depth) { auto pending = this->interface->AboutToShow(item); auto* call = new QDBusPendingCallWatcher(pending, this); - auto responseCallback = [this, item, sendOpened](QDBusPendingCallWatcher* call) { + auto responseCallback = [this, item, depth](QDBusPendingCallWatcher* call) { const QDBusPendingReply reply = *call; if (reply.isError()) { qCWarning(logDbusMenu) << "Error in AboutToShow, but showing anyway for menu" << item << "of" << this << reply.error(); } - this->updateLayout(item, 1); - if (sendOpened) this->sendEvent(item, "opened"); + this->updateLayout(item, depth); delete call; }; @@ -385,6 +373,7 @@ void DBusMenu::updateLayoutRecursive( // there is an actual nullptr in the map and not no entry if (this->items.contains(layout.id)) { item = new DBusMenuItem(layout.id, this, parent); + item->mShowChildren = parent != nullptr && parent->mShowChildren; this->items.insert(layout.id, item); } } @@ -431,8 +420,9 @@ void DBusMenu::updateLayoutRecursive( if (item->mShowChildren && !item->childrenLoaded) { item->childrenLoaded = true; - emit item->showingChildrenChanged(); } + + emit item->layoutUpdated(); } void DBusMenu::removeRecursive(qint32 id) { diff --git a/src/dbus/dbusmenu/dbusmenu.hpp b/src/dbus/dbusmenu/dbusmenu.hpp index ab485c4..bf2f09f 100644 --- a/src/dbus/dbusmenu/dbusmenu.hpp +++ b/src/dbus/dbusmenu/dbusmenu.hpp @@ -14,32 +14,18 @@ #include #include "../../core/imageprovider.hpp" +#include "../../core/qsmenu.hpp" #include "../properties.hpp" #include "dbus_menu_types.hpp" Q_DECLARE_LOGGING_CATEGORY(logDbusMenu); -namespace ToggleButtonType { // NOLINT -Q_NAMESPACE; -QML_ELEMENT; - -enum Enum { - /// This menu item does not have a checkbox or a radiobutton associated with it. - None = 0, - /// This menu item should draw a checkbox. - CheckBox = 1, - /// This menu item should draw a radiobutton. - RadioButton = 2, -}; -Q_ENUM_NS(Enum); - -} // namespace ToggleButtonType - class DBusMenuInterface; namespace qs::dbus::dbusmenu { -QDebug operator<<(QDebug debug, const ToggleButtonType::Enum& toggleType); +// hack because docgen can't take namespaces in superclasses +using menu::QsMenuEntry; class DBusMenu; class DBusMenuPngImage; @@ -47,113 +33,56 @@ class DBusMenuPngImage; ///! Menu item shared by an external program. /// Menu item shared by an external program via the /// [DBusMenu specification](https://github.com/AyatanaIndicators/libdbusmenu/blob/master/libdbusmenu-glib/dbus-menu.xml). -class DBusMenuItem: public QObject { +class DBusMenuItem: public QsMenuEntry { Q_OBJECT; - // clang-format off /// Handle to the root of this menu. Q_PROPERTY(DBusMenu* menuHandle READ menuHandle CONSTANT); - /// Text of the menu item, including hotkey markup. - Q_PROPERTY(QString label READ label NOTIFY labelChanged); - /// Text of the menu item without hotkey markup. - Q_PROPERTY(QString cleanLabel READ cleanLabel NOTIFY labelChanged); - /// If the menu item should be shown as enabled. - /// - /// > [!INFO] Disabled menu items are often used as headers in addition - /// > to actual disabled entries. - Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged); - /// Url of the menu item's icon or `""` if it doesn't have one. - /// - /// This can be passed to [Image.source](https://doc.qt.io/qt-6/qml-qtquick-image.html#source-prop) - /// as shown below. - /// - /// ```qml - /// Image { - /// source: menuItem.icon - /// // To get the best image quality, set the image source size to the same size - /// // as the rendered image. - /// sourceSize.width: width - /// sourceSize.height: height - /// } - /// ``` - Q_PROPERTY(QString icon READ icon NOTIFY iconChanged); - /// If this menu item has an associated checkbox or radiobutton. - /// - /// > [!INFO] It is the responsibility of the remote application to update the state of - /// > checkboxes and radiobuttons via [checkState](#prop.checkState). - Q_PROPERTY(ToggleButtonType::Enum toggleType READ toggleType NOTIFY toggleTypeChanged); - /// The check state of the checkbox or radiobutton if applicable, as a - /// [Qt.CheckState](https://doc.qt.io/qt-6/qt.html#CheckState-enum). - Q_PROPERTY(Qt::CheckState checkState READ checkState NOTIFY checkStateChanged); - /// If this menu item should be rendered as a separator between other items. - /// - /// No other properties have a meaningful value when `isSeparator` is true. - Q_PROPERTY(bool isSeparator READ isSeparator NOTIFY separatorChanged); - /// If this menu item reveals a submenu containing more items. - /// - /// Any submenu items must be requested by setting [showChildren](#prop.showChildren). - Q_PROPERTY(bool hasChildren READ hasChildren NOTIFY hasChildrenChanged); - /// If submenu entries of this item should be shown. - /// - /// When true, children of this menu item will be exposed via [children](#prop.children). - /// Setting this property will additionally send the `opened` and `closed` events to the - /// process that provided the menu. - Q_PROPERTY(bool showChildren READ isShowingChildren WRITE setShowChildren NOTIFY showingChildrenChanged); - /// Children of this menu item. Only populated when [showChildren](#prop.showChildren) is true. - /// - /// > [!INFO] Use [hasChildren](#prop.hasChildren) to check if this item should reveal a submenu - /// > instead of checking if `children` is empty. - Q_PROPERTY(QQmlListProperty children READ children NOTIFY childrenChanged); - // clang-format on QML_ELEMENT; QML_UNCREATABLE("DBusMenus can only be acquired from a DBusMenuHandle"); public: explicit DBusMenuItem(qint32 id, DBusMenu* menu, DBusMenuItem* parentMenu); - /// Send a `clicked` event to the remote application for this menu item. - Q_INVOKABLE void click(); - - /// Send a `hovered` event to the remote application for this menu item. + /// Refreshes the menu contents. /// - /// Note: we are not aware of any programs that use this in any meaningful way. - Q_INVOKABLE void hover() const; + /// Usually you shouldn't need to call this manually but some applications providing + /// menus do not update them correctly. Call this if menus don't update their state. + /// + /// The `layoutUpdated` signal will be sent when a response is received. + Q_INVOKABLE void updateLayout() const; [[nodiscard]] DBusMenu* menuHandle() const; - [[nodiscard]] QString label() const; - [[nodiscard]] QString cleanLabel() const; - [[nodiscard]] bool enabled() const; - [[nodiscard]] QString icon() const; - [[nodiscard]] ToggleButtonType::Enum toggleType() const; - [[nodiscard]] Qt::CheckState checkState() const; - [[nodiscard]] bool isSeparator() const; - [[nodiscard]] bool hasChildren() const; + + [[nodiscard]] bool isSeparator() const override; + [[nodiscard]] bool enabled() const override; + [[nodiscard]] QString text() const override; + [[nodiscard]] QString icon() const override; + [[nodiscard]] menu::QsMenuButtonType::Enum buttonType() const override; + [[nodiscard]] Qt::CheckState checkState() const override; + [[nodiscard]] bool hasChildren() const override; [[nodiscard]] bool isShowingChildren() const; - void setShowChildren(bool showChildren); + void setShowChildrenRecursive(bool showChildren); - [[nodiscard]] QQmlListProperty children(); + [[nodiscard]] QQmlListProperty children() override; void updateProperties(const QVariantMap& properties, const QStringList& removed = {}); void onChildrenUpdated(); qint32 id = 0; - QString mLabel; + QString mText; QVector mChildren; bool mShowChildren = false; bool childrenLoaded = false; DBusMenu* menu = nullptr; signals: - void labelChanged(); - //void mnemonicChanged(); - void enabledChanged(); - void iconChanged(); - void separatorChanged(); - void toggleTypeChanged(); - void checkStateChanged(); - void hasChildrenChanged(); - void showingChildrenChanged(); - void childrenChanged(); + void layoutUpdated(); + +private slots: + void sendOpened() const; + void sendClosed() const; + void sendTriggered() const; private: QString mCleanLabel; @@ -163,14 +92,14 @@ private: bool mSeparator = false; QString iconName; DBusMenuPngImage* image = nullptr; - ToggleButtonType::Enum mToggleType = ToggleButtonType::None; - Qt::CheckState mCheckState = Qt::Checked; + menu::QsMenuButtonType::Enum mButtonType = menu::QsMenuButtonType::None; + Qt::CheckState mCheckState = Qt::Unchecked; bool displayChildren = false; QVector enabledChildren; DBusMenuItem* parentMenu = nullptr; - static qsizetype childrenCount(QQmlListProperty* property); - static DBusMenuItem* childAt(QQmlListProperty* property, qsizetype index); + static qsizetype childrenCount(QQmlListProperty* property); + static menu::QsMenuEntry* childAt(QQmlListProperty* property, qsizetype index); }; QDebug operator<<(QDebug debug, DBusMenuItem* item); @@ -192,7 +121,7 @@ public: dbus::DBusProperty status {this->properties, "Status"}; dbus::DBusProperty iconThemePath {this->properties, "IconThemePath", {}, false}; - void prepareToShow(qint32 item, bool sendOpened); + void prepareToShow(qint32 item, qint32 depth); void updateLayout(qint32 parent, qint32 depth); void removeRecursive(qint32 id); void sendEvent(qint32 item, const QString& event); diff --git a/src/services/status_notifier/item.cpp b/src/services/status_notifier/item.cpp index b8359ee..a5a9aa9 100644 --- a/src/services/status_notifier/item.cpp +++ b/src/services/status_notifier/item.cpp @@ -75,6 +75,7 @@ StatusNotifierItem::StatusNotifierItem(const QString& address, QObject* parent) QObject::connect(&this->overlayIconPixmaps, &AbstractDBusProperty::changed, this, &StatusNotifierItem::updateIcon); QObject::connect(&this->properties, &DBusPropertyGroup::getAllFinished, this, &StatusNotifierItem::onGetAllFinished); + QObject::connect(&this->menuPath, &AbstractDBusProperty::changed, this, &StatusNotifierItem::onMenuPathChanged); // clang-format on QObject::connect(this->item, &DBusStatusNotifierItem::NewStatus, this, [this](QString value) { @@ -230,13 +231,41 @@ void StatusNotifierItem::updateIcon() { emit this->iconChanged(); } -DBusMenu* StatusNotifierItem::createMenu() const { - auto path = this->menuPath.get().path(); - if (!path.isEmpty()) { - return new DBusMenu(this->item->service(), this->menuPath.get().path()); +DBusMenu* StatusNotifierItem::menu() const { return this->mMenu; } + +void StatusNotifierItem::refMenu() { + this->menuRefcount++; + + if (this->menuRefcount == 1) { + this->onMenuPathChanged(); + } else { + // Refresh the layout when opening a menu in case a bad client isn't updating it + // and another ref is open somewhere. + this->mMenu->rootItem.updateLayout(); + } +} + +void StatusNotifierItem::unrefMenu() { + this->menuRefcount--; + + if (this->menuRefcount == 0) { + this->onMenuPathChanged(); + } +} + +void StatusNotifierItem::onMenuPathChanged() { + if (this->mMenu) { + this->mMenu->deleteLater(); + this->mMenu = nullptr; } - return nullptr; + if (this->menuRefcount > 0 && !this->menuPath.get().path().isEmpty()) { + this->mMenu = new DBusMenu(this->item->service(), this->menuPath.get().path()); + this->mMenu->setParent(this); + this->mMenu->rootItem.setShowChildrenRecursive(true); + } + + emit this->menuChanged(); } void StatusNotifierItem::onGetAllFinished() { diff --git a/src/services/status_notifier/item.hpp b/src/services/status_notifier/item.hpp index aa41190..04cceef 100644 --- a/src/services/status_notifier/item.hpp +++ b/src/services/status_notifier/item.hpp @@ -41,7 +41,10 @@ public: [[nodiscard]] bool isReady() const; [[nodiscard]] QString iconId() const; [[nodiscard]] QPixmap createPixmap(const QSize& size) const; - [[nodiscard]] qs::dbus::dbusmenu::DBusMenu* createMenu() const; + + [[nodiscard]] qs::dbus::dbusmenu::DBusMenu* menu() const; + void refMenu(); + void unrefMenu(); void activate(); void secondaryActivate(); @@ -70,16 +73,22 @@ public: signals: void iconChanged(); void ready(); + void menuChanged(); private slots: void updateIcon(); void onGetAllFinished(); + void onMenuPathChanged(); private: + void updateMenuState(); + DBusStatusNotifierItem* item = nullptr; TrayImageHandle imageHandle {this}; bool mReady = false; + dbus::dbusmenu::DBusMenu* mMenu = nullptr; + quint32 menuRefcount = 0; // bumped to inhibit caching quint32 iconIndex = 0; diff --git a/src/services/status_notifier/qml.cpp b/src/services/status_notifier/qml.cpp index f81a638..e5c64d2 100644 --- a/src/services/status_notifier/qml.cpp +++ b/src/services/status_notifier/qml.cpp @@ -10,6 +10,7 @@ #include #include "../../core/model.hpp" +#include "../../core/platformmenu.hpp" #include "../../dbus/dbusmenu/dbusmenu.hpp" #include "../../dbus/properties.hpp" #include "host.hpp" @@ -18,6 +19,7 @@ using namespace qs::dbus; using namespace qs::dbus::dbusmenu; using namespace qs::service::sni; +using namespace qs::menu::platform; SystemTrayItem::SystemTrayItem(qs::service::sni::StatusNotifierItem* item, QObject* parent) : QObject(parent) @@ -30,6 +32,7 @@ SystemTrayItem::SystemTrayItem(qs::service::sni::StatusNotifierItem* item, QObje QObject::connect(this->item, &StatusNotifierItem::iconChanged, this, &SystemTrayItem::iconChanged); QObject::connect(&this->item->tooltip, &AbstractDBusProperty::changed, this, &SystemTrayItem::tooltipTitleChanged); QObject::connect(&this->item->tooltip, &AbstractDBusProperty::changed, this, &SystemTrayItem::tooltipDescriptionChanged); + QObject::connect(&this->item->menuPath, &AbstractDBusProperty::changed, this, &SystemTrayItem::hasMenuChanged); QObject::connect(&this->item->isMenu, &AbstractDBusProperty::changed, this, &SystemTrayItem::onlyMenuChanged); // clang-format on } @@ -87,6 +90,11 @@ QString SystemTrayItem::tooltipDescription() const { return this->item->tooltip.get().description; } +bool SystemTrayItem::hasMenu() const { + if (this->item == nullptr) return false; + return !this->item->menuPath.get().path().isEmpty(); +} + bool SystemTrayItem::onlyMenu() const { if (this->item == nullptr) return false; return this->item->isMenu.get(); @@ -94,10 +102,27 @@ bool SystemTrayItem::onlyMenu() const { void SystemTrayItem::activate() const { this->item->activate(); } void SystemTrayItem::secondaryActivate() const { this->item->secondaryActivate(); } + void SystemTrayItem::scroll(qint32 delta, bool horizontal) const { this->item->scroll(delta, horizontal); } +void SystemTrayItem::display(QObject* parentWindow, qint32 relativeX, qint32 relativeY) { + this->item->refMenu(); + auto* platform = new PlatformMenuEntry(&this->item->menu()->rootItem); + + QObject::connect(&this->item->menu()->rootItem, &DBusMenuItem::layoutUpdated, platform, [=]() { + platform->relayout(); + auto success = platform->display(parentWindow, relativeX, relativeY); + + // calls destroy which also unrefs + if (!success) delete platform; + }); + + QObject::connect(platform, &PlatformMenuEntry::closed, this, [=]() { platform->deleteLater(); }); + QObject::connect(platform, &QObject::destroyed, this, [this]() { this->item->unrefMenu(); }); +} + SystemTray::SystemTray(QObject* parent): QObject(parent) { auto* host = StatusNotifierHost::instance(); @@ -129,46 +154,45 @@ ObjectModel* SystemTray::items() { return &this->mItems; } SystemTrayItem* SystemTrayMenuWatcher::trayItem() const { return this->item; } +SystemTrayMenuWatcher::~SystemTrayMenuWatcher() { + if (this->item != nullptr) { + this->item->item->unrefMenu(); + } +} + void SystemTrayMenuWatcher::setTrayItem(SystemTrayItem* item) { if (item == this->item) return; if (this->item != nullptr) { + this->item->item->unrefMenu(); QObject::disconnect(this->item, nullptr, this, nullptr); } this->item = item; if (item != nullptr) { + this->item->item->refMenu(); + QObject::connect(item, &QObject::destroyed, this, &SystemTrayMenuWatcher::onItemDestroyed); QObject::connect( - &item->item->menuPath, - &AbstractDBusProperty::changed, + item->item, + &StatusNotifierItem::menuChanged, this, - &SystemTrayMenuWatcher::onMenuPathChanged + &SystemTrayMenuWatcher::menuChanged ); } - this->onMenuPathChanged(); emit this->trayItemChanged(); + emit this->menuChanged(); } DBusMenuItem* SystemTrayMenuWatcher::menu() const { - if (this->mMenu == nullptr) return nullptr; - return &this->mMenu->rootItem; + return this->item ? &this->item->item->menu()->rootItem : nullptr; } void SystemTrayMenuWatcher::onItemDestroyed() { this->item = nullptr; - this->onMenuPathChanged(); emit this->trayItemChanged(); -} - -void SystemTrayMenuWatcher::onMenuPathChanged() { - if (this->mMenu != nullptr) { - this->mMenu->deleteLater(); - } - - this->mMenu = this->item == nullptr ? nullptr : this->item->item->createMenu(); emit this->menuChanged(); } diff --git a/src/services/status_notifier/qml.hpp b/src/services/status_notifier/qml.hpp index e55509d..9510285 100644 --- a/src/services/status_notifier/qml.hpp +++ b/src/services/status_notifier/qml.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include "../../core/model.hpp" @@ -44,8 +45,6 @@ Q_ENUM_NS(Enum); /// A system tray item, roughly conforming to the [kde/freedesktop spec] /// (there is no real spec, we just implemented whatever seemed to actually be used). /// -/// The associated context menu can be retrieved using a [SystemTrayMenuWatcher](../systemtraymenuwatcher). -/// /// [kde/freedesktop spec]: https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/StatusNotifierItem/ class SystemTrayItem: public QObject { using DBusMenuItem = qs::dbus::dbusmenu::DBusMenuItem; @@ -61,6 +60,9 @@ class SystemTrayItem: public QObject { Q_PROPERTY(QString icon READ icon NOTIFY iconChanged); Q_PROPERTY(QString tooltipTitle READ tooltipTitle NOTIFY tooltipTitleChanged); Q_PROPERTY(QString tooltipDescription READ tooltipDescription NOTIFY tooltipDescriptionChanged); + /// If this tray item has an associated menu accessible via `display` + /// or a [SystemTrayMenuWatcher](../systemtraymenuwatcher). + Q_PROPERTY(bool hasMenu READ hasMenu NOTIFY hasMenuChanged); /// If this tray item only offers a menu and activation will do nothing. Q_PROPERTY(bool onlyMenu READ onlyMenu NOTIFY onlyMenuChanged); QML_ELEMENT; @@ -78,6 +80,9 @@ public: /// Scroll action, such as changing volume on a mixer. Q_INVOKABLE void scroll(qint32 delta, bool horizontal) const; + /// Display a platform menu at the given location relative to the parent window. + Q_INVOKABLE void display(QObject* parentWindow, qint32 relativeX, qint32 relativeY); + [[nodiscard]] QString id() const; [[nodiscard]] QString title() const; [[nodiscard]] SystemTrayStatus::Enum status() const; @@ -85,6 +90,7 @@ public: [[nodiscard]] QString icon() const; [[nodiscard]] QString tooltipTitle() const; [[nodiscard]] QString tooltipDescription() const; + [[nodiscard]] bool hasMenu() const; [[nodiscard]] bool onlyMenu() const; qs::service::sni::StatusNotifierItem* item = nullptr; @@ -97,6 +103,7 @@ signals: void iconChanged(); void tooltipTitleChanged(); void tooltipDescriptionChanged(); + void hasMenuChanged(); void onlyMenuChanged(); }; @@ -141,6 +148,8 @@ class SystemTrayMenuWatcher: public QObject { public: explicit SystemTrayMenuWatcher(QObject* parent = nullptr): QObject(parent) {} + ~SystemTrayMenuWatcher() override; + Q_DISABLE_COPY_MOVE(SystemTrayMenuWatcher); [[nodiscard]] SystemTrayItem* trayItem() const; void setTrayItem(SystemTrayItem* item); @@ -148,14 +157,12 @@ public: [[nodiscard]] DBusMenuItem* menu() const; signals: - void menuChanged(); void trayItemChanged(); + void menuChanged(); private slots: void onItemDestroyed(); - void onMenuPathChanged(); private: SystemTrayItem* item = nullptr; - DBusMenu* mMenu = nullptr; }; diff --git a/src/wayland/CMakeLists.txt b/src/wayland/CMakeLists.txt index ac8f42b..a57c557 100644 --- a/src/wayland/CMakeLists.txt +++ b/src/wayland/CMakeLists.txt @@ -50,7 +50,9 @@ endfunction() # ----- -qt_add_library(quickshell-wayland STATIC) +qt_add_library(quickshell-wayland STATIC + platformmenu.cpp +) # required to make sure the constructor is linked add_library(quickshell-wayland-init OBJECT init.cpp) diff --git a/src/wayland/init.cpp b/src/wayland/init.cpp index 95adb24..b43179f 100644 --- a/src/wayland/init.cpp +++ b/src/wayland/init.cpp @@ -4,6 +4,7 @@ #include #include "../core/plugin.hpp" +#include "platformmenu.hpp" #ifdef QS_WAYLAND_WLR_LAYERSHELL #include "wlr_layershell.hpp" @@ -26,6 +27,8 @@ class WaylandPlugin: public QuickshellPlugin { return isWayland; } + void init() override { installPlatformMenuHook(); } + void registerTypes() override { #ifdef QS_WAYLAND_WLR_LAYERSHELL qmlRegisterType("Quickshell._WaylandOverlay", 1, 0, "PanelWindow"); diff --git a/src/wayland/platformmenu.cpp b/src/wayland/platformmenu.cpp new file mode 100644 index 0000000..b7ae3d0 --- /dev/null +++ b/src/wayland/platformmenu.cpp @@ -0,0 +1,32 @@ +#include "platformmenu.hpp" + +#include +#include +#include + +#include "../core/platformmenu.hpp" + +using namespace qs::menu::platform; +using namespace QtWayland; + +// fixes positioning of submenus when hitting screen edges +void platformMenuHook(PlatformMenuQMenu* menu) { + auto* window = menu->windowHandle(); + + auto constraintAdjustment = QtWayland::xdg_positioner::constraint_adjustment_flip_x + | QtWayland::xdg_positioner::constraint_adjustment_flip_y; + + window->setProperty("_q_waylandPopupConstraintAdjustment", constraintAdjustment); + + if (auto* containingMenu = menu->containingMenu) { + auto geom = containingMenu->actionGeometry(menu->menuAction()); + + // use the first action to find the offsets relative to the containing window + auto baseGeom = containingMenu->actionGeometry(containingMenu->actions().first()); + geom += QMargins(0, baseGeom.top(), 0, baseGeom.top()); + + window->setProperty("_q_waylandPopupAnchorRect", geom); + } +} + +void installPlatformMenuHook() { PlatformMenuEntry::registerCreationHook(&platformMenuHook); } diff --git a/src/wayland/platformmenu.hpp b/src/wayland/platformmenu.hpp new file mode 100644 index 0000000..0362932 --- /dev/null +++ b/src/wayland/platformmenu.hpp @@ -0,0 +1,3 @@ +#pragma once + +void installPlatformMenuHook();