feat: add wavy circular indicator

This commit is contained in:
2 * r + 2 * t 2026-04-23 20:53:15 +10:00
parent eed9e4f8a0
commit 13713aa5d7
4 changed files with 86 additions and 92 deletions

View file

@ -20,7 +20,7 @@ BusyIndicator {
}
property real implicitSize: Tokens.font.body.medium.pointSize * 3
property real strokeWidth: Tokens.padding.small * 0.8
property real strokeWidth: Tokens.padding.extraSmall
property color fgColour: Colours.palette.m3primary
property color bgColour: Colours.palette.m3secondaryContainer

View file

@ -1,68 +1,101 @@
import QtQuick
import QtQuick.Shapes
import Caelestia.Components
import Caelestia.Config
import qs.components
import qs.services
Shape {
Item {
id: root
property real value
property int startAngle: -90
property int sweepAngle: 360
property int strokeWidth: Tokens.padding.small
property int padding: 0
property int spacing: Tokens.spacing.small
property color fgColour: Colours.palette.m3primary
property color bgColour: Colours.palette.m3secondaryContainer
property bool wavy: false
property alias waveFrequency: wave.frequency
property alias waveAmplitude: wave.amplitudeMultiplier
property alias wavePaused: waveProgAnim.paused
property alias waveDuration: waveProgAnim.duration
readonly property real size: Math.min(width, height)
readonly property real arcRadius: (size - padding - strokeWidth) / 2
readonly property real vValue: value || 1 / 360
readonly property real arcRadius: (size - padding - strokeWidth * (1 + waveAmplitude * 2)) / 2
readonly property real clampedVal: Math.max(1 / 360, Math.min(1, value))
readonly property real gapAngle: ((spacing + strokeWidth) / (arcRadius || 1)) * (180 / Math.PI)
preferredRendererType: Shape.CurveRenderer
asynchronous: true
readonly property real thickness: strokeWidth * (1 + waveAmplitude) * 2 // For consumers
property real implicitSize
ShapePath {
fillColor: "transparent"
strokeColor: root.bgColour
strokeWidth: root.strokeWidth
capStyle: root.Tokens.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap
implicitWidth: implicitSize
implicitHeight: implicitSize
PathAngleArc {
startAngle: root.startAngle + 360 * root.vValue + root.gapAngle
sweepAngle: Math.max(-root.gapAngle, 360 * (1 - root.vValue) - root.gapAngle * 2)
radiusX: root.arcRadius
radiusY: root.arcRadius
centerX: root.size / 2
centerY: root.size / 2
}
Shape {
preferredRendererType: Shape.CurveRenderer
asynchronous: true
opacity: Math.min(1, remainingArc.sweepAngle)
Behavior on strokeColor {
CAnim {
duration: Tokens.anim.durations.large
ShapePath {
fillColor: "transparent"
strokeColor: root.bgColour
strokeWidth: Math.min(1, remainingArc.sweepAngle) * root.strokeWidth
capStyle: ShapePath.RoundCap
PathAngleArc {
id: remainingArc
radiusX: root.arcRadius
radiusY: root.arcRadius
centerX: root.size / 2
centerY: root.size / 2
startAngle: root.startAngle + root.clampedVal * root.sweepAngle + root.gapAngle
sweepAngle: Math.max(1 / 360, root.sweepAngle * (1 - root.clampedVal) - root.gapAngle * 2)
}
Behavior on strokeColor {
CAnim {}
}
}
}
ShapePath {
fillColor: "transparent"
strokeColor: root.fgColour
strokeWidth: root.strokeWidth
capStyle: root.Tokens.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap
WavyLine {
id: wave
PathAngleArc {
startAngle: root.startAngle
sweepAngle: 360 * root.vValue
radiusX: root.arcRadius
radiusY: root.arcRadius
centerX: root.size / 2
centerY: root.size / 2
anchors.fill: parent
anchors.margins: -lineWidth * amplitudeMultiplier
lineWidth: root.strokeWidth
color: root.fgColour
pathType: WavyLine.Arc
radius: root.arcRadius
startAngle: root.startAngle
fullAngle: root.sweepAngle
value: root.clampedVal
frequency: 8
amplitudeMultiplier: root.wavy ? 0.5 : 0
Anim on waveProgress {
id: waveProgAnim
running: true
from: 0
to: 1
duration: 2000
easing.type: Easing.Linear
loops: Animation.Infinite
}
Behavior on strokeColor {
CAnim {
duration: Tokens.anim.durations.large
Behavior on color {
CAnim {}
}
Behavior on amplitudeMultiplier {
Anim {
type: Anim.DefaultEffects
}
}
}

View file

@ -19,9 +19,7 @@ Item {
return active?.length ? (active.position % active.length) / active.length : 0;
}
readonly property real arcCoverGap: Tokens.spacing.small
readonly property real arcRadius: (cover.width + Tokens.sizes.dashboard.mediaProgressThickness) / 2 + arcCoverGap
readonly property real arcGap: (Tokens.spacing.extraSmall + Tokens.sizes.dashboard.mediaProgressThickness) / arcRadius * 180 / Math.PI
readonly property real arcCoverGap: Tokens.spacing.extraSmall
anchors.top: parent.top
anchors.bottom: parent.bottom
@ -45,59 +43,22 @@ Item {
service: Audio.beatTracker
}
Shape {
preferredRendererType: Shape.CurveRenderer
opacity: Math.min(1, remainingArc.sweepAngle)
CircularProgress {
id: prog
ShapePath {
fillColor: "transparent"
strokeColor: Colours.palette.m3secondaryContainer
strokeWidth: Math.min(1, remainingArc.sweepAngle) * root.Tokens.sizes.dashboard.mediaProgressThickness
capStyle: ShapePath.RoundCap
anchors.centerIn: cover
implicitSize: cover.width + root.arcCoverGap + thickness * 2
PathAngleArc {
id: remainingArc
centerX: cover.x + cover.width / 2
centerY: cover.y + cover.height / 2
radiusX: root.arcRadius
radiusY: root.arcRadius
startAngle: -90 - root.Tokens.sizes.dashboard.mediaProgressSweep / 2 + root.playerProgress * root.Tokens.sizes.dashboard.mediaProgressSweep + root.arcGap
sweepAngle: Math.max(0.1, root.Tokens.sizes.dashboard.mediaProgressSweep * (1 - root.playerProgress) - root.arcGap)
}
Behavior on strokeColor {
CAnim {}
}
}
}
WavyLine {
anchors.fill: cover
anchors.margins: -lineWidth * amplitudeMultiplier * 2 - lineWidth - root.arcCoverGap
lineWidth: Tokens.sizes.dashboard.mediaProgressThickness
color: Colours.palette.m3primary
pathType: WavyLine.Arc
radius: root.arcRadius
frequency: 8
startAngle: -fullAngle / 2
fullAngle: Tokens.sizes.dashboard.mediaProgressSweep
fgColour: Colours.palette.m3primary
strokeWidth: Tokens.sizes.dashboard.mediaProgressThickness
startAngle: -90 - sweepAngle / 2
sweepAngle: Tokens.sizes.dashboard.mediaProgressSweep
value: root.playerProgress
Anim on waveProgress {
running: true
paused: !Players.active?.isPlaying
from: 0
to: 1
duration: 2000
easing.type: Easing.Linear
loops: Animation.Infinite
}
Behavior on color {
CAnim {}
}
wavy: true
waveFrequency: 8
waveDuration: 2000
wavePaused: !Players.active?.isPlaying
}
Item {
@ -106,7 +67,7 @@ Item {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Tokens.padding.large + Tokens.sizes.dashboard.mediaProgressThickness + root.arcCoverGap
anchors.margins: Tokens.padding.medium + root.arcCoverGap + prog.thickness
implicitHeight: width
// Slight glow to separate from bg

View file

@ -19,7 +19,7 @@ WavyLine::WavyLine(QQuickItem* parent)
, m_fullAngle(360)
, m_radius(-1)
, m_value(1)
, m_startAngleRad(-M_PI / 2.0)
, m_startAngleRad(0)
, m_fullAngleRad(2 * M_PI) {
setAntialiasing(true);
}
@ -127,7 +127,7 @@ qreal WavyLine::startAngle() const {
void WavyLine::setStartAngle(qreal startAngle) {
if (!qFuzzyCompare(m_startAngle + 1.0, startAngle + 1.0)) {
m_startAngle = startAngle;
m_startAngleRad = startAngle * M_PI / 180.0 - M_PI / 2.0;
m_startAngleRad = startAngle * M_PI / 180.0;
emit startAngleChanged();
update();
}