feat: add styled progress bar

This commit is contained in:
2 * r + 2 * t 2026-04-23 22:05:37 +10:00
parent 21b5972d46
commit 28a84a9611
6 changed files with 480 additions and 15 deletions

View file

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

View file

@ -1,15 +1,16 @@
qml_module(caelestia-internal qml_module(caelestia-internal
URI Caelestia.Internal URI Caelestia.Internal
SOURCES SOURCES
arcgauge.hpp arcgauge.cpp arcgauge.cpp
cachingimagemanager.hpp cachingimagemanager.cpp cachingimagemanager.cpp
circularbuffer.hpp circularbuffer.cpp circularbuffer.cpp
circularindicatormanager.hpp circularindicatormanager.cpp circularindicatormanager.cpp
hyprdevices.hpp hyprdevices.cpp linearindicatormanager.cpp
hyprextras.hpp hyprextras.cpp hyprdevices.cpp
logindmanager.hpp logindmanager.cpp hyprextras.cpp
sparklineitem.hpp sparklineitem.cpp logindmanager.cpp
visualiserbars.hpp visualiserbars.cpp sparklineitem.cpp
visualiserbars.cpp
LIBRARIES LIBRARIES
Qt::Gui Qt::Gui
Qt::Quick Qt::Quick

View file

@ -0,0 +1,119 @@
#include "linearindicatormanager.hpp"
#include <qpoint.h>
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<qreal>(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<LinearIndicatorSegment*> 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

View file

@ -0,0 +1,86 @@
#pragma once
#include <qcolor.h>
#include <qeasingcurve.h>
#include <qobject.h>
#include <qqmlengine.h>
#include <qqmlintegration.h>
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<caelestia::controls::LinearIndicatorSegment*> 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<LinearIndicatorSegment*> 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<QEasingCurve, 4> m_interpolators;
qreal m_progress;
qreal m_completeEndProgress;
int m_gap;
std::array<LinearIndicatorSegment*, SEGMENTS> m_activeIndicators;
};
} // namespace caelestia::controls

View file

@ -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()) { if (!source.isLocalFile()) {
qCWarning(lcCUtils) << "copyFile: source" << source << "is not a local file"; qCWarning(lcCUtils) << "copyFile: source" << source << "is not a local file";
return false; 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()); return QFile::copy(source.toLocalFile(), target.toLocalFile());
} }
bool CUtils::deleteFile(const QUrl& path) const { bool CUtils::deleteFile(const QUrl& path) {
if (!path.isLocalFile()) { if (!path.isLocalFile()) {
qCWarning(lcCUtils) << "deleteFile: path" << path << "is not a local file"; qCWarning(lcCUtils) << "deleteFile: path" << path << "is not a local file";
return false; return false;
@ -129,7 +129,7 @@ bool CUtils::deleteFile(const QUrl& path) const {
return QFile::remove(path.toLocalFile()); return QFile::remove(path.toLocalFile());
} }
QString CUtils::toLocalFile(const QUrl& url) const { QString CUtils::toLocalFile(const QUrl& url) {
if (!url.isLocalFile()) { if (!url.isLocalFile()) {
qCWarning(lcCUtils) << "toLocalFile: given url is not a local file" << url; qCWarning(lcCUtils) << "toLocalFile: given url is not a local file" << url;
return QString(); return QString();
@ -138,4 +138,8 @@ QString CUtils::toLocalFile(const QUrl& url) const {
return url.toLocalFile(); return url.toLocalFile();
} }
qreal CUtils::clamp(qreal value, qreal min, qreal max) {
return qBound(min, value, max);
}
} // namespace caelestia } // namespace caelestia

View file

@ -21,9 +21,11 @@ public:
Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved, QJSValue onFailed); Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved, QJSValue onFailed);
// clang-format on // clang-format on
Q_INVOKABLE bool copyFile(const QUrl& source, const QUrl& target, bool overwrite = true) const; Q_INVOKABLE static bool copyFile(const QUrl& source, const QUrl& target, bool overwrite = true);
Q_INVOKABLE bool deleteFile(const QUrl& path) const; Q_INVOKABLE static bool deleteFile(const QUrl& path);
Q_INVOKABLE QString toLocalFile(const QUrl& url) const; Q_INVOKABLE static QString toLocalFile(const QUrl& url);
Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max);
}; };
} // namespace caelestia } // namespace caelestia