diff --git a/Helpers/NetworkUsage.qml b/Helpers/NetworkUsage.qml index a8178ad..51957da 100644 --- a/Helpers/NetworkUsage.qml +++ b/Helpers/NetworkUsage.qml @@ -1,17 +1,14 @@ pragma Singleton -import qs.Config - +import QtQuick import Quickshell import Quickshell.Io - -import QtQuick +import ZShell.Internal +import qs.Config Singleton { id: root - property var _downloadHistory: [] - // Private properties property real _downloadSpeed: 0 property real _downloadTotal: 0 @@ -25,12 +22,11 @@ Singleton { property real _prevRxBytes: 0 property real _prevTimestamp: 0 property real _prevTxBytes: 0 - property var _uploadHistory: [] property real _uploadSpeed: 0 property real _uploadTotal: 0 - // History of speeds for sparkline (most recent at end) - readonly property var downloadHistory: _downloadHistory + // History buffers for sparkline + readonly property CircularBuffer downloadBuffer: _downloadBuffer // Current speeds in bytes per second readonly property real downloadSpeed: _downloadSpeed @@ -39,7 +35,7 @@ Singleton { readonly property real downloadTotal: _downloadTotal readonly property int historyLength: 30 property int refCount: 0 - readonly property var uploadHistory: _uploadHistory + readonly property CircularBuffer uploadBuffer: _uploadBuffer readonly property real uploadSpeed: _uploadSpeed readonly property real uploadTotal: _uploadTotal @@ -139,6 +135,18 @@ Singleton { }; } + CircularBuffer { + id: _downloadBuffer + + capacity: root.historyLength + 1 + } + + CircularBuffer { + id: _uploadBuffer + + capacity: root.historyLength + 1 + } + FileView { id: netDevFile @@ -190,25 +198,11 @@ Singleton { root._downloadSpeed = rxDelta / timeDelta; root._uploadSpeed = txDelta / timeDelta; - const maxHistory = root.historyLength + 1; + if (root._downloadSpeed >= 0 && isFinite(root._downloadSpeed)) + _downloadBuffer.push(root._downloadSpeed); - if (root._downloadSpeed >= 0 && isFinite(root._downloadSpeed)) { - let newDownHist = root._downloadHistory.slice(); - newDownHist.push(root._downloadSpeed); - if (newDownHist.length > maxHistory) { - newDownHist.shift(); - } - root._downloadHistory = newDownHist; - } - - if (root._uploadSpeed >= 0 && isFinite(root._uploadSpeed)) { - let newUpHist = root._uploadHistory.slice(); - newUpHist.push(root._uploadSpeed); - if (newUpHist.length > maxHistory) { - newUpHist.shift(); - } - root._uploadHistory = newUpHist; - } + if (root._uploadSpeed >= 0 && isFinite(root._uploadSpeed)) + _uploadBuffer.push(root._uploadSpeed); } // Calculate totals with overflow handling diff --git a/Modules/Resources/Content.qml b/Modules/Resources/Content.qml index 432edc6..57c45e0 100644 --- a/Modules/Resources/Content.qml +++ b/Modules/Resources/Content.qml @@ -1,75 +1,30 @@ -import Quickshell import QtQuick import QtQuick.Controls import QtQuick.Layouts import Quickshell.Services.UPower +import ZShell.Internal import qs.Components -import qs.Config import qs.Helpers +import qs.Config Item { id: root readonly property int minWidth: 400 + 400 + Appearance.spacing.normal + 120 + Appearance.padding.large * 2 - readonly property real nonAnimHeight: (placeholder.visible ? placeholder.height : content.implicitHeight) + Appearance.padding.normal * 2 - readonly property real nonAnimWidth: Math.max(minWidth, content.implicitWidth) + Appearance.padding.normal * 2 - required property real padding - required property PersistentProperties visibilities function displayTemp(temp: real): string { return `${Math.ceil(temp)}°C`; } - implicitHeight: nonAnimHeight - implicitWidth: nonAnimWidth - - CustomRect { - id: placeholder - - anchors.centerIn: parent - color: DynamicColors.tPalette.m3surfaceContainer - height: 350 - radius: Appearance.rounding.large - 10 - visible: false - width: 400 - - ColumnLayout { - anchors.centerIn: parent - spacing: Appearance.spacing.normal - - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.extraLarge * 2 - text: "tune" - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: DynamicColors.palette.m3onSurface - font.pointSize: Appearance.font.size.large - text: qsTr("No widgets enabled") - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small - text: qsTr("Enable widgets in dashboard settings") - } - } - } + implicitHeight: content.implicitHeight + implicitWidth: Math.max(minWidth, content.implicitWidth) RowLayout { id: content anchors.left: parent.left - anchors.leftMargin: root.padding anchors.right: parent.right - anchors.rightMargin: root.padding - anchors.verticalCenter: parent.verticalCenter spacing: Appearance.spacing.normal - visible: !placeholder.visible Ref { service: SystemUsage @@ -84,7 +39,7 @@ Item { RowLayout { Layout.fillWidth: true spacing: Appearance.spacing.normal - visible: true + visible: Config.dashboard.performance.showCpu || (Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE") HeroCard { Layout.fillWidth: true @@ -172,7 +127,7 @@ Item { property real percentage: UPower.displayDevice.percentage color: DynamicColors.tPalette.m3surfaceContainer - radius: Appearance.rounding.large - 10 + radius: Appearance.rounding.large Behavior on animatedPercentage { Anim { @@ -318,7 +273,7 @@ Item { clip: true color: DynamicColors.tPalette.m3surfaceContainer - radius: Appearance.rounding.large - 10 + radius: Appearance.rounding.large Behavior on animatedPercentage { Anim { @@ -344,52 +299,15 @@ Item { Layout.fillHeight: true Layout.fillWidth: true - Canvas { - id: gaugeCanvas - + ArcGauge { + accentColor: gaugeCard.accentColor anchors.centerIn: parent height: width + percentage: gaugeCard.animatedPercentage + startAngle: gaugeCard.arcStartAngle + sweepAngle: gaugeCard.arcSweep + trackColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) width: Math.min(parent.width, parent.height) - - Component.onCompleted: requestPaint() - onPaint: { - const ctx = getContext("2d"); - ctx.reset(); - const cx = width / 2; - const cy = height / 2; - const radius = (Math.min(width, height) - 12) / 2; - const lineWidth = 10; - ctx.beginPath(); - ctx.arc(cx, cy, radius, gaugeCard.arcStartAngle, gaugeCard.arcStartAngle + gaugeCard.arcSweep); - ctx.lineWidth = lineWidth; - ctx.lineCap = "round"; - ctx.strokeStyle = DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2); - ctx.stroke(); - if (gaugeCard.animatedPercentage > 0) { - ctx.beginPath(); - ctx.arc(cx, cy, radius, gaugeCard.arcStartAngle, gaugeCard.arcStartAngle + gaugeCard.arcSweep * gaugeCard.animatedPercentage); - ctx.lineWidth = lineWidth; - ctx.lineCap = "round"; - ctx.strokeStyle = gaugeCard.accentColor; - ctx.stroke(); - } - } - - Connections { - function onAnimatedPercentageChanged() { - gaugeCanvas.requestPaint(); - } - - target: gaugeCard - } - - Connections { - function onPaletteChanged() { - gaugeCanvas.requestPaint(); - } - - target: DynamicColors - } } CustomText { @@ -427,7 +345,7 @@ Item { property real usage: 0 color: DynamicColors.tPalette.m3surfaceContainer - radius: Appearance.rounding.large - 10 + radius: Appearance.rounding.large Behavior on animatedTemp { Anim { @@ -452,66 +370,57 @@ Item { anchors.left: parent.left anchors.top: parent.top color: Qt.alpha(heroCard.accentColor, 0.15) - width: parent.width * heroCard.animatedUsage + implicitWidth: parent.width * heroCard.animatedUsage } - ColumnLayout { - anchors.bottomMargin: Appearance.padding.normal - anchors.fill: parent + CardHeader { + accentColor: heroCard.accentColor + anchors.left: parent.left anchors.leftMargin: Appearance.padding.large - anchors.rightMargin: Appearance.padding.large - anchors.topMargin: Appearance.padding.normal + anchors.top: parent.top + anchors.topMargin: Math.round(Appearance.padding.large * 1.2) + icon: heroCard.icon + title: heroCard.title + width: parent.width - anchors.leftMargin - usageColumn.anchors.rightMargin - usageLabel.width - Appearance.spacing.normal + } + + Column { + anchors.bottom: parent.bottom + anchors.bottomMargin: Math.round(Appearance.padding.large * 1.3) + anchors.left: parent.left + anchors.margins: Math.round(Appearance.padding.large * 1.2) + anchors.right: parent.right spacing: Appearance.spacing.small - CardHeader { - accentColor: heroCard.accentColor - icon: heroCard.icon - title: heroCard.title + Row { + spacing: Appearance.spacing.small + + CustomText { + font.pointSize: Appearance.font.size.normal + font.weight: Font.Medium + text: heroCard.secondaryValue + } + + CustomText { + anchors.baseline: parent.children[0].baseline + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + text: heroCard.secondaryLabel + } } - RowLayout { - Layout.fillHeight: true - Layout.fillWidth: true - spacing: Appearance.spacing.normal - - Column { - Layout.alignment: Qt.AlignBottom - Layout.fillWidth: true - spacing: Appearance.spacing.small - - Row { - spacing: Appearance.spacing.small - - CustomText { - font.pointSize: Appearance.font.size.normal - font.weight: Font.Medium - text: heroCard.secondaryValue - } - - CustomText { - anchors.baseline: parent.children[0].baseline - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small - text: heroCard.secondaryLabel - } - } - - ProgressBar { - bgColor: Qt.alpha(heroCard.accentColor, 0.2) - fgColor: heroCard.accentColor - height: 6 - value: heroCard.tempProgress - width: parent.width * 0.5 - } - } - - Item { - Layout.fillWidth: true - } + ProgressBar { + bgColor: Qt.alpha(heroCard.accentColor, 0.2) + fgColor: heroCard.accentColor + implicitHeight: 6 + implicitWidth: parent.width * 0.5 + value: heroCard.tempProgress } } Column { + id: usageColumn + anchors.margins: Appearance.padding.large anchors.right: parent.right anchors.rightMargin: 32 @@ -519,6 +428,8 @@ Item { spacing: 0 CustomText { + id: usageLabel + anchors.right: parent.right color: DynamicColors.palette.m3onSurfaceVariant font.pointSize: Appearance.font.size.normal @@ -541,7 +452,7 @@ Item { clip: true color: DynamicColors.tPalette.m3surfaceContainer - radius: Appearance.rounding.large - 10 + radius: Appearance.rounding.large Ref { service: NetworkUsage @@ -563,109 +474,45 @@ Item { Layout.fillHeight: true Layout.fillWidth: true - Canvas { - id: sparklineCanvas + SparklineItem { + id: sparkline - property int _lastTickCount: -1 - property int _tickCount: 0 - property var downHistory: NetworkUsage.downloadHistory - property real slideProgress: 0 property real smoothMax: targetMax property real targetMax: 1024 - property var upHistory: NetworkUsage.uploadHistory - - function checkAndAnimate(): void { - const currentLength = (downHistory || []).length; - if (currentLength > 0 && _tickCount !== _lastTickCount) { - _lastTickCount = _tickCount; - updateMax(); - } - } - - function updateMax(): void { - const downHist = downHistory || []; - const upHist = upHistory || []; - const allValues = downHist.concat(upHist); - targetMax = Math.max(...allValues, 1024); - requestPaint(); - } anchors.fill: parent + historyLength: NetworkUsage.historyLength + line1: NetworkUsage.uploadBuffer // qmllint disable missing-type + line1Color: DynamicColors.palette.m3secondary + line1FillAlpha: 0.15 + line2: NetworkUsage.downloadBuffer // qmllint disable missing-type + line2Color: DynamicColors.palette.m3tertiary + line2FillAlpha: 0.2 + maxValue: smoothMax - NumberAnimation on slideProgress { - duration: Config.dashboard.resourceUpdateInterval - from: 0 - loops: Animation.Infinite - running: true - to: 1 - } Behavior on smoothMax { Anim { duration: Appearance.anim.durations.large } } - Component.onCompleted: updateMax() - onDownHistoryChanged: checkAndAnimate() - onPaint: { - const ctx = getContext("2d"); - ctx.reset(); - const w = width; - const h = height; - const downHist = downHistory || []; - const upHist = upHistory || []; - if (downHist.length < 2 && upHist.length < 2) - return; - - const maxVal = smoothMax; - - const drawLine = (history, color, fillAlpha) => { - if (history.length < 2) - return; - - const len = history.length; - const stepX = w / (NetworkUsage.historyLength - 1); - const startX = w - (len - 1) * stepX - stepX * slideProgress + stepX; - ctx.beginPath(); - ctx.moveTo(startX, h - (history[0] / maxVal) * h); - for (let i = 1; i < len; i++) { - const x = startX + i * stepX; - const y = h - (history[i] / maxVal) * h; - ctx.lineTo(x, y); - } - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.lineCap = "round"; - ctx.lineJoin = "round"; - ctx.stroke(); - ctx.lineTo(startX + (len - 1) * stepX, h); - ctx.lineTo(startX, h); - ctx.closePath(); - ctx.fillStyle = Qt.rgba(Qt.color(color).r, Qt.color(color).g, Qt.color(color).b, fillAlpha); - ctx.fill(); - }; - - drawLine(upHist, DynamicColors.palette.m3secondary.toString(), 0.15); - drawLine(downHist, DynamicColors.palette.m3tertiary.toString(), 0.2); - } - onSlideProgressChanged: requestPaint() - onSmoothMaxChanged: requestPaint() - onUpHistoryChanged: checkAndAnimate() - Connections { - function onPaletteChanged() { - sparklineCanvas.requestPaint(); + function onValuesChanged(): void { + sparkline.targetMax = Math.max(NetworkUsage.downloadBuffer.maximum, NetworkUsage.uploadBuffer.maximum, 1024); + slideAnim.restart(); } - target: DynamicColors + target: NetworkUsage.downloadBuffer } - Timer { - interval: Config.dashboard.resourceUpdateInterval - repeat: true - running: true + NumberAnimation { + id: slideAnim - onTriggered: sparklineCanvas._tickCount++ + duration: Config.dashboard.resourceUpdateInterval + from: 0 + property: "slideProgress" + target: sparkline + to: 1 } } @@ -676,7 +523,7 @@ Item { font.pointSize: Appearance.font.size.small opacity: 0.6 text: qsTr("Collecting data...") - visible: NetworkUsage.downloadHistory.length < 2 + visible: NetworkUsage.downloadBuffer.count < 2 } } @@ -819,7 +666,7 @@ Item { clip: true color: DynamicColors.tPalette.m3surfaceContainer - radius: Appearance.rounding.large - 10 + radius: Appearance.rounding.large Behavior on animatedPercentage { Anim { @@ -891,7 +738,6 @@ Item { HoverHandler { id: hintHover - } } } @@ -900,52 +746,15 @@ Item { Layout.fillHeight: true Layout.fillWidth: true - Canvas { - id: storageGaugeCanvas - + ArcGauge { + accentColor: storageGaugeCard.accentColor anchors.centerIn: parent height: width + percentage: storageGaugeCard.animatedPercentage + startAngle: storageGaugeCard.arcStartAngle + sweepAngle: storageGaugeCard.arcSweep + trackColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) width: Math.min(parent.width, parent.height) - - Component.onCompleted: requestPaint() - onPaint: { - const ctx = getContext("2d"); - ctx.reset(); - const cx = width / 2; - const cy = height / 2; - const radius = (Math.min(width, height) - 12) / 2; - const lineWidth = 10; - ctx.beginPath(); - ctx.arc(cx, cy, radius, storageGaugeCard.arcStartAngle, storageGaugeCard.arcStartAngle + storageGaugeCard.arcSweep); - ctx.lineWidth = lineWidth; - ctx.lineCap = "round"; - ctx.strokeStyle = DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2); - ctx.stroke(); - if (storageGaugeCard.animatedPercentage > 0) { - ctx.beginPath(); - ctx.arc(cx, cy, radius, storageGaugeCard.arcStartAngle, storageGaugeCard.arcStartAngle + storageGaugeCard.arcSweep * storageGaugeCard.animatedPercentage); - ctx.lineWidth = lineWidth; - ctx.lineCap = "round"; - ctx.strokeStyle = storageGaugeCard.accentColor; - ctx.stroke(); - } - } - - Connections { - function onAnimatedPercentageChanged() { - storageGaugeCanvas.requestPaint(); - } - - target: storageGaugeCard - } - - Connections { - function onPaletteChanged() { - storageGaugeCanvas.requestPaint(); - } - - target: DynamicColors - } } CustomText { diff --git a/Modules/Updates/UpdatesPopout.qml b/Modules/Updates/UpdatesPopout.qml index 2b592d3..895bd76 100644 --- a/Modules/Updates/UpdatesPopout.qml +++ b/Modules/Updates/UpdatesPopout.qml @@ -103,17 +103,18 @@ CustomClippingRect { Layout.fillHeight: true Layout.preferredWidth: 300 - CustomText { + MarqueeText { id: versionFrom Layout.fillHeight: true Layout.preferredWidth: 125 + animate: true color: DynamicColors.palette.m3tertiary - elide: Text.ElideRight font.pointSize: Appearance.font.size.large - horizontalAlignment: Text.AlignHCenter + marqueeEnabled: true + pauseMs: 4000 text: update.sections[1] - verticalAlignment: Text.AlignVCenter + width: 125 } MaterialIcon { @@ -125,17 +126,18 @@ CustomClippingRect { verticalAlignment: Text.AlignVCenter } - CustomText { + MarqueeText { id: versionTo Layout.fillHeight: true Layout.preferredWidth: 120 + animate: true color: DynamicColors.palette.m3primary - elide: Text.ElideRight font.pointSize: Appearance.font.size.large - horizontalAlignment: Text.AlignHCenter + marqueeEnabled: true + pauseMs: 4000 text: update.sections[3] - verticalAlignment: Text.AlignVCenter + width: 125 } } } diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index 1883d73..5bdce41 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -5,6 +5,7 @@ qml_module(ZShell-internal hyprdevices.hpp hyprdevices.cpp cachingimagemanager.hpp cachingimagemanager.cpp circularindicatormanager.hpp circularindicatormanager.cpp + circularbuffer.hpp circularbuffer.cpp LIBRARIES Qt::Gui Qt::Quick diff --git a/Plugins/ZShell/Internal/circularbuffer.cpp b/Plugins/ZShell/Internal/circularbuffer.cpp new file mode 100644 index 0000000..2d508ce --- /dev/null +++ b/Plugins/ZShell/Internal/circularbuffer.cpp @@ -0,0 +1,95 @@ +#include "circularbuffer.hpp" + +#include + +namespace caelestia::internal { + +CircularBuffer::CircularBuffer(QObject* parent) + : QObject(parent) { +} + +int CircularBuffer::capacity() const { + return m_capacity; +} + +void CircularBuffer::setCapacity(int capacity) { + if (capacity < 0) + capacity = 0; + if (m_capacity == capacity) + return; + + const auto old = values(); + + m_capacity = capacity; + m_data.resize(capacity); + m_data.fill(0.0); + m_head = 0; + m_count = 0; + + // Re-push old values, keeping the most recent ones + const auto start = old.size() > capacity ? old.size() - capacity : 0; + for (auto i = start; i < old.size(); ++i) { + m_data[m_head] = old[i]; + m_head = (m_head + 1) % m_capacity; + m_count++; + } + + emit capacityChanged(); + emit countChanged(); + emit valuesChanged(); +} + +int CircularBuffer::count() const { + return m_count; +} + +QList CircularBuffer::values() const { + QList result; + result.reserve(m_count); + for (int i = 0; i < m_count; ++i) + result.append(at(i)); + return result; +} + +qreal CircularBuffer::maximum() const { + if (m_count == 0) + return 0.0; + + qreal maxVal = at(0); + for (int i = 1; i < m_count; ++i) + maxVal = std::max(maxVal, at(i)); + return maxVal; +} + +void CircularBuffer::push(qreal value) { + if (m_capacity <= 0) + return; + + m_data[m_head] = value; + m_head = (m_head + 1) % m_capacity; + if (m_count < m_capacity) { + m_count++; + emit countChanged(); + } + emit valuesChanged(); +} + +void CircularBuffer::clear() { + if (m_count == 0) + return; + + m_head = 0; + m_count = 0; + emit countChanged(); + emit valuesChanged(); +} + +qreal CircularBuffer::at(int index) const { + if (index < 0 || index >= m_count) + return 0.0; + + const int actualIndex = (m_head - m_count + index + m_capacity) % m_capacity; + return m_data[actualIndex]; +} + +} // namespace caelestia::internal diff --git a/Plugins/ZShell/Internal/circularbuffer.hpp b/Plugins/ZShell/Internal/circularbuffer.hpp new file mode 100644 index 0000000..3c31849 --- /dev/null +++ b/Plugins/ZShell/Internal/circularbuffer.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +namespace caelestia::internal { + +class CircularBuffer : public QObject { +Q_OBJECT +QML_ELEMENT + +Q_PROPERTY(int capacity READ capacity WRITE setCapacity NOTIFY capacityChanged) +Q_PROPERTY(int count READ count NOTIFY countChanged) +Q_PROPERTY(QList values READ values NOTIFY valuesChanged) +Q_PROPERTY(qreal maximum READ maximum NOTIFY valuesChanged) + +public: +explicit CircularBuffer(QObject* parent = nullptr); + +[[nodiscard]] int capacity() const; +void setCapacity(int capacity); + +[[nodiscard]] int count() const; +[[nodiscard]] QList values() const; +[[nodiscard]] qreal maximum() const; + +Q_INVOKABLE void push(qreal value); +Q_INVOKABLE void clear(); +Q_INVOKABLE [[nodiscard]] qreal at(int index) const; + +signals: +void capacityChanged(); +void countChanged(); +void valuesChanged(); + +private: +QVector m_data; +int m_head = 0; +int m_count = 0; +int m_capacity = 0; +}; + +} // namespace caelestia::internal diff --git a/Plugins/ZShell/Internal/sparklineitem.cpp b/Plugins/ZShell/Internal/sparklineitem.cpp new file mode 100644 index 0000000..2ec00d3 --- /dev/null +++ b/Plugins/ZShell/Internal/sparklineitem.cpp @@ -0,0 +1,215 @@ +#include "sparklineitem.hpp" + +#include +#include +#include + +namespace caelestia::internal { + +SparklineItem::SparklineItem(QQuickItem* parent) + : QQuickPaintedItem(parent) { + setAntialiasing(true); +} + +void SparklineItem::paint(QPainter* painter) { + const bool has1 = m_line1 && m_line1->count() >= 2; + const bool has2 = m_line2 && m_line2->count() >= 2; + if (!has1 && !has2) + return; + + painter->setRenderHint(QPainter::Antialiasing, true); + + // Draw line1 first (behind), then line2 (in front) + if (has1) + drawLine(painter, m_line1, m_line1Color, m_line1FillAlpha); + if (has2) + drawLine(painter, m_line2, m_line2Color, m_line2FillAlpha); +} + +void SparklineItem::drawLine(QPainter* painter, CircularBuffer* buffer, const QColor& color, qreal fillAlpha) { + if (m_historyLength < 2) + return; + + const qreal w = width(); + const qreal h = height(); + const int len = buffer->count(); + const qreal stepX = w / static_cast(m_historyLength - 1); + const qreal startX = w - (len - 1) * stepX - stepX * m_slideProgress + stepX; + + // Build line path + QPainterPath linePath; + linePath.moveTo(startX, h - (buffer->at(0) / m_maxValue) * h); + for (int i = 1; i < len; ++i) { + const qreal x = startX + i * stepX; + const qreal y = h - (buffer->at(i) / m_maxValue) * h; + linePath.lineTo(x, y); + } + + // Stroke the line + QPen pen(color, m_lineWidth); + pen.setCapStyle(Qt::RoundCap); + pen.setJoinStyle(Qt::RoundJoin); + painter->setPen(pen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(linePath); + + // Fill under the line + QPainterPath fillPath = linePath; + fillPath.lineTo(startX + (len - 1) * stepX, h); + fillPath.lineTo(startX, h); + fillPath.closeSubpath(); + + QColor fillColor = color; + fillColor.setAlphaF(static_cast(fillAlpha)); + painter->setPen(Qt::NoPen); + painter->setBrush(fillColor); + painter->drawPath(fillPath); +} + +void SparklineItem::connectBuffer(CircularBuffer* buffer) { + if (!buffer) + return; + + connect(buffer, &CircularBuffer::valuesChanged, this, [this]() { + update(); + }); + connect(buffer, &QObject::destroyed, this, [this, buffer]() { + if (m_line1 == buffer) { + m_line1 = nullptr; + emit line1Changed(); + } + if (m_line2 == buffer) { + m_line2 = nullptr; + emit line2Changed(); + } + update(); + }); +} + +CircularBuffer* SparklineItem::line1() const { + return m_line1; +} + +void SparklineItem::setLine1(CircularBuffer* buffer) { + if (m_line1 == buffer) + return; + if (m_line1) + disconnect(m_line1, nullptr, this, nullptr); + m_line1 = buffer; + connectBuffer(buffer); + emit line1Changed(); + update(); +} + +CircularBuffer* SparklineItem::line2() const { + return m_line2; +} + +void SparklineItem::setLine2(CircularBuffer* buffer) { + if (m_line2 == buffer) + return; + if (m_line2) + disconnect(m_line2, nullptr, this, nullptr); + m_line2 = buffer; + connectBuffer(buffer); + emit line2Changed(); + update(); +} + +QColor SparklineItem::line1Color() const { + return m_line1Color; +} + +void SparklineItem::setLine1Color(const QColor& color) { + if (m_line1Color == color) + return; + m_line1Color = color; + emit line1ColorChanged(); + update(); +} + +QColor SparklineItem::line2Color() const { + return m_line2Color; +} + +void SparklineItem::setLine2Color(const QColor& color) { + if (m_line2Color == color) + return; + m_line2Color = color; + emit line2ColorChanged(); + update(); +} + +qreal SparklineItem::line1FillAlpha() const { + return m_line1FillAlpha; +} + +void SparklineItem::setLine1FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line1FillAlpha, alpha)) + return; + m_line1FillAlpha = alpha; + emit line1FillAlphaChanged(); + update(); +} + +qreal SparklineItem::line2FillAlpha() const { + return m_line2FillAlpha; +} + +void SparklineItem::setLine2FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line2FillAlpha, alpha)) + return; + m_line2FillAlpha = alpha; + emit line2FillAlphaChanged(); + update(); +} + +qreal SparklineItem::maxValue() const { + return m_maxValue; +} + +void SparklineItem::setMaxValue(qreal value) { + if (qFuzzyCompare(m_maxValue, value)) + return; + m_maxValue = value; + emit maxValueChanged(); + update(); +} + +qreal SparklineItem::slideProgress() const { + return m_slideProgress; +} + +void SparklineItem::setSlideProgress(qreal progress) { + if (qFuzzyCompare(m_slideProgress, progress)) + return; + m_slideProgress = progress; + emit slideProgressChanged(); + update(); +} + +int SparklineItem::historyLength() const { + return m_historyLength; +} + +void SparklineItem::setHistoryLength(int length) { + if (m_historyLength == length) + return; + m_historyLength = length; + emit historyLengthChanged(); + update(); +} + +qreal SparklineItem::lineWidth() const { + return m_lineWidth; +} + +void SparklineItem::setLineWidth(qreal width) { + if (qFuzzyCompare(m_lineWidth, width)) + return; + m_lineWidth = width; + emit lineWidthChanged(); + update(); +} + +} // namespace caelestia::internal diff --git a/Plugins/ZShell/Internal/sparklineitem.hpp b/Plugins/ZShell/Internal/sparklineitem.hpp new file mode 100644 index 0000000..e32e2c7 --- /dev/null +++ b/Plugins/ZShell/Internal/sparklineitem.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include + +#include "circularbuffer.hpp" + +namespace caelestia::internal { + +class SparklineItem : public QQuickPaintedItem { +Q_OBJECT +QML_ELEMENT + +Q_PROPERTY(CircularBuffer* line1 READ line1 WRITE setLine1 NOTIFY line1Changed) +Q_PROPERTY(CircularBuffer* line2 READ line2 WRITE setLine2 NOTIFY line2Changed) +Q_PROPERTY(QColor line1Color READ line1Color WRITE setLine1Color NOTIFY line1ColorChanged) +Q_PROPERTY(QColor line2Color READ line2Color WRITE setLine2Color NOTIFY line2ColorChanged) +Q_PROPERTY(qreal line1FillAlpha READ line1FillAlpha WRITE setLine1FillAlpha NOTIFY line1FillAlphaChanged) +Q_PROPERTY(qreal line2FillAlpha READ line2FillAlpha WRITE setLine2FillAlpha NOTIFY line2FillAlphaChanged) +Q_PROPERTY(qreal maxValue READ maxValue WRITE setMaxValue NOTIFY maxValueChanged) +Q_PROPERTY(qreal slideProgress READ slideProgress WRITE setSlideProgress NOTIFY slideProgressChanged) +Q_PROPERTY(int historyLength READ historyLength WRITE setHistoryLength NOTIFY historyLengthChanged) +Q_PROPERTY(qreal lineWidth READ lineWidth WRITE setLineWidth NOTIFY lineWidthChanged) + +public: +explicit SparklineItem(QQuickItem* parent = nullptr); + +void paint(QPainter* painter) override; + +[[nodiscard]] CircularBuffer* line1() const; +void setLine1(CircularBuffer* buffer); + +[[nodiscard]] CircularBuffer* line2() const; +void setLine2(CircularBuffer* buffer); + +[[nodiscard]] QColor line1Color() const; +void setLine1Color(const QColor& color); + +[[nodiscard]] QColor line2Color() const; +void setLine2Color(const QColor& color); + +[[nodiscard]] qreal line1FillAlpha() const; +void setLine1FillAlpha(qreal alpha); + +[[nodiscard]] qreal line2FillAlpha() const; +void setLine2FillAlpha(qreal alpha); + +[[nodiscard]] qreal maxValue() const; +void setMaxValue(qreal value); + +[[nodiscard]] qreal slideProgress() const; +void setSlideProgress(qreal progress); + +[[nodiscard]] int historyLength() const; +void setHistoryLength(int length); + +[[nodiscard]] qreal lineWidth() const; +void setLineWidth(qreal width); + +signals: +void line1Changed(); +void line2Changed(); +void line1ColorChanged(); +void line2ColorChanged(); +void line1FillAlphaChanged(); +void line2FillAlphaChanged(); +void maxValueChanged(); +void slideProgressChanged(); +void historyLengthChanged(); +void lineWidthChanged(); + +private: +void drawLine(QPainter* painter, CircularBuffer* buffer, const QColor& color, qreal fillAlpha); +void connectBuffer(CircularBuffer* buffer); + +CircularBuffer* m_line1 = nullptr; +CircularBuffer* m_line2 = nullptr; +QColor m_line1Color; +QColor m_line2Color; +qreal m_line1FillAlpha = 0.15; +qreal m_line2FillAlpha = 0.2; +qreal m_maxValue = 1024.0; +qreal m_slideProgress = 0.0; +int m_historyLength = 30; +qreal m_lineWidth = 2.0; +}; + +} // namespace caelestia::internal