From 28a84a96114ea6757a445bdc788d10f2ee920314 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:05:37 +1000 Subject: [PATCH] feat: add styled progress bar --- components/controls/StyledProgressBar.qml | 253 ++++++++++++++++++ plugin/src/Caelestia/Internal/CMakeLists.txt | 19 +- .../Internal/linearindicatormanager.cpp | 119 ++++++++ .../Internal/linearindicatormanager.hpp | 86 ++++++ plugin/src/Caelestia/cutils.cpp | 10 +- plugin/src/Caelestia/cutils.hpp | 8 +- 6 files changed, 480 insertions(+), 15 deletions(-) create mode 100644 components/controls/StyledProgressBar.qml create mode 100644 plugin/src/Caelestia/Internal/linearindicatormanager.cpp create mode 100644 plugin/src/Caelestia/Internal/linearindicatormanager.hpp diff --git a/components/controls/StyledProgressBar.qml b/components/controls/StyledProgressBar.qml new file mode 100644 index 00000000..22b4c556 --- /dev/null +++ b/components/controls/StyledProgressBar.qml @@ -0,0 +1,253 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Templates +import Caelestia +import Caelestia.Components +import Caelestia.Config +import Caelestia.Internal +import qs.components +import qs.services + +ProgressBar { + id: root + + enum IndeterminateAnimState { + Running, + Completing, + Stopped + } + + property color fgColour: Colours.palette.m3primary + property color bgColour: Colours.palette.m3secondaryContainer + + property bool wavy + property bool wavePaused + property int waveFrequency: 6 + property real waveAmplitude: 0.5 + property int waveDuration: 1000 + property bool animateIndeterminate + + property int indeterminateAnimState: StyledProgressBar.Stopped + + function toBounds(startFrac: real, endFrac: real, gapSize: real): point { + startFrac = CUtils.clamp(startFrac, 0, 1); + endFrac = CUtils.clamp(endFrac, 0, 1); + + // Ramp down gap size + const GAP_RAMP_DOWN_THRESHOLD = 0.01; + gapSize += height / 2; + const startGapSize = (gapSize * CUtils.clamp(startFrac, 0, GAP_RAMP_DOWN_THRESHOLD) / GAP_RAMP_DOWN_THRESHOLD); + const endGapSize = (gapSize * (1 - CUtils.clamp(endFrac, 1 - GAP_RAMP_DOWN_THRESHOLD, 1)) / GAP_RAMP_DOWN_THRESHOLD); + const start = width * startFrac + startGapSize; + const end = width * endFrac - endGapSize; + + return start >= end ? Qt.point(0, 0) : Qt.point(start, end); + } + + function updateIAnimState(): void { + if (indeterminate && animateIndeterminate) { + manager.completeEndProgress = 0; + indeterminateAnimState = StyledProgressBar.Running; + } else if (indeterminateAnimState === StyledProgressBar.Running) { + indeterminateAnimState = StyledProgressBar.Completing; + } + } + + onIndeterminateChanged: updateIAnimState() + onAnimateIndeterminateChanged: updateIAnimState() + Component.onCompleted: updateIAnimState() + + implicitWidth: 200 + implicitHeight: 4 + + contentItem: Loader { + anchors.fill: parent + sourceComponent: root.indeterminate || root.indeterminateAnimState !== StyledProgressBar.Stopped ? indeterminateComp : determinateComp + } + + LinearIndicatorManager { + id: manager + + gap: Tokens.spacing.extraSmall + + Anim on progress { + running: root.indeterminateAnimState !== StyledProgressBar.Stopped + from: 0 + to: 1 + duration: manager.duration + easing.type: Easing.Linear + loops: Animation.Infinite + } + + Anim on completeEndProgress { + running: root.indeterminateAnimState === StyledProgressBar.Completing + to: 1 + duration: manager.completeEndDuration + onFinished: { + if (root.indeterminateAnimState === StyledProgressBar.Completing) + root.indeterminateAnimState = StyledProgressBar.Stopped; + } + } + } + + Behavior on value { + Anim {} + } + + Component { + id: determinateComp + + Item { + Line { + id: remaining + + anchors.right: parent.right + implicitWidth: parent.width - wave.implicitWidth - Tokens.spacing.extraSmall + } + + Line { + property real implicitSize + + Component.onCompleted: implicitSize = Qt.binding(() => parent.width * (1 - root.visualPosition) < parent.height ? parent.height : 4) + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: (parent.height - implicitHeight) / 2 + + implicitWidth: implicitSize + implicitHeight: implicitSize + + radius: Tokens.rounding.full + color: root.fgColour + + Behavior on implicitSize { + Anim { + type: Anim.FastSpatial + } + } + } + + Wave { + id: wave + + readonly property real targetWidth: parent.width * root.visualPosition + + anchors.left: parent.left + + Anim on implicitWidth { + running: true + to: wave.targetWidth + onFinished: wave.implicitWidth = Qt.binding(() => wave.targetWidth) + } + } + } + } + + Component { + id: indeterminateComp + + Item { + id: content + + Line { + bounds: { + const i = manager.activeIndicators[0]; // qmllint disable unresolved-type + return i ? root.toBounds(0, i.startFraction, i.gapSize / 2) : Qt.point(0, 0); + } + } + + Line { + bounds: { + const i = manager.activeIndicators[manager.activeIndicators.length - 1]; // qmllint disable unresolved-type + return i ? root.toBounds(i.endFraction, 1, i.gapSize / 2) : Qt.point(0, 0); + } + } + + Instantiator { + model: Math.max(manager.activeIndicators.length, 1) - 1 // qmllint disable unresolved-type + + delegate: Line { + required property int index + readonly property LinearIndicatorSegment cur: manager.activeIndicators[index] // qmllint disable unresolved-type + readonly property LinearIndicatorSegment next: manager.activeIndicators[index + 1 % manager.activeIndicators.length] // qmllint disable unresolved-type + + bounds: root.toBounds(cur.endFraction, next.startFraction, cur.gapSize / 2) + } + + onObjectAdded: (_, obj) => content.data.push(obj) + onObjectRemoved: (_, obj) => { + const idx = content.data.indexOf(obj); + if (idx !== -1) + content.data.splice(idx, 1); + } + } + + Instantiator { + model: manager.activeIndicators // qmllint disable unresolved-type + + delegate: Wave { + required property LinearIndicatorSegment modelData + readonly property point bounds: root.toBounds(modelData.startFraction, modelData.endFraction, modelData.gapSize / 2) + + x: bounds.x + implicitWidth: bounds.y - bounds.x + + color: root.fgColour + } + + onObjectAdded: (_, obj) => content.data.push(obj) + onObjectRemoved: (_, obj) => { + const idx = content.data.indexOf(obj); + if (idx !== -1) + content.data.splice(idx, 1); + } + } + } + } + + component Line: StyledRect { + property point bounds + + x: bounds.x + implicitWidth: bounds.y - bounds.x + + anchors.verticalCenter: parent.verticalCenter + implicitHeight: parent.height + + radius: Tokens.rounding.full + color: root.fgColour + } + + component Wave: WavyLine { + anchors.verticalCenter: parent.verticalCenter + implicitHeight: lineWidth * amplitudeMultiplier * 2 + lineWidth + + lineWidth: parent.height + amplitudeMultiplier: root.wavy ? root.waveAmplitude : 0 + frequency: root.waveFrequency + startX: x + fullLength: parent.width + color: root.fgColour + + Anim on waveProgress { + running: true + paused: root.wavePaused + from: 0 + to: 1 + duration: root.waveDuration + easing.type: Easing.Linear + loops: Animation.Infinite + } + + Behavior on amplitudeMultiplier { + Anim { + type: Anim.DefaultEffects + } + } + + Behavior on color { + CAnim {} + } + } +} diff --git a/plugin/src/Caelestia/Internal/CMakeLists.txt b/plugin/src/Caelestia/Internal/CMakeLists.txt index bc4a6948..7feead00 100644 --- a/plugin/src/Caelestia/Internal/CMakeLists.txt +++ b/plugin/src/Caelestia/Internal/CMakeLists.txt @@ -1,15 +1,16 @@ qml_module(caelestia-internal URI Caelestia.Internal SOURCES - arcgauge.hpp arcgauge.cpp - cachingimagemanager.hpp cachingimagemanager.cpp - circularbuffer.hpp circularbuffer.cpp - circularindicatormanager.hpp circularindicatormanager.cpp - hyprdevices.hpp hyprdevices.cpp - hyprextras.hpp hyprextras.cpp - logindmanager.hpp logindmanager.cpp - sparklineitem.hpp sparklineitem.cpp - visualiserbars.hpp visualiserbars.cpp + arcgauge.cpp + cachingimagemanager.cpp + circularbuffer.cpp + circularindicatormanager.cpp + linearindicatormanager.cpp + hyprdevices.cpp + hyprextras.cpp + logindmanager.cpp + sparklineitem.cpp + visualiserbars.cpp LIBRARIES Qt::Gui Qt::Quick diff --git a/plugin/src/Caelestia/Internal/linearindicatormanager.cpp b/plugin/src/Caelestia/Internal/linearindicatormanager.cpp new file mode 100644 index 00000000..597a593d --- /dev/null +++ b/plugin/src/Caelestia/Internal/linearindicatormanager.cpp @@ -0,0 +1,119 @@ +#include "linearindicatormanager.hpp" + +#include + +namespace { + +// See +// https://github.com/material-components/material-components-android/blob/master/lib/java/com/google/android/material/progressindicator/LinearIndeterminateDisjointAnimatorDelegate.java#L44-L46 +constexpr int TOTAL_DURATION_IN_MS = 1800; +constexpr std::array DURATION_TO_MOVE_SEGMENT_ENDS = { 533, 567, 850, 750 }; +constexpr std::array DELAY_TO_MOVE_SEGMENT_ENDS = { 1267, 1000, 333, 0 }; + +QEasingCurve curve(const QPointF& c1, const QPointF& c2) { + QEasingCurve curve(QEasingCurve::BezierSpline); + curve.addCubicBezierSegment(c1, c2, { 1.0, 1.0 }); + return curve; +} + +qreal getFractionInRange(qreal playtime, int start, int duration) { + const auto fraction = static_cast(playtime - start) / duration; + return std::clamp(fraction, 0.0, 1.0); +} + +} // namespace + +namespace caelestia::controls { + +LinearIndicatorSegment::LinearIndicatorSegment(int gap, QObject* parent) + : QObject(parent) + , m_startFraction(0) + , m_endFraction(0) + , m_gapSize(gap) {} + +qreal LinearIndicatorSegment::startFraction() const { + return m_startFraction; +} + +qreal LinearIndicatorSegment::endFraction() const { + return m_endFraction; +} + +int LinearIndicatorSegment::gapSize() const { + return m_gapSize; +} + +LinearIndicatorManager::LinearIndicatorManager(QObject* parent) + : QObject(parent) + , m_interpolators({ + curve({ 0.2, 0.0 }, { 0.8, 1.0 }), + curve({ 0.4, 0.0 }, { 1.0, 1.0 }), + curve({ 0.0, 0.0 }, { 0.65, 1.0 }), + curve({ 0.1, 0.0 }, { 0.45, 1.0 }), + }) + , m_progress(0) + , m_completeEndProgress(0) + , m_gap(4) + , m_activeIndicators({ + new LinearIndicatorSegment(m_gap, this), + new LinearIndicatorSegment(m_gap, this), + }) { + for (auto el : m_activeIndicators) + QObject::connect(this, &LinearIndicatorManager::updated, el, &LinearIndicatorSegment::updated); +} + +QList LinearIndicatorManager::activeIndicators() const { + return { m_activeIndicators.cbegin(), m_activeIndicators.cend() }; +} + +qreal LinearIndicatorManager::progress() const { + return m_progress; +} + +qreal LinearIndicatorManager::completeEndProgress() const { + return m_completeEndProgress; +} + +int LinearIndicatorManager::gap() const { + return m_gap; +} + +void LinearIndicatorManager::setGap(int gap) { + m_gap = gap; + for (auto el : m_activeIndicators) + el->m_gapSize = m_gap; + update(m_progress); +} + +int LinearIndicatorManager::duration() const { + return TOTAL_DURATION_IN_MS; +} + +int LinearIndicatorManager::completeEndDuration() const { + return TOTAL_DURATION_IN_MS; +} + +void LinearIndicatorManager::update(qreal progress) { + const auto playtime = progress * TOTAL_DURATION_IN_MS; + for (size_t i = 0; i < SEGMENTS; i++) { + const auto di = i * 2; + auto* const indicator = m_activeIndicators[i]; + + auto fraction = getFractionInRange(playtime, DELAY_TO_MOVE_SEGMENT_ENDS[di], DURATION_TO_MOVE_SEGMENT_ENDS[di]); + indicator->m_startFraction = std::clamp(m_interpolators[di].valueForProgress(fraction), 0.0, 1.0); + + fraction = + getFractionInRange(playtime, DELAY_TO_MOVE_SEGMENT_ENDS[di + 1], DURATION_TO_MOVE_SEGMENT_ENDS[di + 1]); + indicator->m_endFraction = std::clamp(m_interpolators[di + 1].valueForProgress(fraction), 0.0, 1.0); + } + + m_progress = progress; + emit updated(); +} + +void LinearIndicatorManager::updateCompleteEndProgress(qreal progress) { + m_completeEndProgress = progress; + update(m_progress); +} + +} // namespace caelestia::controls diff --git a/plugin/src/Caelestia/Internal/linearindicatormanager.hpp b/plugin/src/Caelestia/Internal/linearindicatormanager.hpp new file mode 100644 index 00000000..272e8643 --- /dev/null +++ b/plugin/src/Caelestia/Internal/linearindicatormanager.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace caelestia::controls { + +class LinearIndicatorManager; + +class LinearIndicatorSegment : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("LinearIndicatorSegments can only be retrieved from a " + "LinearIndicatorManager.") + + Q_PROPERTY(qreal startFraction READ startFraction NOTIFY updated FINAL) + Q_PROPERTY(qreal endFraction READ endFraction NOTIFY updated FINAL) + Q_PROPERTY(int gapSize READ gapSize NOTIFY updated FINAL) + +public: + explicit LinearIndicatorSegment(int gap, QObject* parent = nullptr); + + qreal startFraction() const; + qreal endFraction() const; + int gapSize() const; + +signals: + void updated(); + +private: + qreal m_startFraction; + qreal m_endFraction; + int m_gapSize; + + friend LinearIndicatorManager; +}; + +class LinearIndicatorManager : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + QList activeIndicators READ activeIndicators CONSTANT FINAL) + + Q_PROPERTY(qreal progress READ progress WRITE update NOTIFY updated FINAL) + Q_PROPERTY(qreal completeEndProgress READ completeEndProgress WRITE updateCompleteEndProgress NOTIFY updated FINAL) + Q_PROPERTY(int gap READ gap WRITE setGap NOTIFY updated FINAL) + + Q_PROPERTY(qreal duration READ duration CONSTANT FINAL) + Q_PROPERTY(qreal completeEndDuration READ completeEndDuration CONSTANT FINAL) + +public: + explicit LinearIndicatorManager(QObject* parent = nullptr); + + QList activeIndicators() const; + + qreal progress() const; + qreal completeEndProgress() const; + + int gap() const; + void setGap(int gap); + + int duration() const; + int completeEndDuration() const; + + void update(qreal progress); + void updateCompleteEndProgress(qreal progress); + +signals: + void updated(); + +private: + static constexpr int SEGMENTS = 2; + + std::array m_interpolators; + qreal m_progress; + qreal m_completeEndProgress; + int m_gap; + + std::array m_activeIndicators; +}; + +} // namespace caelestia::controls diff --git a/plugin/src/Caelestia/cutils.cpp b/plugin/src/Caelestia/cutils.cpp index 28a0e178..9c2426c2 100644 --- a/plugin/src/Caelestia/cutils.cpp +++ b/plugin/src/Caelestia/cutils.cpp @@ -100,7 +100,7 @@ void CUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, Q }); } -bool CUtils::copyFile(const QUrl& source, const QUrl& target, bool overwrite) const { +bool CUtils::copyFile(const QUrl& source, const QUrl& target, bool overwrite) { if (!source.isLocalFile()) { qCWarning(lcCUtils) << "copyFile: source" << source << "is not a local file"; return false; @@ -120,7 +120,7 @@ bool CUtils::copyFile(const QUrl& source, const QUrl& target, bool overwrite) co return QFile::copy(source.toLocalFile(), target.toLocalFile()); } -bool CUtils::deleteFile(const QUrl& path) const { +bool CUtils::deleteFile(const QUrl& path) { if (!path.isLocalFile()) { qCWarning(lcCUtils) << "deleteFile: path" << path << "is not a local file"; return false; @@ -129,7 +129,7 @@ bool CUtils::deleteFile(const QUrl& path) const { return QFile::remove(path.toLocalFile()); } -QString CUtils::toLocalFile(const QUrl& url) const { +QString CUtils::toLocalFile(const QUrl& url) { if (!url.isLocalFile()) { qCWarning(lcCUtils) << "toLocalFile: given url is not a local file" << url; return QString(); @@ -138,4 +138,8 @@ QString CUtils::toLocalFile(const QUrl& url) const { return url.toLocalFile(); } +qreal CUtils::clamp(qreal value, qreal min, qreal max) { + return qBound(min, value, max); +} + } // namespace caelestia diff --git a/plugin/src/Caelestia/cutils.hpp b/plugin/src/Caelestia/cutils.hpp index 027226d6..29ae3ca5 100644 --- a/plugin/src/Caelestia/cutils.hpp +++ b/plugin/src/Caelestia/cutils.hpp @@ -21,9 +21,11 @@ public: Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved, QJSValue onFailed); // clang-format on - Q_INVOKABLE bool copyFile(const QUrl& source, const QUrl& target, bool overwrite = true) const; - Q_INVOKABLE bool deleteFile(const QUrl& path) const; - Q_INVOKABLE QString toLocalFile(const QUrl& url) const; + Q_INVOKABLE static bool copyFile(const QUrl& source, const QUrl& target, bool overwrite = true); + Q_INVOKABLE static bool deleteFile(const QUrl& path); + Q_INVOKABLE static QString toLocalFile(const QUrl& url); + + Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max); }; } // namespace caelestia