ui: add native reload popup

This commit is contained in:
outfoxxed 2025-05-16 00:11:09 -07:00
parent fef840d2e8
commit 5048b97307
Signed by: outfoxxed
GPG key ID: 4C88A185FB89301E
13 changed files with 475 additions and 4 deletions

View file

@ -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)

View file

@ -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()";

View file

@ -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);

View file

@ -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<QuickshellScreenInfo>* prop);
static QuickshellScreenInfo* screenAt(QQmlListProperty<QuickshellScreenInfo>* prop, qsizetype i);
};

View file

@ -12,8 +12,10 @@
#include <qtmetamacros.h>
#include <qurl.h>
#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, "");
}
}

View file

@ -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);

View file

@ -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 $<TARGET_FILE:${name}>)
endfunction()

18
src/ui/CMakeLists.txt Normal file
View file

@ -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)

237
src/ui/ReloadPopup.qml Normal file
View file

@ -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." }
}
}
}
}

82
src/ui/Tooltip.qml Normal file
View file

@ -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
}
}
}

58
src/ui/reload_popup.cpp Normal file
View file

@ -0,0 +1,58 @@
#include "reload_popup.hpp"
#include <utility>
#include <qcontainerfwd.h>
#include <qlogging.h>
#include <qobject.h>
#include <qqmlcomponent.h>
#include <qtenvironmentvariables.h>
#include <qtimer.h>
#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

39
src/ui/reload_popup.hpp Normal file
View file

@ -0,0 +1,39 @@
#pragma once
#include <qcontainerfwd.h>
#include <qobject.h>
#include <qqmlengine.h>
#include <qqmlintegration.h>
#include <qtmetamacros.h>
#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

View file

@ -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 $<TARGET_FILE:${name}>)
endfunction()