diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 882d2bae..d3070b6b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -10,6 +10,7 @@ add_subdirectory(ipc) add_subdirectory(window) add_subdirectory(io) add_subdirectory(widgets) +add_subdirectory(ui) if (CRASH_REPORTER) add_subdirectory(crash) diff --git a/src/core/generation.cpp b/src/core/generation.cpp index ef4449b3..32f75862 100644 --- a/src/core/generation.cpp +++ b/src/core/generation.cpp @@ -45,6 +45,8 @@ EngineGeneration::EngineGeneration(const QDir& rootPath, QmlScanner scanner) QsEnginePlugin::runConstructGeneration(*this); } +EngineGeneration::EngineGeneration(): EngineGeneration(QDir(), QmlScanner()) {} + EngineGeneration::~EngineGeneration() { if (this->engine != nullptr) { qFatal() << this << "destroyed without calling destroy()"; diff --git a/src/core/generation.hpp b/src/core/generation.hpp index 632bd8a5..f0c4d02a 100644 --- a/src/core/generation.hpp +++ b/src/core/generation.hpp @@ -28,6 +28,7 @@ class EngineGeneration: public QObject { Q_OBJECT; public: + explicit EngineGeneration(); explicit EngineGeneration(const QDir& rootPath, QmlScanner scanner); ~EngineGeneration() override; Q_DISABLE_COPY_MOVE(EngineGeneration); diff --git a/src/core/qmlglobal.hpp b/src/core/qmlglobal.hpp index 8667a80e..83a97186 100644 --- a/src/core/qmlglobal.hpp +++ b/src/core/qmlglobal.hpp @@ -173,6 +173,14 @@ public: Q_INVOKABLE [[nodiscard]] QString statePath(const QString& path) const; /// Equivalent to `${Quickshell.cacheDir}/${path}` Q_INVOKABLE [[nodiscard]] QString cachePath(const QString& path) const; + /// When called from @@reloadCompleted() or @@reloadFailed(), prevents the + /// default reload popup from displaying. + /// + /// The popup can also be blocked by setting `QS_NO_RELOAD_POPUP=1`. + Q_INVOKABLE void inhibitReloadPopup() { this->mInhibitReloadPopup = true; } + + void clearReloadPopupInhibit() { this->mInhibitReloadPopup = false; } + [[nodiscard]] bool isReloadPopupInhibited() const { return this->mInhibitReloadPopup; } [[nodiscard]] QString shellRoot() const; @@ -212,6 +220,8 @@ private slots: private: QuickshellGlobal(QObject* parent = nullptr); + bool mInhibitReloadPopup = false; + static qsizetype screensCount(QQmlListProperty* prop); static QuickshellScreenInfo* screenAt(QQmlListProperty* prop, qsizetype i); }; diff --git a/src/core/rootwrapper.cpp b/src/core/rootwrapper.cpp index b394af58..6950aa92 100644 --- a/src/core/rootwrapper.cpp +++ b/src/core/rootwrapper.cpp @@ -12,8 +12,10 @@ #include #include +#include "../ui/reload_popup.hpp" #include "../window/floatingwindow.hpp" #include "generation.hpp" +#include "instanceinfo.hpp" #include "qmlglobal.hpp" #include "scan.hpp" @@ -68,6 +70,18 @@ void RootWrapper::reloadGraph(bool hard) { qWarning().noquote() << error; generation->destroy(); + if (this->generation != nullptr) { + auto showPopup = true; + + if (this->generation->qsgInstance != nullptr) { + this->generation->qsgInstance->clearReloadPopupInhibit(); + emit this->generation->qsgInstance->reloadFailed(error); + showPopup = !this->generation->qsgInstance->isReloadPopupInhibited(); + } + + if (showPopup) qs::ui::ReloadPopup::spawnPopup(InstanceInfo::CURRENT.instanceId, true, error); + } + if (this->generation != nullptr && this->generation->qsgInstance != nullptr) { emit this->generation->qsgInstance->reloadFailed(error); } @@ -113,8 +127,16 @@ void RootWrapper::reloadGraph(bool hard) { this->onWatchFilesChanged(); - if (isReload && this->generation->qsgInstance != nullptr) { - emit this->generation->qsgInstance->reloadCompleted(); + if (isReload) { + auto showPopup = true; + + if (this->generation->qsgInstance != nullptr) { + this->generation->qsgInstance->clearReloadPopupInhibit(); + emit this->generation->qsgInstance->reloadCompleted(); + showPopup = !this->generation->qsgInstance->isReloadPopupInhibited(); + } + + if (showPopup) qs::ui::ReloadPopup::spawnPopup(InstanceInfo::CURRENT.instanceId, false, ""); } } diff --git a/src/core/scan.hpp b/src/core/scan.hpp index e3071a88..0b4f1600 100644 --- a/src/core/scan.hpp +++ b/src/core/scan.hpp @@ -11,6 +11,7 @@ Q_DECLARE_LOGGING_CATEGORY(logQmlScanner); // expects canonical paths class QmlScanner { public: + QmlScanner() = default; QmlScanner(const QDir& rootPath): rootPath(rootPath) {} void scanDir(const QString& path); diff --git a/src/core/test/CMakeLists.txt b/src/core/test/CMakeLists.txt index bb49192d..c4005c8b 100644 --- a/src/core/test/CMakeLists.txt +++ b/src/core/test/CMakeLists.txt @@ -1,6 +1,6 @@ function (qs_test name) add_executable(${name} ${ARGN}) - target_link_libraries(${name} PRIVATE Qt::Quick Qt::Test quickshell-core quickshell-window) + target_link_libraries(${name} PRIVATE Qt::Quick Qt::Test quickshell-core quickshell-window quickshell-ui) add_test(NAME ${name} WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" COMMAND $) endfunction() diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt new file mode 100644 index 00000000..4043fed1 --- /dev/null +++ b/src/ui/CMakeLists.txt @@ -0,0 +1,18 @@ +qt_add_library(quickshell-ui STATIC + reload_popup.cpp +) + +# do not install this module +qt_add_qml_module(quickshell-ui + URI Quickshell._InternalUi + VERSION 0.1 + DEPENDENCIES QtQuick + QML_FILES + Tooltip.qml + ReloadPopup.qml +) + +qs_module_pch(quickshell-ui SET large) + +target_link_libraries(quickshell-ui PRIVATE Qt::Quick) +target_link_libraries(quickshell PRIVATE quickshell-uiplugin) diff --git a/src/ui/ReloadPopup.qml b/src/ui/ReloadPopup.qml new file mode 100644 index 00000000..cf2806d2 --- /dev/null +++ b/src/ui/ReloadPopup.qml @@ -0,0 +1,237 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets + +PanelWindow { + id: root + required property ReloadPopupInfo reloadInfo + readonly property string instanceId: root.reloadInfo.instanceId + readonly property bool failed: root.reloadInfo.failed + readonly property string errorString: root.reloadInfo.errorString + + anchors { left: true; top: true } + margins { left: 25; top: 25 } + + implicitWidth: wrapper.implicitWidth + implicitHeight: wrapper.implicitHeight + + color: "transparent" + + focusable: failText.focus + + // Composite before changing opacity + SequentialAnimation on contentItem.opacity { + id: fadeOutAnim + NumberAnimation { + // avoids 0 which closes the popup + from: 0.0001; to: 1 + duration: 250 + easing.type: Easing.OutQuad + } + PauseAnimation { duration: root.failed ? 2000 : 500 } + NumberAnimation { + to: 0 + duration: root.failed ? 3000 : 800 + easing.type: Easing.InQuad + } + } + + Behavior on contentItem.opacity { + enabled: !fadeOutAnim.running + + NumberAnimation { + duration: 250 + easing.type: Easing.OutQuad + } + } + + contentItem.onOpacityChanged: { + if (contentItem.opacity == 0) root.reloadInfo.closed() + } + + component PopupText: Text { + color: palette.active.text + } + + component TopButton: WrapperMouseArea { + id: buttonMouse + property alias image: image.source + property bool red: false + + hoverEnabled: true + + WrapperRectangle { + radius: 5 + + color: { + if (buttonMouse.red) { + const baseColor = "#c04040"; + if (buttonMouse.pressed) return Qt.tint(palette.active.button, Qt.alpha(baseColor, 0.8)); + if (buttonMouse.containsMouse) return baseColor; + } else { + if (buttonMouse.pressed) return Qt.tint(palette.active.button, Qt.alpha(palette.active.accent, 0.3)); + if (buttonMouse.containsMouse) return Qt.tint(palette.active.button, Qt.alpha(palette.active.accent, 0.5)); + } + + return palette.active.button; + } + + border.color: { + if (buttonMouse.red) { + const baseColor = "#c04040"; + if (buttonMouse.pressed) return Qt.tint(palette.active.light, Qt.alpha(baseColor, 0.8)); + if (buttonMouse.containsMouse) return baseColor; + } else { + if (buttonMouse.pressed) return Qt.tint(palette.active.light, Qt.alpha(palette.active.accent, 0.7)); + if (buttonMouse.containsMouse) return palette.active.accent; + } + + return palette.active.light; + } + + Behavior on color { ColorAnimation { duration: 100 } } + Behavior on border.color { ColorAnimation { duration: 100 } } + + IconImage { id: image; implicitSize: 22 } + } + } + + WrapperRectangle { + id: wrapper + anchors.fill: parent + color: palette.active.window + border.color: root.failed ? "#b53030" : palette.active.accent + + radius: 10 + margin: 10 + + HoverHandler { + onHoveredChanged: { + if (hovered && fadeOutAnim.running) { + fadeOutAnim.stop(); + root.contentItem.opacity = 1; + } + } + } + + ColumnLayout { + RowLayout { + PopupText { + font.pixelSize: 20 + fontSizeMode: Text.VerticalFit + text: `Quickshell: ${root.failed ? "Config reload failed" : "Config reloaded"}` + } + + Item { Layout.fillWidth: true } + + TopButton { + id: copyButton + visible: root.failed + image: Quickshell.iconPath("edit-copy") + onClicked: { + Quickshell.clipboardText = root.errorString; + copyTooltip.showAction(); + } + } + + Tooltip { + id: copyTooltip + anchorItem: copyButton + show: copyButton.containsMouse + text: "Copy error message" + actionText: "Copied to clipboard" + } + + TopButton { + image: Quickshell.iconPath("window-close") + red: true + onClicked: { + fadeOutAnim.stop() + root.contentItem.opacity = 0 + } + } + } + + WrapperRectangle { + visible: root.failed + color: palette.active.base + margin: 10 + radius: 5 + + TextEdit { + id: failText + text: root.errorString + color: palette.active.text + selectionColor: palette.active.highlight + selectedTextColor: palette.active.highlightedText + readOnly: true + } + } + + RowLayout { + PopupText { text: "Run" } + + WrapperMouseArea { + id: logButton + + Layout.topMargin: -logWrapper.margin + Layout.bottomMargin: -logWrapper.margin + + hoverEnabled: true + + onPressed: { + Quickshell.clipboardText = logText.text; + logCopyTooltip.showAction(); + } + + WrapperRectangle { + id: logWrapper + margin: 2 + radius: 5 + + color: { + if (logButton.pressed) return Qt.tint(palette.active.base, Qt.alpha(palette.active.accent, 0.1)); + if (logButton.containsMouse) return Qt.tint(palette.active.base, Qt.alpha(palette.active.accent, 0.2)); + return palette.active.base; + } + + border.color: { + if (logButton.pressed) return Qt.tint(palette.active.button, Qt.alpha(palette.active.accent, 0.3)); + if (logButton.containsMouse) return Qt.tint(palette.active.button, Qt.alpha(palette.active.accent, 0.5)); + return palette.active.button; + } + + Behavior on color { ColorAnimation { duration: 100 } } + Behavior on border.color { ColorAnimation { duration: 100 } } + + RowLayout { + PopupText { + id: logText + text: `qs log -i ${root.instanceId}` + } + + IconImage { + Layout.fillHeight: true + implicitWidth: height + source: Quickshell.iconPath("edit-copy") + } + } + } + + Tooltip { + id: logCopyTooltip + anchorItem: logWrapper + show: logButton.containsMouse + text: "Copy command" + actionText: "Copied to clipboard" + } + } + + PopupText { text: "to view the log." } + } + } + } +} diff --git a/src/ui/Tooltip.qml b/src/ui/Tooltip.qml new file mode 100644 index 00000000..66b4edd4 --- /dev/null +++ b/src/ui/Tooltip.qml @@ -0,0 +1,82 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets + +PopupWindow { + id: popup + required property Item anchorItem + required property string text + property string actionText + property bool show: false + + function showAction() { + mShowAction = true; + showInternal = true; + hangTimer.restart(); + } + + // We should be using a bottom center anchor but support for them is bad compositor side. + anchor { + window: anchorItem.QsWindow.window + adjustment: PopupAdjustment.None + gravity: Edges.Bottom | Edges.Right + + onAnchoring: { + const pos = anchorItem.QsWindow.contentItem.mapFromItem( + anchorItem, + anchorItem.width / 2 - popup.width / 2, + anchorItem.height + 5 + ); + + anchor.rect.x = pos.x; + anchor.rect.y = pos.y; + } + } + + property bool showInternal: false + property bool mShowAction: false + property real opacity: showInternal ? 1 : 0 + + onShowChanged: hangTimer.restart() + + Timer { + id: hangTimer + interval: 400 + onTriggered: popup.showInternal = popup.show + } + + Behavior on opacity { + NumberAnimation { + duration: 200 + easing.type: popup.showInternal ? Easing.InQuart : Easing.OutQuart + } + } + + color: "transparent" + visible: opacity != 0 + onVisibleChanged: if (!visible) mShowAction = false + + implicitWidth: content.implicitWidth + implicitHeight: content.implicitHeight + + WrapperRectangle { + id: content + opacity: popup.opacity + color: palette.active.toolTipBase + border.color: palette.active.light + margin: 5 + radius: 5 + + transform: Scale { + origin.x: content.width / 2 + origin.y: 0 + xScale: 0.6 + popup.opacity * 0.4 + yScale: xScale + } + + Text { + text: popup.mShowAction ? popup.actionText : popup.text + color: palette.active.toolTipText + } + } +} diff --git a/src/ui/reload_popup.cpp b/src/ui/reload_popup.cpp new file mode 100644 index 00000000..9a77fae3 --- /dev/null +++ b/src/ui/reload_popup.cpp @@ -0,0 +1,58 @@ +#include "reload_popup.hpp" +#include + +#include +#include +#include +#include +#include +#include + +#include "../core/generation.hpp" + +namespace qs::ui { + +ReloadPopup::ReloadPopup(QString instanceId, bool failed, QString errorString) + : generation(new EngineGeneration()) + , instanceId(std::move(instanceId)) + , failed(failed) + , errorString(std::move(errorString)) { + auto component = QQmlComponent( + this->generation->engine, + "qrc:/qt/qml/Quickshell/_InternalUi/ReloadPopup.qml", + this + ); + + this->popup = component.createWithInitialProperties({{"reloadInfo", QVariant::fromValue(this)}}); + + if (!popup) { + qCritical() << "Failed to open reload popup:" << component.errorString(); + } + + this->generation->onReload(nullptr); +} + +void ReloadPopup::closed() { + if (ReloadPopup::activePopup == this) ReloadPopup::activePopup = nullptr; + + if (!this->deleting) { + this->deleting = true; + + QTimer::singleShot(0, [this]() { + this->popup->deleteLater(); + this->generation->destroy(); + this->deleteLater(); + }); + } +} + +void ReloadPopup::spawnPopup(QString instanceId, bool failed, QString errorString) { + if (qEnvironmentVariableIsSet("QS_NO_RELOAD_POPUP")) return; + + if (ReloadPopup::activePopup) ReloadPopup::activePopup->closed(); + ReloadPopup::activePopup = new ReloadPopup(std::move(instanceId), failed, std::move(errorString)); +} + +ReloadPopup* ReloadPopup::activePopup = nullptr; + +} // namespace qs::ui diff --git a/src/ui/reload_popup.hpp b/src/ui/reload_popup.hpp new file mode 100644 index 00000000..78ab55ac --- /dev/null +++ b/src/ui/reload_popup.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../core/generation.hpp" + +namespace qs::ui { + +class ReloadPopup: public QObject { + Q_OBJECT; + QML_NAMED_ELEMENT(ReloadPopupInfo); + QML_UNCREATABLE("") + Q_PROPERTY(QString instanceId MEMBER instanceId CONSTANT); + Q_PROPERTY(bool failed MEMBER failed CONSTANT); + Q_PROPERTY(QString errorString MEMBER errorString CONSTANT); + +public: + Q_INVOKABLE void closed(); + + static void spawnPopup(QString instanceId, bool failed, QString errorString); + +private: + ReloadPopup(QString instanceId, bool failed, QString errorString); + + EngineGeneration* generation; + QObject* popup = nullptr; + QString instanceId; + bool failed = false; + bool deleting = false; + QString errorString; + + static ReloadPopup* activePopup; +}; + +} // namespace qs::ui diff --git a/src/window/test/CMakeLists.txt b/src/window/test/CMakeLists.txt index 09085fd7..70615119 100644 --- a/src/window/test/CMakeLists.txt +++ b/src/window/test/CMakeLists.txt @@ -1,6 +1,6 @@ function (qs_test name) add_executable(${name} ${ARGN}) - target_link_libraries(${name} PRIVATE Qt::Quick Qt::Test quickshell-window quickshell-core) + target_link_libraries(${name} PRIVATE Qt::Quick Qt::Test quickshell-window quickshell-core quickshell-ui) add_test(NAME ${name} WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" COMMAND $) endfunction()