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

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()) {
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

View file

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