From e49ecabf62524b06f646e76b7f4546fe4944c959 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 9 Nov 2026 20:20:27 +0100 Subject: [PATCH] fix: graph jitter on load + format --- Modules/Dashboard/Processes/SystemInfo.qml | 29 +-- Modules/Resources/Cards/NetworkCard.qml | 14 -- Plugins/ZShell/Components/linegraph.cpp | 171 +++++++----------- Plugins/ZShell/Components/linegraph.hpp | 4 +- .../ZShell/Components/sparklinecontent.cpp | 85 ++------- .../ZShell/Components/sparklinecontent.hpp | 13 +- Plugins/ZShell/Components/sparklineitem.cpp | 28 ++- Plugins/ZShell/Components/sparklineitem.hpp | 4 + .../ZShell/Components/wavylinerenderer.cpp | 4 +- Plugins/ZShell/Internal/linegeometry.cpp | 35 ++-- Plugins/ZShell/Internal/linegeometry.hpp | 8 +- Plugins/ZShell/zutils.cpp | 100 +++++----- Plugins/ZShell/zutils.hpp | 6 +- 13 files changed, 181 insertions(+), 320 deletions(-) diff --git a/Modules/Dashboard/Processes/SystemInfo.qml b/Modules/Dashboard/Processes/SystemInfo.qml index 7cacb0c..a1b8f2f 100644 --- a/Modules/Dashboard/Processes/SystemInfo.qml +++ b/Modules/Dashboard/Processes/SystemInfo.qml @@ -61,8 +61,8 @@ Item { implicitHeight: mainLayout.implicitHeight + mainLayout.anchors.margins * 2 SnapLayout { -orientation: Qt.Vertical id: mainLayout + orientation: Qt.Vertical anchors.fill: parent anchors.margins: Tokens.padding.large spacing: 0 @@ -102,31 +102,6 @@ orientation: Qt.Vertical line1FillAlpha: 0.15 maxValue: 1.0 slideInterval: Config.dashboard.resourceUpdateInterval - slideProgress: 1 - - Connections { - function onValuesChanged(): void { - slideAnim.restart(); - } - - target: card.buffer - } - - NumberAnimation { - id: slideAnim - - // Only drives frames; the slide phase is the sample - // clock inside SparklineItem. It must not stop at the - // end of a cycle, or the newest value is held on the - // clipped edge until the next sample arrives. - duration: Config.dashboard.resourceUpdateInterval - easing.type: Easing.Linear - from: 0 - loops: Animation.Infinite - property: "slideProgress" - target: sparkline - to: 1 - } } // "Collecting data" placeholder @@ -139,8 +114,8 @@ orientation: Qt.Vertical } SnapLayout { -orientation: Qt.Vertical id: infoColumn + orientation: Qt.Vertical anchors.right: parent.right anchors.bottom: parent.bottom width: { diff --git a/Modules/Resources/Cards/NetworkCard.qml b/Modules/Resources/Cards/NetworkCard.qml index 7b17acc..bb02b7a 100644 --- a/Modules/Resources/Cards/NetworkCard.qml +++ b/Modules/Resources/Cards/NetworkCard.qml @@ -63,7 +63,6 @@ CustomRect { line2FillAlpha: 0.2 maxValue: smoothMax slideInterval: Config.dashboard.resourceUpdateInterval - slideProgress: 1 Behavior on smoothMax { Anim {} @@ -76,23 +75,10 @@ CustomRect { Connections { function onUpdateTriggered(): void { sparkline.targetMax = Math.max(NetworkUsage.downloadBuffer.maximum, NetworkUsage.uploadBuffer.maximum, 1024); - slideAnim.restart(); } target: NetworkUsage } - - NumberAnimation { - id: slideAnim - - duration: Config.dashboard.resourceUpdateInterval - easing.type: Easing.Linear - from: 0 - loops: Animation.Infinite - property: "slideProgress" - target: sparkline - to: 1 - } } // "Collecting data" placeholder diff --git a/Plugins/ZShell/Components/linegraph.cpp b/Plugins/ZShell/Components/linegraph.cpp index 2d0c03f..d772d87 100644 --- a/Plugins/ZShell/Components/linegraph.cpp +++ b/Plugins/ZShell/Components/linegraph.cpp @@ -17,8 +17,7 @@ namespace { constexpr qreal kNaN = std::numeric_limits::quiet_NaN(); bool sameReal(qreal a, qreal b) { - if (std::isnan(a) || std::isnan(b)) - return std::isnan(a) && std::isnan(b); + if (std::isnan(a) || std::isnan(b)) return std::isnan(a) && std::isnan(b); return qFuzzyCompare(a, b); } @@ -34,8 +33,7 @@ qreal parseValue(const QVariant& v) { // Index of the segment whose right endpoint is at or past targetX. int indexAt(const QVector& points, qreal targetX) { for (int i = 0; i < points.size() - 1; ++i) { - if (targetX <= points[i + 1].x()) - return i; + if (targetX <= points[i + 1].x()) return i; } const int last = static_cast(points.size()) - 2; return last > 0 ? last : 0; @@ -44,13 +42,9 @@ int indexAt(const QVector& points, qreal targetX) { // The part of the polyline left/right of targetX, ending or beginning exactly at // the curve point there. Used to carve a gap around the marker dot. QVector cutLeft( - const QVector& points, - qreal targetX, - bool curveSmoothing) { - if (targetX <= points.first().x()) - return {}; - if (targetX >= points.last().x()) - return points; + const QVector& points, qreal targetX, bool curveSmoothing) { + if (targetX <= points.first().x()) return {}; + if (targetX >= points.last().x()) return points; const int j = indexAt(points, targetX); QVector left = points.first(j + 1); @@ -60,13 +54,9 @@ QVector cutLeft( } QVector cutRight( - const QVector& points, - qreal targetX, - bool curveSmoothing) { - if (targetX >= points.last().x()) - return {}; - if (targetX <= points.first().x()) - return points; + const QVector& points, qreal targetX, bool curveSmoothing) { + if (targetX >= points.last().x()) return {}; + if (targetX <= points.first().x()) return points; const int j = indexAt(points, targetX); QVector right = points.mid(j + 1); @@ -76,27 +66,20 @@ QVector cutRight( } QString formatXLabel(LineGraph::XLabelMode mode, int i, bool use12) { - if (mode == LineGraph::XLabelMode::None) - return {}; - if (mode == LineGraph::XLabelMode::Number) - return QString::number(i); + if (mode == LineGraph::XLabelMode::None) return {}; + if (mode == LineGraph::XLabelMode::Number) return QString::number(i); // Hour: an hour of day 0..24, on a 12- or 24-hour clock. if (use12) { const int hr = (i % 12 == 0) ? 12 : (i % 12); - return QString::number(hr) - + (i < 12 ? QStringLiteral(" AM") - : QStringLiteral(" PM")); + return QString::number(hr) + + (i < 12 ? QStringLiteral(" AM") : QStringLiteral(" PM")); } return QString::number(i); } QString formatYLabel( - LineGraph::YLabelMode mode, - qreal v, - int decimals, - const QString& suffix) { - if (mode == LineGraph::YLabelMode::Off) - return {}; + LineGraph::YLabelMode mode, qreal v, int decimals, const QString& suffix) { + if (mode == LineGraph::YLabelMode::Off) return {}; return QString::number(v, 'f', qMax(0, decimals)) + suffix; } } // namespace @@ -194,7 +177,7 @@ void LineGraphRenderer::buildModel(const LineGraph* graph) { const qreal padLeft = graph->m_labelFontSize * 5.0; const qreal padBottom = graph->m_labelFontSize + 14.0; - model.plot = Plot {padLeft, 0.0, W - padLeft, H - padBottom}; + model.plot = Plot{padLeft, 0.0, W - padLeft, H - padBottom}; if (model.plot.w < 2 || model.plot.h < 2) { m_model = model; return; @@ -222,8 +205,7 @@ void LineGraphRenderer::buildModel(const LineGraph* graph) { int n = 0; for (int j = -radius; j <= radius; ++j) { const int u = i + j; - if (u < 0 || u >= raw.size()) - continue; + if (u < 0 || u >= raw.size()) continue; if (!std::isnan(raw[u])) { sum += raw[u]; ++n; @@ -242,8 +224,7 @@ void LineGraphRenderer::buildModel(const LineGraph* graph) { qreal dmin = std::numeric_limits::infinity(); qreal dmax = -std::numeric_limits::infinity(); for (const qreal v : series) { - if (std::isnan(v)) - continue; + if (std::isnan(v)) continue; dmin = std::min(dmin, v); dmax = std::max(dmax, v); } @@ -254,12 +235,12 @@ void LineGraphRenderer::buildModel(const LineGraph* graph) { const int step = graph->m_horizontalLineStep; const int minSteps = graph->m_minHorizontalLines > 1 - ? graph->m_minHorizontalLines - 1 - : 0; + ? graph->m_minHorizontalLines - 1 + : 0; if (step > 0) { if (hiAuto) { - const qreal baseLo = - loAuto ? std::floor(dmin / step) * step : lo; + const qreal baseLo = loAuto ? std::floor(dmin / step) * step + : lo; const int span = std::max( {static_cast(std::ceil((dmax - baseLo) / step)), minSteps, @@ -274,12 +255,9 @@ void LineGraphRenderer::buildModel(const LineGraph* graph) { lo = hi - qreal(span) * step; } } else { - if (loAuto) - lo = dmin; - if (hiAuto) - hi = dmax; - if (hi <= lo) - hi = lo + 1.0; + if (loAuto) lo = dmin; + if (hiAuto) hi = dmax; + if (hi <= lo) hi = lo + 1.0; } } @@ -295,8 +273,7 @@ void LineGraphRenderer::buildModel(const LineGraph* graph) { return model.plot.x + (i / qreal(graph->m_xSteps)) * model.plot.w; }; const auto yAt = [&](qreal v) { - return model.plot.y - + (1.0 - (v - scaleBottom) / range) * model.plot.h; + return model.plot.y + (1.0 - (v - scaleBottom) / range) * model.plot.h; }; const auto devW = [&](qreal w) { @@ -310,53 +287,48 @@ void LineGraphRenderer::buildModel(const LineGraph* graph) { const int vStep = graph->m_verticalLineStep; const int hStep = graph->m_horizontalLineStep; const auto tickVisible = [&](int i) { - if (i == 0 && !graph->m_firstXLine) - return false; - if (i == graph->m_xSteps && !graph->m_lastXLine) - return false; + if (i == 0 && !graph->m_firstXLine) return false; + if (i == graph->m_xSteps && !graph->m_lastXLine) return false; return true; }; if (vStep > 0) { for (int i = 0; i <= graph->m_xSteps; i += vStep) { - if (!tickVisible(i)) - continue; - model.vGrid.append( - internal::snapLine(xAt(i), gridWDev, dpr)); + if (!tickVisible(i)) continue; + model.vGrid.append(internal::snapLine(xAt(i), gridWDev, dpr)); } } - const qreal vStart = lo - - (graph->m_yPaddingBottom > 0.0 - ? std::floor(graph->m_yPaddingBottom / hStep) - * hStep - : 0.0); + const qreal vStart = + lo - (graph->m_yPaddingBottom > 0.0 + ? std::floor(graph->m_yPaddingBottom / hStep) * hStep + : 0.0); if (hStep > 0) { for (qreal v = vStart; v <= hi + 0.001; v += hStep) - model.hGrid.append( - internal::snapLine(yAt(v), gridWDev, dpr)); + model.hGrid.append(internal::snapLine(yAt(v), gridWDev, dpr)); } if (vStep > 0 && graph->m_xLabelMode != LineGraph::XLabelMode::None) { for (int i = 0; i <= graph->m_xSteps; i += vStep) { - if (!tickVisible(i)) - continue; + if (!tickVisible(i)) continue; const QString text = formatXLabel( graph->m_xLabelMode, i, graph->m_useTwelveHourClock); if (!text.isEmpty()) - model.xLabels.append(Label { - text, xAt(i) + 4.0, - model.plot.y + model.plot.h + 8.0}); + model.xLabels.append( + Label{ + text, xAt(i) + 4.0, model.plot.y + model.plot.h + 8.0}); } } if (hStep > 0 && graph->m_yLabelMode != LineGraph::YLabelMode::Off) { for (qreal v = vStart; v <= hi + 0.001; v += hStep) { const QString text = formatYLabel( - graph->m_yLabelMode, v, graph->m_yLabelDecimals, + graph->m_yLabelMode, + v, + graph->m_yLabelDecimals, graph->m_yLabelSuffix); if (!text.isEmpty()) - model.yLabels.append(Label {text, 2.0, yAt(v) - 2.0}); + model.yLabels.append(Label{text, 2.0, yAt(v) - 2.0}); } } @@ -378,36 +350,33 @@ void LineGraphRenderer::buildModel(const LineGraph* graph) { } current.append(QPointF(snapX(xAt(i)), yAt(series[i]))); } - if (!current.isEmpty()) - runs.append(current); + if (!current.isEmpty()) runs.append(current); const bool curve = graph->m_curveSmoothing; - const bool hasMarker = - graph->m_markerX >= 0.0 && graph->m_markerX <= graph->m_xSteps; + const bool hasMarker = graph->m_markerX >= 0.0 && + graph->m_markerX <= graph->m_xSteps; qreal markerPx = 0.0; qreal gapPx = 0.0; if (hasMarker) { markerPx = xAt(graph->m_markerX); qreal slope = 0.0; for (const auto& run : runs) { - if (run.size() > 2 && run.first().x() <= markerPx - && markerPx <= run.last().x() - && markerPx - 1.0 >= run.first().x() - && markerPx + 1.0 <= run.last().x()) { - slope = (internal::pointAtX(run, markerPx + 1.0, curve).y() - - internal::pointAtX(run, markerPx - 1.0, curve) - .y()) - / 2.0; + if (run.size() > 2 && run.first().x() <= markerPx && + markerPx <= run.last().x() && + markerPx - 1.0 >= run.first().x() && + markerPx + 1.0 <= run.last().x()) { + slope = (internal::pointAtX(run, markerPx + 1.0, curve).y() - + internal::pointAtX(run, markerPx - 1.0, curve).y()) / + 2.0; break; } } - gapPx = (graph->m_markerDotRadius + graph->m_markerGap) - / std::sqrt(1.0 + slope * slope); + gapPx = (graph->m_markerDotRadius + graph->m_markerGap) / + std::sqrt(1.0 + slope * slope); } for (const auto& run : runs) { - if (run.size() < 2) - continue; + if (run.size() < 2) continue; Segment seg; seg.fill = internal::curveVertices(run, dpr, curve); @@ -415,16 +384,13 @@ void LineGraphRenderer::buildModel(const LineGraph* graph) { seg.fill.append(QPointF(run.first().x(), model.plotBottom)); if (hasMarker) { - const QVector left = - cutLeft(run, markerPx - gapPx, curve); + const QVector left = cutLeft(run, markerPx - gapPx, curve); const QVector right = cutRight(run, markerPx + gapPx, curve); if (left.size() >= 2) - seg.strokes.append( - internal::curveVertices(left, dpr, curve)); + seg.strokes.append(internal::curveVertices(left, dpr, curve)); if (right.size() >= 2) - seg.strokes.append( - internal::curveVertices(right, dpr, curve)); + seg.strokes.append(internal::curveVertices(right, dpr, curve)); } else { seg.strokes.append(internal::curveVertices(run, dpr, curve)); } @@ -433,8 +399,7 @@ void LineGraphRenderer::buildModel(const LineGraph* graph) { if (hasMarker) { model.marker.hasLine = true; - model.marker.lineX = - internal::snapLine(markerPx, gridWDev, dpr); + model.marker.lineX = internal::snapLine(markerPx, gridWDev, dpr); // Dashes are drawn as solid segments: QCanvasPainter strokes are always // solid, so a dashed line is a run of short strokes, as LineGraph.qml did. @@ -442,19 +407,15 @@ void LineGraphRenderer::buildModel(const LineGraph* graph) { bool on = true; while (dashY < H) { const qreal dashEnd = std::min(dashY + 4.0, H); - if (on) - model.marker.dash.append( - {snapX(dashY), snapX(dashEnd)}); + if (on) model.marker.dash.append({snapX(dashY), snapX(dashEnd)}); dashY = dashEnd; on = !on; } const int i0 = static_cast(std::floor(graph->m_markerX)); - const qreal v0 = (i0 >= 0 && i0 < series.size()) ? series[i0] - : kNaN; - const qreal v1 = (i0 + 1 >= 0 && i0 + 1 < series.size()) - ? series[i0 + 1] - : kNaN; + const qreal v0 = (i0 >= 0 && i0 < series.size()) ? series[i0] : kNaN; + const qreal v1 = + (i0 + 1 >= 0 && i0 + 1 < series.size()) ? series[i0 + 1] : kNaN; qreal y = kNaN; if (!std::isnan(v0) && !std::isnan(v1)) y = yAt(v0 + (v1 - v0) * (graph->m_markerX - qreal(i0))); @@ -475,8 +436,7 @@ void LineGraphRenderer::buildModel(const LineGraph* graph) { void LineGraphRenderer::paint(QCanvasPainter* painter) { painter->clearRect(0, 0, width(), height()); - if (!m_model.valid) - return; + if (!m_model.valid) return; const Model& m = m_model; @@ -553,8 +513,7 @@ void LineGraphRenderer::paint(QCanvasPainter* painter) { if (m.marker.hasDot) { painter->setFillStyle(m_markerColor); painter->beginPath(); - painter->circle( - m.marker.dot.x(), m.marker.dot.y(), m.marker.dotRadius); + painter->circle(m.marker.dot.x(), m.marker.dot.y(), m.marker.dotRadius); painter->fill(); } } diff --git a/Plugins/ZShell/Components/linegraph.hpp b/Plugins/ZShell/Components/linegraph.hpp index 6a7b9d6..3ec51c7 100644 --- a/Plugins/ZShell/Components/linegraph.hpp +++ b/Plugins/ZShell/Components/linegraph.hpp @@ -46,8 +46,8 @@ class LineGraph : public QCanvasPainterItem { bool firstXLine READ firstXLine WRITE setFirstXLine NOTIFY firstXLineChanged FINAL) Q_PROPERTY( - bool lastXLine READ lastXLine WRITE setLastXLine NOTIFY - lastXLineChanged FINAL) + bool lastXLine READ lastXLine WRITE setLastXLine NOTIFY lastXLineChanged + FINAL) Q_PROPERTY( int minHorizontalLines READ minHorizontalLines WRITE setMinHorizontalLines NOTIFY minHorizontalLinesChanged FINAL) diff --git a/Plugins/ZShell/Components/sparklinecontent.cpp b/Plugins/ZShell/Components/sparklinecontent.cpp index a063942..257b06b 100644 --- a/Plugins/ZShell/Components/sparklinecontent.cpp +++ b/Plugins/ZShell/Components/sparklinecontent.cpp @@ -16,43 +16,21 @@ using internal::fromDev; using internal::snapLine; using internal::toDev; -// Device-pixel snapping, bezier sampling and Catmull-Rom controls come from -// internal::linegeometry, shared with LineGraph so both graphs align and smooth -// their lines identically. namespace { -// A QQuickPaintedItem only lands on the device grid when its logical size is a -// whole number *and* that whole number times dpr is a whole number as well. -// Qt derives the painter node from qRound(size) (qquickpainteditem.cpp), makes -// the texture from that rounded size times dpr, scales the painter by -// texture/size rather than by dpr, and draws the texture into a quad of the -// rounded size (qsgdefaultpainternode.cpp). Any fraction therefore moves the -// painter scale off dpr - a height of 75.3333 at dpr 1.5 becomes a node of 75 -// and a texture of qRound(112.5) = 113, a scale of 1.5067, so every y snapped -// to the dpr 1.5 grid falls between texture rows and the whole texture is then -// resampled to a 112.5 pixel tall quad. That is the fuzz. 74 -> 111 and -// 76 -> 114 are both exact. -// -// `grow` picks the direction: shrink to stay inside the slot, grow to stay -// outside it. The overhang on the right has to grow, or the texture's own edge -// moves inside the parent's clip and slides left and right with the scroll. qreal snapPaintedExtent(qreal logical, qreal dpr, bool grow) { qreal whole = grow ? std::ceil(logical - 1e-6) : std::floor(logical + 1e-6); for (int guard = 0; guard < 16 && whole >= 1.0; ++guard) { const qreal device = whole * dpr; - if (std::abs(device - std::round(device)) < 1e-6) - return whole; + if (std::abs(device - std::round(device)) < 1e-6) return whole; whole += grow ? 1.0 : -1.0; } - // No whole extent within the guard window (an exotic dpr): a whole number is - // still closer than a fractional one, which Qt rounds away anyway. return std::max(1.0, grow ? whole : std::floor(logical)); } } // namespace SparklineContent::SparklineContent(QQuickItem* parent) : QQuickPaintedItem(parent) { - m_sampleClock.start(); setAntialiasing(true); } @@ -75,13 +53,6 @@ qreal SparklineContent::stepX() const { 2, (m_line1 && m_line1->count() >= 2) ? m_line1->count() : (m_line2 ? m_line2->count() : 2)); - // Exactly the slot divided by the history, in logical pixels. Rounding this - // to a whole device pixel was tempting - it puts every sample on a pixel - // column - but the rounding error accumulates over the whole window: history - // - 1 steps then no longer span the slot, so the oldest value either leaves - // the clip before its tick (rounding up) or sits at the edge until the buffer - // evicts it, popping out at the tick (rounding down). crispness does not need - // it: internal::curveVertices puts a vertex on every device column anyway. return m_sliding ? m_viewportW / static_cast(m_historyLength - 1) : m_viewportW / static_cast(len - 1); } @@ -99,23 +70,23 @@ void SparklineContent::setViewportSize( void SparklineContent::setSlideInterval(qreal intervalMs) { if (qFuzzyCompare(m_slideIntervalMs, intervalMs)) return; m_slideIntervalMs = intervalMs; - m_sampleClock.restart(); invalidate(); } qreal SparklineContent::slidePhase(qreal progress) const { if (m_slideIntervalMs <= 0.0) return qBound(0.0, progress, 1.0); - // The sample clock, not the animation's timeline. The NumberAnimation runs on - // the render-loop clock while the sample arrives from a QTimer of the same - // duration, so it reaches 1 on the last frame before that timer fires and then - // stops. For that frame the newest value is parked exactly on the parent's - // clip edge - it shows up at the clipped edge instead of sliding in from - // beyond it, and the following frame travels nothing. Holding is capped at one - // step, so a stalled producer cannot scroll the curve into empty space. + + if (!m_sampleClock.isValid()) return 1.0; + const qreal elapsed = static_cast(m_sampleClock.elapsed()); return qBound(0.0, elapsed / m_slideIntervalMs, 1.0); } +bool SparklineContent::slideActive() const { + return m_slideIntervalMs > 0.0 && m_sampleClock.isValid() && + static_cast(m_sampleClock.elapsed()) < m_slideIntervalMs; +} + void SparklineContent::setSlideProgress(qreal progress) { setX(-stepX() * slidePhase(progress)); } @@ -139,11 +110,6 @@ void SparklineContent::setLine2(internal::CircularBuffer* buffer) { void SparklineContent::onSampleArrived() { m_sampleClock.restart(); invalidate(); - // The offset reset and the buffer shifting its values by one step are a - // single change: applied here, both are pending before the next sync, so they - // cannot land in different frames. Left to the animation to write, a frame - // can paint the reindexed values with the previous cycle's full offset still - // in place, which moves the whole graph one extra step left for that frame. setSlideProgress(0.0); } @@ -223,16 +189,7 @@ void SparklineContent::invalidate() { void SparklineContent::applyGeometry() { const qreal dpr = m_dpr > 0.0 ? m_dpr : 1.0; - // The newest value is drawn one step past the slot's right edge, so the child - // has to overhang the slot and the overhang is offset left as it slides. Two - // steps, not one: the newest vertex sits at one step past the edge and its - // stroke cap extends beyond that, so a one-step overhang puts the texture's - // own right edge exactly on the clip boundary at the end of a cycle, where the - // cut-off ink becomes visible for a frame. const qreal pad = m_sliding ? 2.0 * stepX() : 0.0; - // Qt rounds the painted size anyway, so choose a whole value that keeps - // device alignment. Width grows, to keep that overhang outside the clip; - // height shrinks, to stay inside the slot. const qreal paintedW = snapPaintedExtent(m_viewportW + pad, dpr, true); m_paintedH = snapPaintedExtent(m_viewportH, dpr, false); setSize(QSizeF(paintedW, m_paintedH)); @@ -241,13 +198,10 @@ void SparklineContent::applyGeometry() { SparklineContent::LineGeometry SparklineContent::buildGeometry( internal::CircularBuffer* buffer) const { LineGeometry geom; - // Every input has to be a usable number: a NaN from QML (an animated maxValue - // fed by an empty buffer, for instance) would otherwise reach llround and the - // path stroker as garbage, which draws a shape that ignores the curve. if (!buffer || buffer->count() < 2 || !std::isfinite(m_maxValue) || m_maxValue <= 0.0 || !std::isfinite(m_viewportW) || - m_viewportW <= 0.0 || !std::isfinite(m_viewportH) || m_viewportH <= 0.0 || - m_paintedH < 1.0) + m_viewportW <= 0.0 || !std::isfinite(m_viewportH) || + m_viewportH <= 0.0 || m_paintedH < 1.0) return geom; if (m_historyLength < 2) return geom; @@ -259,8 +213,6 @@ SparklineContent::LineGeometry SparklineContent::buildGeometry( const qint64 lineDev = std::isfinite(m_lineWidth) ? (std::max)(qint64(1), toDev(m_lineWidth, dpr)) : qint64(1); - // The painted height, not the slot's: it is the number of texture rows that - // actually exist, so the plot and the fill bottom match them exactly. const qint64 heightDev = toDev(m_paintedH, dpr); qreal startX = 0.0; @@ -269,9 +221,6 @@ SparklineContent::LineGeometry SparklineContent::buildGeometry( else startX = 0.0; - // Plot bounds in device pixels: half a stroke plus the curve overshoot at - // the top, a half stroke and one pixel of antialiasing feather at the - // bottom, so neither edge of the stroke is cut by the item's own rect. const qint64 curvePadDev = toDev(m_curveSmoothing ? 3.0 : 0.0, dpr); const qint64 plotTopDev = lineDev / 2 + curvePadDev; const qint64 plotBottomDev = @@ -285,26 +234,18 @@ SparklineContent::LineGeometry SparklineContent::buildGeometry( for (int i = 0; i < len; ++i) { const qreal raw = buffer->smoothedAt(i); - // A non-finite sample poisons the whole path: llround of it is undefined, - // and the stroke and fill then ignore everything after that vertex. Treat - // it as no traffic instead. const qreal value = std::isfinite(raw) ? qBound(0.0, raw, m_maxValue) : 0.0; - // The y of every sample goes on the grid the stroke width needs; x stays - // exact so the window's span is exactly the slot's width. const qreal y = fromDev( plotTopDev + static_cast(std::llround( (1.0 - value / m_maxValue) * static_cast(plotHDev))), dpr); - points.append(QPointF(startX + qreal(i) * step, snapLine(y, lineDev, dpr))); + points.append( + QPointF(startX + qreal(i) * step, snapLine(y, lineDev, dpr))); } - // One vertex per device-pixel column, shared with LineGraphItem through - // internal::curveVertices: a segment spanning several columns would otherwise - // interpolate its edge across all of them. y stays continuous, so a slope is - // interpolated rather than stepped one pixel at a time. const QVector verts = internal::curveVertices(points, dpr, m_curveSmoothing); diff --git a/Plugins/ZShell/Components/sparklinecontent.hpp b/Plugins/ZShell/Components/sparklinecontent.hpp index 794a6a1..bd3db4a 100644 --- a/Plugins/ZShell/Components/sparklinecontent.hpp +++ b/Plugins/ZShell/Components/sparklinecontent.hpp @@ -31,20 +31,15 @@ class SparklineContent : public QQuickPaintedItem { void setViewportSize(qreal viewportWidth, qreal viewportHeight); - // How often a new sample arrives, in milliseconds. With this set the slide - // phase is read from the sample clock rather than from `progress`, which - // makes the wrap exact; without it the phase is the value QML supplies. void setSlideInterval(qreal intervalMs); - // Offsets the item for the slide. Kept here rather than in SparklineItem - // because the quantization needs both stepX() and the window's dpr. void setSlideProgress(qreal progress); [[nodiscard]] qreal stepX() const; + [[nodiscard]] bool slideActive() const; + protected: - // The stroke width, sample spacing and plot bounds are quantized in device - // pixels, so they have to be built with the ratio the window actually has. void itemChange(ItemChange change, const ItemChangeData& value) override; private: @@ -78,13 +73,9 @@ class SparklineContent : public QQuickPaintedItem { qreal m_viewportW = 0.0; qreal m_viewportH = 0.0; - // The height actually painted, snapped so the texture maps 1:1 onto device - // rows. The plot bounds are built from this, not from the slot's height. qreal m_paintedH = 0.0; qreal m_dpr = 1.0; qreal m_slideIntervalMs = 0.0; - // Restarted whenever a sample arrives, so the slide phase follows the sample - // cadence instead of the animation's timeline. QElapsedTimer m_sampleClock; LineGeometry m_geom1; diff --git a/Plugins/ZShell/Components/sparklineitem.cpp b/Plugins/ZShell/Components/sparklineitem.cpp index 3c010a5..123c96f 100644 --- a/Plugins/ZShell/Components/sparklineitem.cpp +++ b/Plugins/ZShell/Components/sparklineitem.cpp @@ -2,6 +2,8 @@ #include "sparklinecontent.hpp" +#include + namespace ZShell::components { SparklineItem::SparklineItem(QQuickItem* parent) : QQuickItem(parent) { @@ -18,6 +20,29 @@ void SparklineItem::geometryChange( updateContentOffset(); } +void SparklineItem::itemChange(ItemChange change, const ItemChangeData& value) { + QQuickItem::itemChange(change, value); + if (change != ItemSceneChange) return; + + if (m_syncConnection) QObject::disconnect(m_syncConnection); + + if (value.window) { + m_syncConnection = connect( + value.window, + &QQuickWindow::beforeFrameBegin, + this, + &SparklineItem::syncSlidePhase); + } +} + +void SparklineItem::syncSlidePhase() { + if (m_slideInterval <= 0.0) return; + + updateContentOffset(); + + if (isVisible() && m_content->slideActive() && window()) window()->update(); +} + void SparklineItem::updateContentOffset() { m_content->setSlideProgress(m_slideProgress); } @@ -111,9 +136,6 @@ qreal SparklineItem::slideProgress() const { } void SparklineItem::setSlideProgress(qreal progress) { - // Always forwarded: with a slide interval set the phase comes from the sample - // clock, so the offset can move even when the animation value repeats. An - // unchanged position is a no-op in QQuickItem. m_slideProgress = progress; updateContentOffset(); emit slideProgressChanged(); diff --git a/Plugins/ZShell/Components/sparklineitem.hpp b/Plugins/ZShell/Components/sparklineitem.hpp index b2db6fe..ff802ed 100644 --- a/Plugins/ZShell/Components/sparklineitem.hpp +++ b/Plugins/ZShell/Components/sparklineitem.hpp @@ -121,11 +121,15 @@ class SparklineItem : public QQuickItem { void geometryChange( const QRectF& newGeometry, const QRectF& oldGeometry) override; + void itemChange(ItemChange change, const ItemChangeData& value) override; + private: void updateContentOffset(); + void syncSlidePhase(); void applySmoothingRadius(internal::CircularBuffer* buffer) const; SparklineContent* m_content = nullptr; + QMetaObject::Connection m_syncConnection; internal::CircularBuffer* m_line1 = nullptr; internal::CircularBuffer* m_line2 = nullptr; QColor m_line1Color; diff --git a/Plugins/ZShell/Components/wavylinerenderer.cpp b/Plugins/ZShell/Components/wavylinerenderer.cpp index 917d075..f204d9a 100644 --- a/Plugins/ZShell/Components/wavylinerenderer.cpp +++ b/Plugins/ZShell/Components/wavylinerenderer.cpp @@ -41,8 +41,8 @@ qreal fromDev(qint64 device, qreal dpr) { // points depending on floating point noise. qreal snapCenterDev(qint64 sizeDev, qint64 widthDev) { const double halfParity = (widthDev % 2) == 0 ? 0.0 : 0.5; - return std::floor(static_cast(sizeDev) / 2.0 - halfParity) - + halfParity; + return std::floor(static_cast(sizeDev) / 2.0 - halfParity) + + halfParity; } qint64 snapAmplitude(qint64 widthDev, qreal multiplier) { diff --git a/Plugins/ZShell/Internal/linegeometry.cpp b/Plugins/ZShell/Internal/linegeometry.cpp index b063ee2..5dcd862 100644 --- a/Plugins/ZShell/Internal/linegeometry.cpp +++ b/Plugins/ZShell/Internal/linegeometry.cpp @@ -8,8 +8,8 @@ namespace ZShell::internal { qreal snapLine(qreal logical, qint64 widthDev, qreal dpr) { const qreal half = qreal(widthDev) / 2.0; - return (static_cast(std::llround(logical * dpr - half)) + half) - / dpr; + return (static_cast(std::llround(logical * dpr - half)) + half) / + dpr; } void segmentControls( @@ -51,8 +51,8 @@ qreal cubicY( for (int it = 0; it < 16; ++it) { const qreal t = (lo + hi) / 2.0; const qreal u = 1.0 - t; - const qreal x = u * u * u * p1.x() + 3 * u * u * t * c1.x() - + 3 * u * t * t * c2.x() + t * t * t * p2.x(); + const qreal x = u * u * u * p1.x() + 3 * u * u * t * c1.x() + + 3 * u * t * t * c2.x() + t * t * t * p2.x(); if (x < targetX) lo = t; else @@ -61,8 +61,8 @@ qreal cubicY( const qreal t = (lo + hi) / 2.0; const qreal u = 1.0 - t; - return u * u * u * p1.y() + 3 * u * u * t * c1.y() - + 3 * u * t * t * c2.y() + t * t * t * p2.y(); + return u * u * u * p1.y() + 3 * u * u * t * c1.y() + + 3 * u * t * t * c2.y() + t * t * t * p2.y(); } namespace { @@ -72,17 +72,14 @@ namespace { int segmentIndexAt(const QVector& points, qreal targetX) { const int last = points.size() - 2; for (int i = 0; i < points.size() - 1; ++i) { - if (targetX <= points[i + 1].x()) - return i; + if (targetX <= points[i + 1].x()) return i; } return qMax(0, last); } } // namespace QPointF pointAtX( - const QVector& points, - qreal targetX, - bool curveSmoothing) { + const QVector& points, qreal targetX, bool curveSmoothing) { const int j = segmentIndexAt(points, targetX); QPointF c1; QPointF c2; @@ -93,9 +90,8 @@ QPointF pointAtX( for (int it = 0; it < 24; ++it) { const qreal t = (lo + hi) / 2.0; const qreal u = 1.0 - t; - const qreal x = u * u * u * points[j].x() - + 3 * u * u * t * c1.x() + 3 * u * t * t * c2.x() - + t * t * t * points[j + 1].x(); + const qreal x = u * u * u * points[j].x() + 3 * u * u * t * c1.x() + + 3 * u * t * t * c2.x() + t * t * t * points[j + 1].x(); if (x < targetX) lo = t; else @@ -104,18 +100,15 @@ QPointF pointAtX( const qreal t = (lo + hi) / 2.0; const qreal u = 1.0 - t; - const qreal y = u * u * u * points[j].y() + 3 * u * u * t * c1.y() - + 3 * u * t * t * c2.y() + t * t * t * points[j + 1].y(); + const qreal y = u * u * u * points[j].y() + 3 * u * u * t * c1.y() + + 3 * u * t * t * c2.y() + t * t * t * points[j + 1].y(); return QPointF(targetX, y); } QVector curveVertices( - const QVector& points, - qreal dpr, - bool curveSmoothing) { + const QVector& points, qreal dpr, bool curveSmoothing) { QVector out; - if (points.size() < 2) - return out; + if (points.size() < 2) return out; out.reserve(points.size()); out.append(points.first()); diff --git a/Plugins/ZShell/Internal/linegeometry.hpp b/Plugins/ZShell/Internal/linegeometry.hpp index 41c1839..0e949c9 100644 --- a/Plugins/ZShell/Internal/linegeometry.hpp +++ b/Plugins/ZShell/Internal/linegeometry.hpp @@ -49,16 +49,12 @@ qreal cubicY( // The curve point at targetX on the polyline, interpolating across the segment // that contains it. Assumes points ascend in x. QPointF pointAtX( - const QVector& points, - qreal targetX, - bool curveSmoothing); + const QVector& points, qreal targetX, bool curveSmoothing); // A dense polyline through points, adding one vertex per device-pixel column // inside each segment so an edge never interpolates across several columns. The // result starts at points.first() and ends at points.last(). points must ascend in // x and hold at least two entries. Backends consume it as a moveTo/lineTo chain. QVector curveVertices( - const QVector& points, - qreal dpr, - bool curveSmoothing); + const QVector& points, qreal dpr, bool curveSmoothing); } // namespace ZShell::internal diff --git a/Plugins/ZShell/zutils.cpp b/Plugins/ZShell/zutils.cpp index fabcce0..0e22f51 100644 --- a/Plugins/ZShell/zutils.cpp +++ b/Plugins/ZShell/zutils.cpp @@ -383,7 +383,8 @@ Centering detectCentering(QQuickItem* item) { } if (ad->usedAnchors & QQuickAnchors::VCenterAnchor) { c.vertical = true; - c.source += (c.source.isEmpty() ? "" : " ") + QStringLiteral("vCenter"); + c.source += + (c.source.isEmpty() ? "" : " ") + QStringLiteral("vCenter"); } } @@ -410,9 +411,9 @@ Centering detectCentering(QQuickItem* item) { // Parity (mod 2) of an item's whole-device-pixel size; no item = even. int devParity(const QQuickItem* it, qreal dpr, bool horizontal) { - if (!it) - return 0; - const qint64 dev = std::llround((horizontal ? it->width() : it->height()) * dpr); + if (!it) return 0; + const qint64 dev = + std::llround((horizontal ? it->width() : it->height()) * dpr); return static_cast(dev % 2); } @@ -423,9 +424,9 @@ int devParity(const QQuickItem* it, qreal dpr, bool horizontal) { // since edges = parentLeft + (parentSize - size) / 2. qreal snapToParity(qreal value, qreal dpr, int parity, int mode) { const double exact = value * dpr; - qint64 n = mode > 0 ? std::llround(std::ceil(exact - 1e-6)) - : mode < 0 ? std::llround(std::floor(exact + 1e-6)) - : std::llround(exact); + qint64 n = mode > 0 ? std::llround(std::ceil(exact - 1e-6)) + : mode < 0 ? std::llround(std::floor(exact + 1e-6)) + : std::llround(exact); if (static_cast(n % 2) != parity) { if (mode > 0) { ++n; @@ -436,8 +437,7 @@ qreal snapToParity(qreal value, qreal dpr, int parity, int mode) { n = (exact >= static_cast(n)) ? n + 1 : n - 1; } } - if (n < 0) - n = 0; + if (n < 0) n = 0; return static_cast(n) / dpr; } @@ -452,16 +452,17 @@ qint64 roundAway(double value) { // divides exactly), so with the parent's half at a whole device offset the // child's device size must be even; if the parent's half sits on a half device // pixel, the child's device size must be odd instead. -qreal snapCenteredSize(qreal value, qreal dpr, qint64 parentHalfRounded, int mode) { +qreal snapCenteredSize( + qreal value, qreal dpr, qint64 parentHalfRounded, int mode) { const double exact = value * dpr; const double anchorDev = double(parentHalfRounded) * dpr; - const bool anchorWhole = - std::fabs(anchorDev - std::round(anchorDev)) < 1e-6; + const bool anchorWhole = std::fabs(anchorDev - std::round(anchorDev)) < + 1e-6; const qint64 parity = anchorWhole ? 0 : 1; - qint64 base = mode > 0 ? std::llround(std::ceil(exact - 1e-6)) - : mode < 0 ? std::llround(std::floor(exact + 1e-6)) - : std::llround(exact); + qint64 base = mode > 0 ? std::llround(std::ceil(exact - 1e-6)) + : mode < 0 ? std::llround(std::floor(exact + 1e-6)) + : std::llround(exact); for (qint64 step = 0; step < 8; ++step) { if (base + step >= 0 && (base + step) % 2 == parity) return qreal(base + step) / dpr; @@ -483,7 +484,8 @@ qreal snapCenteredAxis( int mode) { QQuickItem* parent = item->parentItem(); if (!centering.alignWhenCentered && parent) { - const qreal parentSize = horizontal ? parent->width() : parent->height(); + const qreal parentSize = horizontal ? parent->width() + : parent->height(); return snapCenteredSize(value, dpr, roundAway(parentSize / 2.0), mode); } return snapToParity(value, dpr, devParity(parent, dpr, horizontal), mode); @@ -495,10 +497,8 @@ qreal ZUtils::snap(qreal value, QQuickItem* item) { const qreal dpr = dprFor(item); if (item) { const Centering c = detectCentering(item); - if (c.horizontal) - return snapCenteredAxis(value, item, dpr, true, c, 0); - if (c.vertical) - return snapCenteredAxis(value, item, dpr, false, c, 0); + if (c.horizontal) return snapCenteredAxis(value, item, dpr, true, c, 0); + if (c.vertical) return snapCenteredAxis(value, item, dpr, false, c, 0); } return std::round(value * dpr) / dpr; } @@ -507,10 +507,8 @@ qreal ZUtils::snapCeil(qreal value, QQuickItem* item) { const qreal dpr = dprFor(item); if (item) { const Centering c = detectCentering(item); - if (c.horizontal) - return snapCenteredAxis(value, item, dpr, true, c, 1); - if (c.vertical) - return snapCenteredAxis(value, item, dpr, false, c, 1); + if (c.horizontal) return snapCenteredAxis(value, item, dpr, true, c, 1); + if (c.vertical) return snapCenteredAxis(value, item, dpr, false, c, 1); } return std::ceil(value * dpr - 1e-6) / dpr; } @@ -521,8 +519,7 @@ qreal ZUtils::snapFloor(qreal value, QQuickItem* item) { const Centering c = detectCentering(item); if (c.horizontal) return snapCenteredAxis(value, item, dpr, true, c, -1); - if (c.vertical) - return snapCenteredAxis(value, item, dpr, false, c, -1); + if (c.vertical) return snapCenteredAxis(value, item, dpr, false, c, -1); } return std::floor(value * dpr + 1e-6) / dpr; } @@ -568,15 +565,12 @@ bool ZUtils::logPlacement(QQuickItem* item, const QString& tag) { // instead of dividing, so the edges are what matter. const auto centeredAxisOk = [&](double lo, double hi) { const double size = hi - lo; - if (isOff(size)) - return false; - if (!c.alignWhenCentered) - return !isOff(lo) && !isOff(hi); + if (isOff(size)) return false; + if (!c.alignWhenCentered) return !isOff(lo) && !isOff(hi); const double center = (lo + hi) / 2.0; const double parity = (std::llround(size) % 2 == 0) ? 0.0 : 0.5; double err = std::fmod(center - parity, 1.0); - if (err < 0) - err += 1.0; + if (err < 0) err += 1.0; return std::min(err, 1.0 - err) <= tol; }; @@ -605,9 +599,7 @@ bool ZUtils::logPlacement(QQuickItem* item, const QString& tag) { << "\n device px: left=" << fmt(l) << " top=" << fmt(t) << " right=" << fmt(rt) << " bottom=" << fmt(b) << (isText ? " (right/bottom ignored: Text)" : "") - << (!centerSrc.isEmpty() - ? " (centered: " + centerSrc + ")" - : QString()) + << (!centerSrc.isEmpty() ? " (centered: " + centerSrc + ")" : QString()) << (centeredH || centeredV ? QString("\n center: x=%1 y=%2 size=%3 x %4") .arg(QString::number((l + rt) / 2, 'f', 2)) @@ -642,8 +634,8 @@ bool ZUtils::logCurvePlacement( constexpr double tol = 1e-3; const double dpr = dprFor(item); - const int devW = static_cast( - std::max(1, std::llround(lineWidth * dpr))); + const int devW = + static_cast(std::max(1, std::llround(lineWidth * dpr))); const double half = devW / 2.0; qsizetype offCount = 0; @@ -690,23 +682,25 @@ bool ZUtils::logCurvePlacement( const qsizetype flat = total - sloped; const bool aligned = offCount == 0; - qInfo().noquote() - << (aligned ? "[CURVE ALIGNED] " : "[CURVE UNALIGNED]") - << (tag.isEmpty() ? QString::fromUtf8(item->metaObject()->className()) - : tag) - << "\n samples: " << total << " points (" << flat << " flat, " - << sloped << " sloped ignored), dpr=" << dpr - << ", stroke width=" << lineWidth << " logical = " << devW - << " device px" - << "\n edges: " << offCount << " of " << flat * 2 - << " flat stroke edges off the device grid"; + qInfo().noquote() << (aligned ? "[CURVE ALIGNED] " : "[CURVE UNALIGNED]") + << (tag.isEmpty() ? QString::fromUtf8( + item->metaObject()->className()) + : tag) + << "\n samples: " << total << " points (" << flat + << " flat, " << sloped << " sloped ignored), dpr=" << dpr + << ", stroke width=" << lineWidth << " logical = " << devW + << " device px" + << "\n edges: " << offCount << " of " << flat * 2 + << " flat stroke edges off the device grid"; if (!aligned) { - qInfo().noquote() - << " worst: deviation=" << QString::number(worst, 'f', 3) - << " device px at logical x=" << QString::number(worstX, 'f', 2) - << " y=" << QString::number(worstY, 'f', 2) - << " (" << (worstSide < 0 ? "top" : "bottom") << " edge device=" - << QString::number(worstEdge, 'f', 3) << ")"; + qInfo().noquote() << " worst: deviation=" + << QString::number(worst, 'f', 3) + << " device px at logical x=" + << QString::number(worstX, 'f', 2) + << " y=" << QString::number(worstY, 'f', 2) << " (" + << (worstSide < 0 ? "top" : "bottom") + << " edge device=" + << QString::number(worstEdge, 'f', 3) << ")"; } return aligned; diff --git a/Plugins/ZShell/zutils.hpp b/Plugins/ZShell/zutils.hpp index b9f8aa8..a52f1da 100644 --- a/Plugins/ZShell/zutils.hpp +++ b/Plugins/ZShell/zutils.hpp @@ -64,7 +64,8 @@ class ZUtils : public QObject { // (exact halves when `alignWhenCentered` is true, the rounded half of the // anchor target when it is false). Q_INVOKABLE static qreal snap(qreal value, QQuickItem* item); - Q_INVOKABLE static qreal snapCeil(qreal value, QQuickItem* item); // text-derived sizes + Q_INVOKABLE static qreal snapCeil( + qreal value, QQuickItem* item); // text-derived sizes Q_INVOKABLE static qreal snapFloor(qreal value, QQuickItem* item); // Logs where an item sits in device pixels and whether it is aligned. // Returns true if aligned. `tag` is an optional label for the log line. @@ -72,8 +73,7 @@ class ZUtils : public QObject { // (whole device size, plus the offset the anchor produces), which is // detected from the item's anchors and Layout alignment. Q_INVOKABLE static bool logPlacement( - QQuickItem* item, - const QString& tag = QString()); + QQuickItem* item, const QString& tag = QString()); // Walks a stroked curve path (flat x,y pairs in the item's logical coords, // every point along the curve, not just its defined points) and checks