feat: better notification storage and image caching + blobs
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 11s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 12s
JS/TS / lint (pull_request) Successful in 10s
Python / static (pull_request) Failing after 27s
Rust / build (pull_request) Successful in 54s
Python / verify (pull_request) Failing after 1m26s
Rust / fmt (pull_request) Successful in 46s
Rust / clippy (pull_request) Successful in 1m6s

This commit is contained in:
2026-11-02 19:00:43 +01:00
parent 789305286a
commit 11e7aac195
56 changed files with 5003 additions and 2168 deletions
+56 -6
View File
@@ -16,6 +16,22 @@ constexpr float k_settleVel = 0.05f;
constexpr float k_identityEps = 0.0005f;
constexpr float k_stillPx = 0.5f;
constexpr int k_settleStillSamples = 3;
// How far the deform matrix is from identity, summed over its three distinct
// entries. The matrix is applied as a panel's transform, so it has to reach
// *exactly* identity rather than merely close to it: a residue of 1e-4 scales
// a 400 logical px panel by 0.04 px, which moves a device-aligned child onto a
// fraction of a device pixel, and anything that centers itself inside the
// panel (`anchors` with `alignWhenCentered`, `Layout.HCenter`/`VCenter`) then
// antialiases. The residue also differs per entry, so different children land
// on different sub-pixel offsets - which reads as random blur.
float identityResidue(float dm00, float dm01, float dm11) {
return std::abs(dm00 - 1.0f) + std::abs(dm01) + std::abs(dm11 - 1.0f);
}
bool nearIdentity(float dm00, float dm01, float dm11) {
return identityResidue(dm00, dm01, dm11) <= k_identityEps;
}
} // namespace
BlobRect::BlobRect(QQuickItem* parent) : BlobShape(parent) {
@@ -27,11 +43,12 @@ BlobRect::~BlobRect() {
}
bool BlobRect::needsFrame() const {
// Pump until the matrix is *exactly* identity, not merely within epsilon:
// updatePhysics() snaps it there once it is near enough, so this stays true
// only for the frames between the last integration and that snap.
return m_physicsActive || m_retiring ||
m_stillSamples < k_settleStillSamples ||
std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) +
std::abs(m_dm11 - 1.0f) >
k_identityEps;
identityResidue(m_dm00, m_dm01, m_dm11) > 0.0f;
}
void BlobRect::resetDeformMatrix() {
@@ -55,10 +72,15 @@ void BlobRect::updatePolish() {
}
void BlobRect::finishAtIdentity() {
const bool settled = identityResidue(m_dm00, m_dm01, m_dm11) <= 0.0f &&
!m_retiring && !m_physicsActive;
if (settled) return;
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
m_deformMatrix = QMatrix4x4();
m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f;
m_retiring = false;
m_physicsActive = false;
emit rawDeformMatrixChanged();
@@ -110,6 +132,16 @@ void BlobRect::updatePhysics() {
m_dm00 = m_retire00 + (1.0f - m_retire00) * s;
m_dm01 = m_retire01 - m_retire01 * s;
m_dm11 = m_retire11 + (1.0f - m_retire11) * s;
// Snap the emitted matrix to exact identity while the retire tail is
// below the visible threshold, but keep retiring: ending the retire early
// hides the background while the panel animation still has a tail.
if (nearIdentity(m_dm00, m_dm01, m_dm11)) {
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
}
m_deformMatrix = QMatrix4x4(
m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
emit rawDeformMatrixChanged();
@@ -119,12 +151,15 @@ void BlobRect::updatePhysics() {
const QPointF scenePos = mapToScene(QPointF(width() / 2.0, height() / 2.0));
if (!m_physicsActive && !m_retiring) {
const float stuck = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) +
std::abs(m_dm11 - 1.0f);
if (stuck > k_identityEps) {
const float residue = identityResidue(m_dm00, m_dm01, m_dm11);
if (residue > k_identityEps) {
beginRetire();
return;
}
// Residue at or below the threshold is invisible, so it is written as
// exact identity - the panel transform then stops being a transform at
// all. Sampling continues below, so a new movement can restart physics.
if (residue > 0.0f) finishAtIdentity();
}
if (!m_hasPrevPos) {
@@ -215,6 +250,21 @@ void BlobRect::updatePhysics() {
m_dmVel11 = (m_dmVel11 - kStiffness * (m_dm11 - target11) * dt) * invDamp;
m_dm11 += m_dmVel11 * dt;
// Land on exact identity as soon as the spring is within the invisible
// threshold, so the panel's contents are back on the device pixel grid -
// but keep physicsActive until trySettle() completes: the at-rest drop
// must not happen while the panel animation still has a slow tail.
if (speed < k_moveSpeed && nearIdentity(m_dm00, m_dm01, m_dm11)) {
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
m_deformMatrix = QMatrix4x4();
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
trySettle(speed);
return;
}
m_deformMatrix = QMatrix4x4(
m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
emit rawDeformMatrixChanged();
+59 -6
View File
@@ -15,6 +15,22 @@ constexpr float k_settleVel = 0.05f;
constexpr float k_identityEps = 0.0005f;
constexpr float k_stillPx = 0.5f;
constexpr int k_settleStillSamples = 3;
// How far the deform matrix is from identity, summed over its three distinct
// entries. The matrix is applied as a panel's transform, so it has to reach
// *exactly* identity rather than merely close to it: a residue of 1e-4 scales
// a 400 logical px panel by 0.04 px, which moves a device-aligned child onto a
// fraction of a device pixel, and anything that centers itself inside the
// panel (`anchors` with `alignWhenCentered`, `Layout.HCenter`/`VCenter`) then
// antialiases. The residue also differs per entry, so different children land
// on different sub-pixel offsets - which reads as random blur.
float identityResidue(float dm00, float dm01, float dm11) {
return std::abs(dm00 - 1.0f) + std::abs(dm01) + std::abs(dm11 - 1.0f);
}
bool nearIdentity(float dm00, float dm01, float dm11) {
return identityResidue(dm00, dm01, dm11) <= k_identityEps;
}
} // namespace
BlobRect2::BlobRect2(QQuickItem* parent) : BlobShape2(parent) {
@@ -26,11 +42,12 @@ BlobRect2::~BlobRect2() {
}
bool BlobRect2::needsFrame() const {
// Pump until the matrix is *exactly* identity, not merely within epsilon:
// updatePhysics() snaps it there once it is near enough, so this stays true
// only for the frames between the last integration and that snap.
return m_physicsActive || m_retiring ||
m_stillSamples < k_settleStillSamples ||
std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) +
std::abs(m_dm11 - 1.0f) >
k_identityEps;
identityResidue(m_dm00, m_dm01, m_dm11) > 0.0f;
}
bool BlobRect2::isMoving() const {
@@ -60,10 +77,15 @@ void BlobRect2::requestPhysics() {
}
void BlobRect2::finishAtIdentity() {
const bool settled = identityResidue(m_dm00, m_dm01, m_dm11) <= 0.0f &&
!m_retiring && !m_physicsActive;
if (settled) return;
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
m_deformMatrix = QMatrix4x4();
m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f;
m_retiring = false;
m_physicsActive = false;
emit rawDeformMatrixChanged();
@@ -117,6 +139,17 @@ void BlobRect2::updatePhysics() {
m_dm00 = m_retire00 + (1.0f - m_retire00) * s;
m_dm01 = m_retire01 - m_retire01 * s;
m_dm11 = m_retire11 + (1.0f - m_retire11) * s;
// Snap the emitted matrix to exact identity while the retire tail is
// below the visible threshold, but keep retiring: isMoving() drives the
// at-rest drop, and finishing early hides the background while the panel
// animation still has a tail.
if (nearIdentity(m_dm00, m_dm01, m_dm11)) {
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
}
m_deformMatrix = QMatrix4x4(
m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
emit rawDeformMatrixChanged();
@@ -126,12 +159,15 @@ void BlobRect2::updatePhysics() {
}
if (!m_physicsActive && !m_retiring) {
const float stuck = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) +
std::abs(m_dm11 - 1.0f);
if (stuck > k_identityEps) {
const float residue = identityResidue(m_dm00, m_dm01, m_dm11);
if (residue > k_identityEps) {
beginRetire();
return;
}
// Residue at or below the threshold is invisible, so it is written as
// exact identity - the panel transform then stops being a transform at
// all. Sampling continues below, so a new movement can restart physics.
if (residue > 0.0f) finishAtIdentity();
}
if (m_physicsActive) requestPhysics();
@@ -228,6 +264,23 @@ void BlobRect2::updatePhysics() {
m_dmVel11 = (m_dmVel11 - kStiffness * (m_dm11 - target11) * dt) * invDamp;
m_dm11 += m_dmVel11 * dt;
// Land on exact identity as soon as the spring is within the invisible
// threshold, so the panel's contents are back on the device pixel grid -
// but keep physicsActive until trySettle() completes: isMoving() drives the
// at-rest drop, and clearing physics early hides the background while the
// panel animation still has a slow tail.
if (speed < k_moveSpeed && nearIdentity(m_dm00, m_dm01, m_dm11)) {
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
m_deformMatrix = QMatrix4x4();
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
trySettle(speed);
updateMoving();
return;
}
m_deformMatrix = QMatrix4x4(
m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
emit rawDeformMatrixChanged();
+33 -12
View File
@@ -43,19 +43,26 @@ float sdBox(vec2 p, vec2 center, vec2 halfSize) {
}
float smin(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * h * k * (1.0 / 6.0);
// Circular smooth min — the blend fillet is a true circular arc of radius k,
// tangent to both surfaces (not a polynomial/squircle curve). Deviates from
// min(a, b) only in the corner region where BOTH a < k and b < k (unlike the
// cubic, which deviates over the whole band |a - b| < k and dents the surface
// inward while the shapes are still apart). Always <= min(a, b); max blend
// depth at a == b is (sqrt(2) - 1) * k.
return max(k, min(a, b)) - length(max(vec2(k) - vec2(a, b), vec2(0.0)));
}
float smax(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return max(a, b) + h * h * h * k * (1.0 / 6.0);
// Circular smooth max — dual of smin: -smin(-a, -b, k). Always >= max(a, b).
return min(-k, max(a, b)) + length(max(vec2(a, b) + vec2(k), vec2(0.0)));
}
float smaxSharpA(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
float blend = h * h * h * k * (1.0 / 6.0);
blend *= smoothstep(0.0, k * 0.5, -a);
// Circular smax variant that keeps a's boundary sharp (no inward rounding at
// a = 0). Used for the frame outer edge so it always fills to the edges.
float sm =
min(-k, max(a, b)) + length(max(vec2(a, b) + vec2(k), vec2(0.0)));
float blend = (sm - max(a, b)) * smoothstep(0.0, k * 0.5, -a);
return max(a, b) + blend;
}
@@ -148,7 +155,8 @@ void main() {
continue;
if ((excludeMask & (1 << j)) != 0)
continue;
if (abs(dArr[i] - dArr[j]) >= smoothFactor)
// Circular smin deviates from min only where BOTH d values are < k.
if (max(dArr[i], dArr[j]) >= smoothFactor)
continue;
mergedSdf = min(mergedSdf, smin(dArr[i], dArr[j], smoothFactor));
}
@@ -179,7 +187,13 @@ void main() {
smoothstep(innerRight - smoothFactor, innerRight, pixel.x);
float sinkValue = 0.0;
float preOff = smoothFactor * (1.0 / 6.0);
// Sink onset / residual overlap: how far a rect must penetrate the border
// before the inner wall recedes to form its pocket. Too low and the wall
// recedes faster than the junction can stay convex, denting the inner edge
// inward near the rect's corners; too high and the rect nestles too deep
// before the wall yields. Tuned to the circular smin blend depth: half the
// gap-closing distance, (2 - sqrt(2)) * k / 2.
float preOff = smoothFactor * (2.0 - sqrt(2.0)) * 0.5;
float s = smoothFactor * 2.0;
if (topZone > 0.0 || botZone > 0.0 || leftZone > 0.0 || rightZone > 0.0) {
@@ -215,9 +229,16 @@ void main() {
dInner -= sinkValue;
float negInner = -dInner;
float dFrame = dOuter >= negInner ? dOuter
: smaxSharpA(dOuter, negInner, smoothFactor);
// The circular smax fillet has radius kFrame; when it exceeds the border
// thickness it can't complete inside the border, so the sharp outer-box term
// bleeds onto the inner edge and bulges the inner corners (worst when
// thickness < smoothFactor). Clamp kFrame to the thinnest side so the inner
// edge stays a clean constant-radius arc. Each inner corner is bounded by
// its thinner adjacent side, so the global min is correct for every corner.
float minThick = min(min(innerTop - outerTop, outerBot - innerBot),
min(innerLeft - outerLeft, outerRight - innerRight));
float kFrame = clamp(min(smoothFactor, minThick - 1.0), 1.0, smoothFactor);
float dFrame = smaxSharpA(dOuter, -dInner, kFrame);
mergedSdf = smin(mergedSdf, dFrame, smoothFactor);
if (dFrame < minDist) {
+1
View File
@@ -14,6 +14,7 @@ qml_module(ZShell-components
roundedmask.hpp roundedmask.cpp
roundedclip.hpp roundedclip.cpp
snaplayout.hpp snaplayout.cpp
snapview.hpp snapview.cpp
wheelinverter.hpp
LIBRARIES
Qt::Quick
+314 -19
View File
@@ -17,6 +17,20 @@ qint64 originResidual(qreal logical, qreal dpr) {
return std::llround((device - std::floor(device)) * 1024.0) % 1024;
}
// Snap one size hint to the device grid. `mode` is >0 to round up, <0 to round
// down, 0 to round to nearest. Infinities and NaN are left alone: they mean
// "unbounded".
qreal snapSizeHint(qreal value, qreal dpr, int mode) {
if (qIsNaN(value) || std::isinf(value)) return value;
const double exact = value * dpr;
const double snapped = mode > 0 ? std::ceil(exact - 1e-9)
: mode < 0
? std::floor(exact + 1e-9)
: static_cast<double>(std::llround(exact));
return snapped / dpr;
}
QPointF absoluteOrigin(const QQuickItem* item) {
qreal x = 0.0;
qreal y = 0.0;
@@ -102,9 +116,174 @@ void SnapLayout::itemChange(ItemChange change, const ItemChangeData& value) {
}
}
void SnapLayout::geometryChange(
const QRectF& newGeometry, const QRectF& oldGeometry) {
Q_D(SnapLayout);
QQuickLayout::geometryChange(newGeometry, oldGeometry);
if (!isReady()) return;
const QQuickWindow* win = window();
const qreal dpr = win ? win->effectiveDevicePixelRatio() : 0.0;
if (dpr <= 0.0) return;
// Any change of our own geometry can move us: our size changed and an anchor
// on a bottom or right edge moved us with it, or an ancestor resized and the
// same anchors moved us. Children were placed relative to the origin the
// arrangement saw: their local positions carry the compensation for that
// origin's fraction of a device pixel, so after a move they land on a
// fraction - which is why a centered child is blurred by a different amount
// every time the panel opens. Re-arrange whenever the origin's residual
// changes; it is remembered on every rearrange, so this fires only when a
// re-arrange is really needed, and it converges as soon as the animation
// stops moving the layout.
const QPointF origin = absoluteOrigin(this);
if (originResidual(origin.x(), dpr) == d->originResidualX &&
originResidual(origin.y(), dpr) == d->originResidualY)
return;
rearrangeForOriginMove();
}
// Children are placed against the origin the arrangement saw, so a move that
// changes the origin's fraction of a device pixel needs a new arrangement.
// polish() defers that to the next frame, and a polish request does nothing
// while one is already pending - if no frame follows, or the pending polish is
// never delivered, the children stay placed against the old origin forever.
// Rearrange immediately instead; polish() is only the fallback for the cases
// where rearranging now is not possible.
void SnapLayout::rearrangeForOriginMove() {
Q_D(SnapLayout);
const QSizeF size(width(), height());
if (d->rearranging || size.width() <= 0.0 || size.height() <= 0.0) {
polish();
return;
}
rearrange(size);
}
QSizeF SnapLayout::sizeHint(Qt::SizeHint whichSizeHint) const {
Q_D(const SnapLayout);
return d->engine.sizeHint(whichSizeHint, QSizeF(), d->styleInfo);
const QSizeF engineHint =
d->engine.sizeHint(whichSizeHint, QSizeF(), d->styleInfo);
const QQuickWindow* win = window();
const qreal dpr = win ? win->effectiveDevicePixelRatio() : 1.0;
if (dpr <= 0.0) return engineHint;
const bool horiz = d->orientation == Qt::Horizontal;
// Qt builds implicit sizes out of whole logical pixels:
// QQuickLayout::effectiveSizeHints_helper takes `qCeil(item->implicitHeight())`
// for every child, so the sum can be an odd number of logical pixels, which is
// a half device pixel at a fractional ratio - 37 logical pixels at 1.5x is 55.5
// device pixels. A layout that sizes itself from its implicit size then has an
// edge on a fraction of a device pixel, and anchored to a bottom edge its top
// edge - and every child measured down from that top - lands off the grid.
//
// The preferred size is therefore summed here in device pixels: each child's
// own hint rounded up to the device grid, plus its Layout margins and the
// device-normalized spacing. That is exactly what the arrangement writes, so
// the layout is neither larger than its children (dead space, and a Flickable
// that reports more content height than the content occupies) nor smaller
// (the last child cut off).
qint64 mainExtentDev = 0;
qint64 crossDev = 0;
qint64 maxMainDev = 0;
int count = 0;
for (int i = 0; i < d->engine.itemCount(); ++i) {
const SnapGridLayoutItem* gridItem =
static_cast<const SnapGridLayoutItem*>(d->engine.itemAt(i));
QQuickItem* child = gridItem->layoutItem();
if (!child || shouldIgnoreItem(child)) continue;
QQuickLayoutAttached* info = attachedLayoutObject(child, false);
const QMarginsF margins = info ? info->effectiveQMargins()
: QMarginsF(0, 0, 0, 0);
// The engine's per-child hints are authoritative: they follow
// Layout.preferredWidth/Height when those are set, otherwise the child's
// implicit size, otherwise the first width and height it had, and they
// include the child's Layout margins. They are also cached, so a nested
// layout contributes its own preferred size even before it is polished.
const QSizeF* hints = gridItem->effectiveSizeHints();
const qreal mainMargin = horiz ? margins.left() + margins.right()
: margins.top() + margins.bottom();
const qreal crossMargin = horiz ? margins.top() + margins.bottom()
: margins.left() + margins.right();
qreal mainHint = (horiz ? hints[Qt::PreferredSize].width()
: hints[Qt::PreferredSize].height()) -
mainMargin;
qreal crossHint = (horiz ? hints[Qt::PreferredSize].height()
: hints[Qt::PreferredSize].width()) -
crossMargin;
// The engine hint is `qCeil` of the implicit size, so it can sit up to a
// whole logical pixel above it - a device pixel per child at 1.5x, which
// adds up to dead space in a long column. When the child's own implicit
// size is known and the hint is just that ceiling, measure the implicit
// size and let the device rounding be the only rounding.
const qreal ownMain = horiz ? child->implicitWidth()
: child->implicitHeight();
if (ownMain > 0.0 && mainHint <= ownMain + 1.0) mainHint = ownMain;
const qreal ownCross = horiz ? child->implicitHeight()
: child->implicitWidth();
if (ownCross > 0.0 && crossHint <= ownCross + 1.0) crossHint = ownCross;
if (!std::isfinite(mainHint) || mainHint < 0.0) mainHint = 0.0;
if (!std::isfinite(crossHint) || crossHint < 0.0) crossHint = 0.0;
// Rounding up keeps the hint from ever under-sizing its contents.
const qint64 mainDev = qint64(std::ceil(mainHint * dpr - 1e-9));
const qint64 mainMarginDev = qint64(std::ceil(mainMargin * dpr - 1e-9));
const qint64 crossDevForChild =
qint64(std::ceil(crossHint * dpr - 1e-9)) +
qint64(std::ceil(crossMargin * dpr - 1e-9));
mainExtentDev += mainDev + mainMarginDev;
maxMainDev = (std::max)(maxMainDev, mainDev + mainMarginDev);
crossDev = (std::max)(crossDev, crossDevForChild);
++count;
}
qreal space = d->spacingRequest;
if (qIsNaN(space)) space = d->styleInfo->spacing(d->orientation);
const qint64 gapDev = dpr > 0.0 ? qint64(std::llround(space * dpr))
: qint64(space);
if (uniformCellSizes() && count > 0)
mainExtentDev = maxMainDev * qint64(count);
if (count > 1) mainExtentDev += gapDev * qint64(count - 1);
const QSizeF preferred(
horiz ? qreal(mainExtentDev) / dpr : qreal(crossDev) / dpr,
horiz ? qreal(crossDev) / dpr : qreal(mainExtentDev) / dpr);
if (whichSizeHint == Qt::PreferredSize) return preferred;
if (whichSizeHint == Qt::MinimumSize) {
// Minimums round up, but never past the preferred size, or cells would
// grow past what the arrangement writes.
const QSizeF minimum(
snapSizeHint(engineHint.width(), dpr, 1),
snapSizeHint(engineHint.height(), dpr, 1));
return QSizeF(
(std::min)(minimum.width(), preferred.width()),
(std::min)(minimum.height(), preferred.height()));
}
// Maximums round down, but never below the preferred size: a layout is always
// allowed to be as large as its contents need.
const QSizeF maximum(
snapSizeHint(engineHint.width(), dpr, -1),
snapSizeHint(engineHint.height(), dpr, -1));
return QSizeF(
(std::max)(maximum.width(), preferred.width()),
(std::max)(maximum.height(), preferred.height()));
}
Qt::Orientation SnapLayout::orientation() const {
@@ -325,24 +504,33 @@ void SnapLayout::itemVisibilityChanged(QQuickItem* item) {
void SnapLayout::itemGeometryChanged(
QQuickItem* item, QQuickGeometryChange change, const QRectF& oldGeometry) {
Q_UNUSED(item);
Q_UNUSED(oldGeometry);
if (!isReady() || !change.positionChange()) return;
Q_UNUSED(item)
Q_UNUSED(change)
Q_UNUSED(oldGeometry)
if (!isReady()) return;
Q_D(SnapLayout);
const QQuickWindow* win = window();
const qreal dpr = win ? win->effectiveDevicePixelRatio() : 0.0;
if (dpr <= 0.0) return;
// An ancestor changing size can move us too - a layout anchored to a bottom
// edge slides when its parent grows - so only the origin's fraction of a
// device pixel matters, not which kind of change reached us.
Q_D(SnapLayout);
const QPointF origin = absoluteOrigin(this);
if (originResidual(origin.x(), dpr) == d->originResidualX &&
originResidual(origin.y(), dpr) == d->originResidualY)
return;
polish();
rearrangeForOriginMove();
}
void SnapLayout::updateAncestorListeners() {
Q_D(SnapLayout);
const QFlags<QQuickItemPrivate::ChangeType> mask =
QQuickItemPrivate::Geometry;
QVector<QQuickItem*> current;
for (QQuickItem* it = parentItem(); it; it = it->parentItem()) {
if (qobject_cast<SnapLayout*>(it)) break;
@@ -351,15 +539,34 @@ void SnapLayout::updateAncestorListeners() {
for (const QPointer<QQuickItem>& ancestor :
std::as_const(d->ancestorListeners)) {
if (ancestor && !current.contains(ancestor.data()))
if (ancestor && !current.contains(ancestor.data())) {
QQuickItemPrivate::get(ancestor.data())
->removeItemChangeListener(this, QQuickItemPrivate::Geometry);
->removeItemChangeListener(
this, d->ancestorMasks.value(ancestor.data()));
d->ancestorMasks.remove(ancestor.data());
}
}
// Qt appends one registration per addItemChangeListener call, so only the
// bits that are not registered yet may be added; re-adding the same mask on
// every rearrange would duplicate listeners without bound.
for (QQuickItem* ancestor : std::as_const(current)) {
if (!d->ancestorListeners.contains(ancestor))
const auto have = d->ancestorMasks.value(ancestor);
const auto missing = mask & ~have;
const auto extra = have & ~mask;
if (extra != 0) {
QQuickItemPrivate::get(ancestor)->removeItemChangeListener(
this, extra);
}
if (missing != 0) {
QQuickItemPrivate::get(ancestor)->addItemChangeListener(
this, QQuickItemPrivate::Geometry);
this, missing);
}
d->ancestorMasks[ancestor] = mask;
}
d->ancestorListeners.clear();
d->ancestorListeners.reserve(current.size());
for (QQuickItem* ancestor : std::as_const(current))
@@ -370,10 +577,13 @@ void SnapLayout::clearAncestorListeners() {
Q_D(SnapLayout);
for (const QPointer<QQuickItem>& ancestor :
std::as_const(d->ancestorListeners)) {
if (ancestor)
if (ancestor) {
QQuickItemPrivate::get(ancestor.data())
->removeItemChangeListener(this, QQuickItemPrivate::Geometry);
->removeItemChangeListener(
this, d->ancestorMasks.value(ancestor.data()));
}
}
d->ancestorMasks.clear();
d->ancestorListeners.clear();
}
@@ -393,6 +603,13 @@ void SnapLayout::arrangeChildren() {
qint64 prevSizeDev = 0;
bool started = false;
// The layout's own main extent, rounded down: no child is placed past it, so
// a Flickable that takes this layout's height as its content height can still
// reach the last child.
const QSizeF self = size();
const qint64 layoutMainExtentDev =
qint64(std::floor((horiz ? self.width() : self.height()) * dpr + 1e-6));
for (int i = 0; i < d->engine.itemCount(); ++i) {
SnapGridLayoutItem* gridItem =
static_cast<SnapGridLayoutItem*>(d->engine.itemAt(i));
@@ -419,16 +636,83 @@ void SnapLayout::arrangeChildren() {
std::llround(mainExact - prevEndExact);
}
const qint64 mainFarDev = std::llround(mainExact + mainSizeExact);
const qint64 mainFarExact = std::llround(mainExact + mainSizeExact);
// An aligned child whose cell is its own implicit size is sized from that
// implicit size. The engine recomputes cells from animated geometry, so a
// cell that is mathematically 41 can arrive as 40.9999999999; when the
// device pixel ratio puts 41 exactly on half a device pixel, that epsilon
// flips the rounding from pass to pass and the child visibly resizes while
// it only moves. Children that fill their axis, or whose cell has grown
// past their implicit size, keep the engine-derived size so that animated
// fills still step through device pixels.
QQuickLayoutAttached* info = attachedLayoutObject(child, false);
const qreal mainOwn = horiz ? child->implicitWidth()
: child->implicitHeight();
const qreal crossOwn = horiz ? child->implicitHeight()
: child->implicitWidth();
const bool fillsMain = info &&
(horiz ? info->fillWidth() : info->fillHeight());
const bool fillsCross =
info && (horiz ? info->fillHeight() : info->fillWidth());
const auto snappedOwn = [&](bool fills, qreal cellSize, qreal ownSize) {
if (fills || ownSize <= 0.0 || qAbs(cellSize - ownSize) >= 1.0)
return qint64(-1);
// Round up, like Qt's `qCeil` of implicit sizes: a child is never
// smaller than the content it holds, and this is the same rounding
// sizeHint() sums to compute the layout's own preferred size, so the
// children fill the layout without slack and without overflow.
return (std::max)(qint64(std::ceil(ownSize * dpr - 1e-9)),
qint64(0));
};
const qint64 mainOwnDev =
snappedOwn(fillsMain, horiz ? cell.width() : cell.height(), mainOwn);
const qint64 crossOwnDev = snappedOwn(
fillsCross, horiz ? cell.height() : cell.width(), crossOwn);
// The size along the main axis. An implicit-sized child keeps its own
// implicit size, which carries the device-pixel parity ZUtils chose for
// it. Rounding is not neutral: an odd logical size at 1.5x is exactly half
// a device pixel and always rounds up, so left unchecked every child adds
// half a pixel and a long list ends outside the layout - a Flickable reads
// the layout's height as the end of the content and the last rows become
// unreachable. Corrections are made in steps of two device pixels: an item
// that centers its own content (Layout.HCenter / Layout.VCenter,
// anchors.centerIn) lands on whole device pixels only while its device
// size keeps that parity, so a one-pixel correction moves its centered
// children onto half a pixel and they antialias. Drift from the engine's
// edge is bounded at two device pixels, and no child is placed past the
// layout's own edge - a layout with less room than its children needs
// overflows in Qt too, so the engine's own edge is never pulled back.
const qint64 mainFarCeil =
(std::max)(mainFarExact, layoutMainExtentDev);
const qint64 mainLimit =
(std::min)(mainFarExact + 2, mainFarCeil) - mainNear;
const auto stored = d->childSizes.constFind(child);
qint64 width = engineWidth;
qint64 height = engineHeight;
const qint64 mainSizeWritten =
mainOwnDev >= 0 ? mainOwnDev
: (std::max)(mainFarExact - mainNear, qint64(0));
if (horiz)
width = std::max(mainFarDev - mainNear, qint64(0));
width = mainSizeWritten;
else
height = std::max(mainFarDev - mainNear, qint64(0));
height = mainSizeWritten;
if (crossOwnDev >= 0) {
if (horiz)
height = crossOwnDev;
else
width = crossOwnDev;
}
// Keep the size already written unless the engine asks for a different
// one, or something else resized the child. An aligned size stays
// aligned wherever the layout moves, and resizing again costs the child
// a relayout: an Image reloads its texture, a nested layout rearranges.
const auto stored = d->childSizes.constFind(child);
if (stored != d->childSizes.constEnd() &&
stored.value().engineWidth == engineWidth &&
stored.value().engineHeight == engineHeight &&
@@ -436,11 +720,22 @@ void SnapLayout::arrangeChildren() {
std::llround(child->height() * dpr) == stored.value().height) {
width = stored.value().width;
height = stored.value().height;
} else {
d->childSizes[child] = SnapLayoutPrivate::ChildSize{
engineWidth, engineHeight, width, height};
}
// A remembered size can predate the layout being sized, so the limit is
// applied to whatever ends up being written, keeping the parity of the
// size this child is asking for.
qint64& mainAxisSize = horiz ? width : height;
const int mainParity = int(mainAxisSize % 2);
if (mainAxisSize > mainLimit) {
mainAxisSize = mainLimit;
if (int(mainAxisSize % 2) != mainParity) --mainAxisSize;
}
mainAxisSize = (std::max)(mainAxisSize, qint64(0));
d->childSizes[child] = SnapLayoutPrivate::ChildSize{
engineWidth, engineHeight, width, height};
prevEndExact = mainExact + mainSizeExact;
prevNearDev = mainNear;
prevSizeDev = horiz ? width : height;
+7
View File
@@ -84,6 +84,9 @@ class SnapLayout : public QQuickLayout {
void itemChange(ItemChange change, const ItemChangeData& value) override;
void geometryChange(
const QRectF& newGeometry, const QRectF& oldGeometry) override;
void itemDestroyed(QQuickItem* item) override;
void itemVisibilityChanged(QQuickItem* item) override;
void itemGeometryChanged(
@@ -118,6 +121,9 @@ class SnapLayout : public QQuickLayout {
void arrangeChildren();
// Re-arranges when the origin moved by a fraction of a device pixel.
void rearrangeForOriginMove();
void applySpacing();
void updateAncestorListeners();
@@ -142,6 +148,7 @@ class SnapLayoutPrivate : public QQuickLayoutPrivate {
QList<QQuickItem*> invalidateAfterRearrange;
QVector<QPointer<QQuickItem>> ancestorListeners;
QHash<QQuickItem*, QFlags<QQuickItemPrivate::ChangeType>> ancestorMasks;
qint64 originResidualX = -1;
qint64 originResidualY = -1;
+806
View File
@@ -0,0 +1,806 @@
#include "snapview.hpp"
#include <QQuickWindow>
#include <QTimer>
#include <private/qquickflickable_p.h>
#include <private/qquickflickable_p_p.h>
#include <algorithm>
#include <cmath>
#include <limits>
namespace ZShell::components {
namespace {
// The same device-pixel helpers SnapLayout uses: the residual is the fractional
// part of an absolute device-pixel position, which is how a sub-pixel move of
// the view or its ancestors is noticed without reacting to whole-pixel moves.
qint64 originResidual(qreal logical, qreal dpr) {
const qreal device = logical * dpr;
return std::llround((device - std::floor(device)) * 1024.0) % 1024;
}
QPointF absoluteOrigin(const QQuickItem* item) {
qreal x = 0.0;
qreal y = 0.0;
for (const QQuickItem* it = item; it; it = it->parentItem()) {
x += it->x();
y += it->y();
}
return QPointF(x, y);
}
constexpr int AsyncBatchSize = 4;
// Sizes are compared with a tolerance rather than ==, because the snapped
// logical values are quotients of device pixels and never exact.
bool differs(qreal a, qreal b) {
return qAbs(a - b) > 0.001;
}
// Geometry is watched to notice sub-pixel moves; Destroyed is watched so a
// listener can be dropped while the item is still valid instead of at teardown.
constexpr QFlags<QQuickItemPrivate::ChangeType> WatchedChanges =
QQuickItemPrivate::Geometry | QQuickItemPrivate::Destroyed;
void addGeometryListener(QQuickItem* item, QQuickItemChangeListener* listener) {
if (item)
QQuickItemPrivate::get(item)->addItemChangeListener(
listener, WatchedChanges);
}
void removeGeometryListener(
QQuickItem* item, QQuickItemChangeListener* listener) {
if (item)
QQuickItemPrivate::get(item)->removeItemChangeListener(
listener, WatchedChanges);
}
} // namespace
SnapView::SnapView(QQuickItem* parent) : QQuickFlickable(parent) {
setClip(true);
// Asynchronous batches are spread over event loop turns, so the frames
// between them can render; polish() from updatePolish would run them all
// inside one polish flush.
m_asyncTimer = new QTimer(this);
m_asyncTimer->setSingleShot(true);
m_asyncTimer->setInterval(0);
connect(m_asyncTimer, &QTimer::timeout, this, &SnapView::polish);
}
SnapView::~SnapView() {
clearListeners();
disconnectModel();
}
QAbstractItemModel* SnapView::model() const {
return m_model.data();
}
void SnapView::setModel(QAbstractItemModel* model) {
if (m_model == model) return;
disconnectModel();
for (int index : m_items.keys())
destroyDelegate(index);
m_sizeDevCache.clear();
m_rowSizes.clear();
m_model = model;
invalidateRoleNames();
connectModel();
rebuildRows();
if (m_componentComplete) polish();
emit modelChanged();
}
QQmlComponent* SnapView::delegate() const {
return m_delegate.data();
}
void SnapView::setDelegate(QQmlComponent* delegate) {
if (m_delegate == delegate) return;
m_delegate = delegate;
for (int index : m_items.keys())
destroyDelegate(index);
m_pendingCreate.clear();
m_sizeDevCache.clear();
if (m_componentComplete) polish();
emit delegateChanged();
}
Qt::Orientation SnapView::orientation() const {
return m_orientation;
}
void SnapView::setOrientation(Qt::Orientation orientation) {
if (m_orientation == orientation) return;
m_orientation = orientation;
invalidateMainAxisCaches();
if (m_componentComplete) polish();
emit orientationChanged();
}
qreal SnapView::spacing() const {
return m_spacing;
}
void SnapView::setSpacing(qreal spacing) {
if (qFuzzyCompare(m_spacing, spacing)) return;
m_spacing = spacing;
if (m_componentComplete) polish();
emit spacingChanged();
}
qreal SnapView::cacheBuffer() const {
return m_cacheBuffer;
}
void SnapView::setCacheBuffer(qreal buffer) {
if (qFuzzyCompare(m_cacheBuffer, buffer)) return;
m_cacheBuffer = buffer;
if (m_componentComplete) polish();
emit cacheBufferChanged();
}
qreal SnapView::estimatedSize() const {
return m_estimatedSize;
}
void SnapView::setEstimatedSize(qreal size) {
if (qFuzzyCompare(m_estimatedSize, size)) return;
m_estimatedSize = size;
if (m_componentComplete) polish();
emit estimatedSizeChanged();
}
bool SnapView::snapSizes() const {
return m_snapSizes;
}
void SnapView::setSnapSizes(bool snapSizes) {
if (m_snapSizes == snapSizes) return;
m_snapSizes = snapSizes;
if (m_componentComplete) polish();
emit snapSizesChanged();
}
bool SnapView::asynchronous() const {
return m_asynchronous;
}
void SnapView::setAsynchronous(bool asynchronous) {
if (m_asynchronous == asynchronous) return;
m_asynchronous = asynchronous;
if (m_componentComplete) polish();
emit asynchronousChanged();
}
int SnapView::count() const {
return m_model ? m_model->rowCount() : 0;
}
int SnapView::currentIndex() const {
return m_currentIndex;
}
void SnapView::setCurrentIndex(int index) {
if (m_currentIndex == index) return;
m_currentIndex = index;
emit currentIndexChanged();
emit currentItemChanged();
}
QQuickItem* SnapView::currentItem() const {
return m_items.value(m_currentIndex, nullptr);
}
void SnapView::positionViewAtBeginning() {
positionViewAtIndex(0);
}
void SnapView::positionViewAtEnd() {
positionViewAtIndex(count() - 1);
}
void SnapView::positionViewAtIndex(int index) {
if (!m_componentComplete || index < 0 || index >= m_rows.size()) return;
// Summed here rather than read from m_rows, whose offsets only refresh on
// polish: positioning must be right even when called between passes.
const bool horiz = m_orientation == Qt::Horizontal;
const qreal extent = horiz ? width() : height();
const qreal spacing = effectiveSpacing();
qreal offset = 0.0;
qreal target = 0.0;
qreal total = 0.0;
for (int i = 0; i < m_rows.size(); ++i) {
if (i == index) target = offset;
const qreal size = rowSize(i);
total = offset + size;
offset += size + spacing;
}
const qreal position =
std::clamp(target, 0.0, (std::max)(0.0, total - extent));
const qint64 positionDev = std::llround(position * devicePixelRatio());
const qreal snapped = qreal(positionDev) / devicePixelRatio();
if (horiz)
setContentX(snapped);
else
setContentY(snapped);
}
qreal SnapView::devicePixelRatio() const {
const QQuickWindow* win = window();
const qreal dpr = win ? win->effectiveDevicePixelRatio() : 1.0;
return dpr > 0.0 ? dpr : 1.0;
}
void SnapView::componentComplete() {
QQuickFlickable::componentComplete();
m_componentComplete = true;
rebuildRows();
updateAncestorListeners();
polish();
}
void SnapView::geometryChange(
const QRectF& newGeometry, const QRectF& oldGeometry) {
QQuickFlickable::geometryChange(newGeometry, oldGeometry);
if (m_componentComplete && !m_placing &&
newGeometry.size() != oldGeometry.size()) {
polish();
}
}
void SnapView::itemChange(ItemChange change, const ItemChangeData& value) {
QQuickFlickable::itemChange(change, value);
if (change == ItemSceneChange) {
// Leaving the scene means the ancestors are going away: drop the
// listeners now, while both sides are still whole.
if (value.window) {
updateAncestorListeners();
polish();
} else {
clearListeners();
}
return;
}
if (change == ItemDevicePixelRatioHasChanged) {
updateAncestorListeners();
polish();
}
}
void SnapView::updatePolish() {
if (!m_componentComplete) return;
layoutAndPlace();
if (!m_pendingCreate.isEmpty() && m_asynchronous) m_asyncTimer->start();
}
void SnapView::itemGeometryChanged(
QQuickItem* item, QQuickGeometryChange change, const QRectF& oldGeometry) {
Q_UNUSED(oldGeometry)
if (!m_componentComplete || m_placing) return;
const qreal dpr = devicePixelRatio();
QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem;
const bool watched = std::any_of(
m_ancestorListeners.constBegin(),
m_ancestorListeners.constEnd(),
[item](const QPointer<QQuickItem>& p) { return p == item; });
if (item == content || watched) {
const QPointF origin = contentOrigin();
if (originResidual(origin.x(), dpr) != m_originResidualX ||
originResidual(origin.y(), dpr) != m_originResidualY) {
polish();
}
return;
}
if (change.sizeChange() && m_items.key(item, -1) >= 0) polish();
}
void SnapView::itemDestroyed(QQuickItem* item) {
m_sizeDevCache.remove(item);
const auto isItem = [item](const QPointer<QQuickItem>& p) {
return p == item;
};
m_delegateListeners.erase(
std::remove_if(
m_delegateListeners.begin(), m_delegateListeners.end(), isItem),
m_delegateListeners.end());
m_ancestorListeners.erase(
std::remove_if(
m_ancestorListeners.begin(), m_ancestorListeners.end(), isItem),
m_ancestorListeners.end());
const int index = m_items.key(item, -1);
if (index >= 0) {
m_items.remove(index);
emit currentItemChanged();
}
}
qreal SnapView::effectiveSpacing() const {
return qreal(std::llround(m_spacing * devicePixelRatio())) /
devicePixelRatio();
}
qreal SnapView::rowSize(int index) const {
QQuickItem* item = m_items.value(index, nullptr);
if (item) {
const qreal measured = m_orientation == Qt::Horizontal ? item->width()
: item->height();
if (measured > 0.0) return measured;
}
const qreal cached = m_rowSizes.value(index, 0.0);
if (cached > 0.0) return cached;
return m_estimatedSize > 0.0 ? m_estimatedSize : 0.0;
}
qint64 SnapView::snappedDeviceMainSize(QQuickItem* item) {
const qreal dpr = devicePixelRatio();
const qreal own = m_orientation == Qt::Horizontal ? item->width()
: item->height();
if (own <= 0.0) return 0;
// Reuse the cached device size while the item's own size is still within a
// device pixel of it, so an item that only moves keeps its size.
const qint64 cached = m_sizeDevCache.value(item, -1);
if (cached >= 0 && qAbs(own * dpr - qreal(cached)) <= 1.0) return cached;
const qint64 snapped = std::llround(own * dpr);
m_sizeDevCache[item] = snapped;
return snapped;
}
int SnapView::firstVisibleIndex() const {
if (m_rows.isEmpty()) return 0;
// Row ends are non-decreasing (offsets accumulate size plus spacing), so the
// visible range can be found by binary search instead of a linear scan.
const qreal start = m_orientation == Qt::Horizontal ? contentX()
: contentY();
const qreal from = (std::max)(0.0, start - m_cacheBuffer);
const auto it = std::lower_bound(
m_rows.constBegin(),
m_rows.constEnd(),
from,
[](const Row& row, qreal value) {
return row.exactOffset + row.exactSize < value;
});
return static_cast<int>(std::distance(m_rows.constBegin(), it));
}
int SnapView::lastVisibleIndex() const {
if (m_rows.isEmpty()) return 0;
const bool horiz = m_orientation == Qt::Horizontal;
const qreal start = horiz ? contentX() : contentY();
const qreal extent = horiz ? width() : height();
const qreal to = start + extent + m_cacheBuffer;
const auto it = std::lower_bound(
m_rows.constBegin(),
m_rows.constEnd(),
to,
[](const Row& row, qreal value) { return row.exactOffset <= value; });
if (it == m_rows.constBegin()) return 0;
return static_cast<int>(std::distance(m_rows.constBegin(), it)) - 1;
}
QPointF SnapView::contentOrigin() const {
// QQuickFlickablePrivate::get has no const overload; the item private is the
// base of the flickable private, so casting is the const path to it.
const auto* priv = static_cast<const QQuickFlickablePrivate*>(
QQuickItemPrivate::get(this));
return absoluteOrigin(priv->contentItem);
}
void SnapView::layoutAndPlace() {
const qreal dpr = devicePixelRatio();
const bool horiz = m_orientation == Qt::Horizontal;
const qreal spacing = effectiveSpacing();
// Cumulative exact offsets, so the rounding below has the same exact
// reference SnapLayout's arrangeChildren works from. Running this before
// updateDelegates gives the viewport range to recycle against, and running
// it again afterwards picks up sizes that changed when a re-keyed delegate
// got a new index.
const auto accumulate = [&]() {
qreal offset = 0.0;
for (int i = 0; i < m_rows.size(); ++i) {
qreal size = rowSize(i);
QQuickItem* item = m_items.value(i, nullptr);
if (item) {
const qreal measured = horiz ? item->width() : item->height();
if (measured > 0.0) m_rowSizes[i] = measured;
}
m_rows[i].exactOffset = offset;
m_rows[i].exactSize = size;
offset += size + spacing;
}
return m_rows.isEmpty() ? 0.0 : offset - spacing;
};
accumulate();
updateDelegates();
const qreal total = accumulate();
if (horiz)
setContentWidth(qreal(std::llround(total * dpr)) / dpr);
else
setContentHeight(qreal(std::llround(total * dpr)) / dpr);
QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem;
if (!content) return;
const QPointF origin = absoluteOrigin(content);
const qint64 originXDev = std::llround(origin.x() * dpr);
const qint64 originYDev = std::llround(origin.y() * dpr);
const qint64 crossDev = std::llround((horiz ? height() : width()) * dpr);
qint64 prevNear = 0;
qint64 prevSizeDev = 0;
qreal prevEndExact = 0.0;
bool started = false;
m_placing = true;
for (int i = 0; i < m_rows.size(); ++i) {
const Row& row = m_rows.at(i);
const qreal mainExact = row.exactOffset * dpr;
qint64 mainNear = 0;
if (!started) {
mainNear = std::llround(mainExact);
started = true;
} else {
mainNear =
prevNear + prevSizeDev + std::llround(mainExact - prevEndExact);
}
QQuickItem* item = m_items.value(i, nullptr);
qint64 mainDev = std::llround(row.exactSize * dpr);
if (item) {
const qint64 ownDev = snappedDeviceMainSize(item);
if (ownDev > 0) mainDev = ownDev;
}
prevEndExact = mainExact + row.exactSize * dpr;
prevSizeDev = mainDev;
prevNear = mainNear;
if (!item) continue;
const qreal mainSize = qreal(mainDev) / dpr;
const qreal crossSize = qreal(crossDev) / dpr;
const qreal posX = horiz
? qreal(originXDev + mainNear) / dpr - origin.x()
: qreal(originXDev) / dpr - origin.x();
const qreal posY =
horiz ? qreal(originYDev) / dpr - origin.y()
: qreal(originYDev + mainNear) / dpr - origin.y();
item->setPosition(QPointF(posX, posY));
// Only the cross axis is written unconditionally. Writing the main axis
// would replace the delegate's implicit-size fallback with an explicit
// value, so a delegate whose content changes size could never grow
// again. It is resized only when snapSizes asks for exactly that.
if (horiz) {
if (m_snapSizes && mainDev > 0 && differs(item->width(), mainSize))
item->setWidth(mainSize);
if (differs(item->height(), crossSize)) item->setHeight(crossSize);
} else {
if (differs(item->width(), crossSize)) item->setWidth(crossSize);
if (m_snapSizes && mainDev > 0 && differs(item->height(), mainSize))
item->setHeight(mainSize);
}
}
m_placing = false;
trackOriginResidual();
}
void SnapView::updateDelegates() {
const int first = firstVisibleIndex();
const int last = lastVisibleIndex();
for (int index : m_items.keys()) {
if (index < first || index > last) destroyDelegate(index);
}
QVector<int> missing;
for (int index = first; index <= last; ++index) {
if (m_items.contains(index))
updateDelegateData(index);
else
missing.append(index);
}
int budget = m_asynchronous ? AsyncBatchSize
: (std::numeric_limits<int>::max)();
m_pendingCreate.clear();
for (int index : std::as_const(missing)) {
if (budget <= 0) {
m_pendingCreate.append(index);
continue;
}
(void)createDelegate(index);
--budget;
}
}
QQuickItem* SnapView::createDelegate(int index) {
if (!m_delegate || !m_model || index < 0 || index >= m_model->rowCount())
return nullptr;
if (m_items.contains(index)) return m_items.value(index);
// Same as LazyListView: beginCreate + setInitialProperties + completeCreate,
// so delegates with pragma ComponentBehavior: Bound are accepted.
auto* compContext = m_delegate->creationContext();
if (!compContext) compContext = qmlContext(this);
if (!compContext) return nullptr;
QObject* obj = m_delegate->beginCreate(compContext);
QQuickItem* item = qobject_cast<QQuickItem*>(obj);
if (!item) {
qWarning(
"SnapView: delegate creation failed for row %d: %s",
index,
qPrintable(m_delegate->errorString()));
if (obj) m_delegate->completeCreate();
delete obj;
return nullptr;
}
m_delegate->setInitialProperties(item, delegateProperties(index));
QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem;
item->setParentItem(content ? content : this);
addGeometryListener(item, this);
m_delegateListeners.append(item);
m_items.insert(index, item);
m_delegate->completeCreate();
emit currentItemChanged();
return item;
}
void SnapView::destroyDelegate(int index) {
QQuickItem* item = m_items.take(index);
if (!item) return;
removeGeometryListener(item, this);
m_delegateListeners.removeAll(item);
m_sizeDevCache.remove(item);
item->deleteLater();
emit currentItemChanged();
}
void SnapView::updateDelegateData(int index) {
QQuickItem* item = m_items.value(index, nullptr);
if (!item) return;
const QVariantMap props = delegateProperties(index);
for (auto it = props.constBegin(); it != props.constEnd(); ++it) {
const QByteArray name = it.key().toUtf8();
// Scrolls refresh every visible delegate each pass; writing only the
// values that actually changed keeps that cheap.
if (item->property(name.constData()) == it.value()) continue;
item->setProperty(name.constData(), it.value());
}
}
QVariantMap SnapView::delegateProperties(int index) {
QVariantMap props;
if (!m_model || index < 0 || index >= m_model->rowCount()) return props;
const QModelIndex srcIndex = m_model->index(index, 0);
const auto& names = roleNames();
bool hasModelData = false;
for (auto it = names.constBegin(); it != names.constEnd(); ++it) {
const auto name = QString::fromUtf8(it.value());
props.insert(name, m_model->data(srcIndex, it.key()));
if (name == QStringLiteral("modelData")) hasModelData = true;
}
props.insert(QStringLiteral("index"), index);
if (!hasModelData) {
const auto role = names.isEmpty() ? Qt::DisplayRole
: names.constBegin().key();
props.insert(QStringLiteral("modelData"), m_model->data(srcIndex, role));
}
return props;
}
const QHash<int, QByteArray>& SnapView::roleNames() {
if (!m_roleNamesValid) {
m_roleNames = m_model ? m_model->roleNames() : QHash<int, QByteArray>();
m_roleNamesValid = true;
}
return m_roleNames;
}
void SnapView::invalidateRoleNames() {
m_roleNamesValid = false;
}
void SnapView::invalidateMainAxisCaches() {
// Cached sizes belong to the axis that is no longer the main one.
m_rowSizes.fill(0.0);
m_sizeDevCache.clear();
}
void SnapView::rebuildRows() {
m_rows.resize(count());
m_rowSizes.resize(count());
if (m_currentIndex >= count()) m_currentIndex = -1;
emit countChanged();
emit currentItemChanged();
}
void SnapView::trackOriginResidual() {
const qreal dpr = devicePixelRatio();
const QPointF origin = contentOrigin();
m_originResidualX = originResidual(origin.x(), dpr);
m_originResidualY = originResidual(origin.y(), dpr);
}
void SnapView::shiftDelegateIndices(int from, int delta) {
if (delta == 0) return;
QHash<int, QQuickItem*> shifted;
for (auto it = m_items.constBegin(); it != m_items.constEnd(); ++it) {
shifted.insert(
it.key() >= from ? it.key() + delta : it.key(), it.value());
}
m_items = shifted;
for (int& index : m_pendingCreate) {
if (index >= from) index += delta;
}
}
void SnapView::updateAncestorListeners() {
QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem;
QVector<QPointer<QQuickItem>> want;
if (content) want.append(content);
for (QQuickItem* it = parentItem(); it; it = it->parentItem())
want.append(it);
for (const QPointer<QQuickItem>& item :
std::as_const(m_ancestorListeners)) {
if (item && !want.contains(item))
removeGeometryListener(item.data(), this);
}
for (const QPointer<QQuickItem>& item : std::as_const(want)) {
const bool known = std::any_of(
m_ancestorListeners.constBegin(),
m_ancestorListeners.constEnd(),
[&item](const QPointer<QQuickItem>& p) { return p == item; });
if (!known) addGeometryListener(item.data(), this);
}
m_ancestorListeners = want;
trackOriginResidual();
}
void SnapView::clearListeners() {
for (const QPointer<QQuickItem>& item : std::as_const(m_ancestorListeners))
removeGeometryListener(item.data(), this);
m_ancestorListeners.clear();
m_delegateListeners.clear();
}
void SnapView::connectModel() {
if (!m_model) return;
m_modelConnections.append(
QObject::connect(
m_model,
&QAbstractItemModel::rowsInserted,
this,
&SnapView::onRowsInserted));
m_modelConnections.append(
QObject::connect(
m_model,
&QAbstractItemModel::rowsAboutToBeRemoved,
this,
&SnapView::onRowsAboutToBeRemoved));
m_modelConnections.append(
QObject::connect(
m_model,
&QAbstractItemModel::rowsRemoved,
this,
&SnapView::onRowsRemoved));
m_modelConnections.append(
QObject::connect(
m_model,
&QAbstractItemModel::dataChanged,
this,
&SnapView::onDataChanged));
m_modelConnections.append(
QObject::connect(
m_model,
&QAbstractItemModel::modelReset,
this,
&SnapView::onModelReset));
m_modelConnections.append(
QObject::connect(
m_model, &QAbstractItemModel::layoutChanged, this, [this]() {
rebuildRows();
polish();
}));
}
void SnapView::disconnectModel() {
for (const auto& conn : std::as_const(m_modelConnections))
disconnect(conn);
m_modelConnections.clear();
}
void SnapView::onRowsInserted(const QModelIndex& parent, int first, int last) {
if (parent.isValid()) return;
// Delegates at or after the inserted range now address a different row, and
// the inserted rows have no measured size yet.
shiftDelegateIndices(first, last - first + 1);
m_rowSizes.resize(count());
for (int index = first; index <= last; ++index)
m_rowSizes[index] = 0.0;
rebuildRows();
polish();
}
void SnapView::onRowsAboutToBeRemoved(
const QModelIndex& parent, int first, int last) {
if (parent.isValid()) return;
for (int index = first; index <= last; ++index)
destroyDelegate(index);
m_rowSizes.remove(first, last - first + 1);
shiftDelegateIndices(last + 1, -(last - first + 1));
}
void SnapView::onRowsRemoved(const QModelIndex& parent, int first, int last) {
if (parent.isValid()) return;
Q_UNUSED(first)
Q_UNUSED(last)
rebuildRows();
polish();
}
void SnapView::onDataChanged(
const QModelIndex& topLeft, const QModelIndex& bottomRight) {
if (topLeft.parent().isValid() || bottomRight.parent().isValid()) return;
for (int index = topLeft.row(); index <= bottomRight.row(); ++index)
updateDelegateData(index);
// A row's content can change its size, so re-place as well.
polish();
}
void SnapView::onModelReset() {
for (int index : m_items.keys())
destroyDelegate(index);
m_sizeDevCache.clear();
m_rowSizes.clear();
rebuildRows();
polish();
}
} // namespace ZShell::components
+237
View File
@@ -0,0 +1,237 @@
#pragma once
#include <QAbstractItemModel>
#include <QHash>
#include <QPointer>
#include <QQmlComponent>
#include <private/qquickflickable_p.h>
#include <private/qquickitem_p.h>
#include <qqmlintegration.h>
class QTimer;
namespace ZShell::components {
// A ListView replacement that lays delegates out the way SnapLayout lays out
// children: delegates are placed on device-pixel boundaries, the gaps between
// them are whole device pixels, siblings that move together keep their relative
// offsets, and a delegate that only moves is not resized. Like SnapLayout is to
// RowLayout/ColumnLayout, this is to ListView: a Flickable with its own
// placement, so there is no base-view positioner to fight with.
//
// Delegates receive `index` and `modelData` initial properties, so a delegate
// declares `required property int index` and `required property var modelData`.
// The cross-axis size (height in a horizontal view, width in a vertical one) is
// set by the view, as ListView does; the main-axis size comes from the delegate
// unless snapSizes is true.
class SnapView : public QQuickFlickable, public QQuickItemChangeListener {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(
QAbstractItemModel* model READ model WRITE setModel NOTIFY modelChanged
FINAL)
Q_PROPERTY(
QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY
delegateChanged FINAL)
// Layout
Q_PROPERTY(
Qt::Orientation orientation READ orientation WRITE setOrientation NOTIFY
orientationChanged FINAL)
Q_PROPERTY(
qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged FINAL)
Q_PROPERTY(
qreal cacheBuffer READ cacheBuffer WRITE setCacheBuffer NOTIFY
cacheBufferChanged FINAL)
Q_PROPERTY(
qreal estimatedSize READ estimatedSize WRITE setEstimatedSize NOTIFY
estimatedSizeChanged FINAL)
// Resize delegates to device pixels along the main axis too. Off by
// default, because delegates usually derive that size from their content
// and resizing them would break the binding. Placement is always
// device-pixel snapped.
Q_PROPERTY(
bool snapSizes READ snapSizes WRITE setSnapSizes NOTIFY snapSizesChanged
FINAL)
// Spread delegate creation over frames instead of creating everything in
// the first polish pass.
Q_PROPERTY(
bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY
asynchronousChanged FINAL)
// State
Q_PROPERTY(int count READ count NOTIFY countChanged FINAL)
Q_PROPERTY(
int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY
currentIndexChanged FINAL)
Q_PROPERTY(
QQuickItem* currentItem READ currentItem NOTIFY currentItemChanged FINAL)
signals:
void modelChanged();
void delegateChanged();
void orientationChanged();
void spacingChanged();
void cacheBufferChanged();
void estimatedSizeChanged();
void snapSizesChanged();
void asynchronousChanged();
void countChanged();
void currentIndexChanged();
void currentItemChanged();
public:
explicit SnapView(QQuickItem* parent = nullptr);
~SnapView() override;
[[nodiscard]] QAbstractItemModel* model() const;
void setModel(QAbstractItemModel* model);
[[nodiscard]] QQmlComponent* delegate() const;
void setDelegate(QQmlComponent* delegate);
[[nodiscard]] Qt::Orientation orientation() const;
void setOrientation(Qt::Orientation orientation);
[[nodiscard]] qreal spacing() const;
void setSpacing(qreal spacing);
[[nodiscard]] qreal cacheBuffer() const;
void setCacheBuffer(qreal buffer);
[[nodiscard]] qreal estimatedSize() const;
void setEstimatedSize(qreal size);
[[nodiscard]] bool snapSizes() const;
void setSnapSizes(bool snapSizes);
[[nodiscard]] bool asynchronous() const;
void setAsynchronous(bool asynchronous);
[[nodiscard]] int count() const;
[[nodiscard]] int currentIndex() const;
void setCurrentIndex(int index);
[[nodiscard]] QQuickItem* currentItem() const;
Q_INVOKABLE void positionViewAtBeginning();
Q_INVOKABLE void positionViewAtEnd();
Q_INVOKABLE void positionViewAtIndex(int index);
protected:
void componentComplete() override;
void geometryChange(
const QRectF& newGeometry, const QRectF& oldGeometry) override;
void updatePolish() override;
void itemChange(ItemChange change, const ItemChangeData& value) override;
// QQuickItemChangeListener
void itemGeometryChanged(
QQuickItem* item,
QQuickGeometryChange change,
const QRectF& oldGeometry) override;
void itemDestroyed(QQuickItem* item) override;
private:
struct Row {
qreal exactOffset = 0.0;
qreal exactSize = 0.0;
};
// Main-axis size last measured from a delegate, kept per model row so the
// content extent stays right while delegates outside the viewport are
// destroyed, the way ListView keeps contentHeight.
QVector<qreal> m_rowSizes;
// Placement, mirroring SnapLayout::arrangeChildren: exact positions come
// from cumulative row sizes, each near edge is rounded on its own so gaps
// stay whole device pixels, and siblings keep their relative offsets.
void layoutAndPlace();
void updateDelegates();
[[nodiscard]] QQuickItem* createDelegate(int index);
void destroyDelegate(int index);
void updateDelegateData(int index);
[[nodiscard]] qreal effectiveSpacing() const;
[[nodiscard]] qreal devicePixelRatio() const;
// Main-axis size of a row: the delegate's own size if it exists, otherwise
// the size cached when it was last visible, otherwise the estimate.
[[nodiscard]] qreal rowSize(int index) const;
[[nodiscard]] qint64 snappedDeviceMainSize(QQuickItem* item);
[[nodiscard]] int firstVisibleIndex() const;
[[nodiscard]] int lastVisibleIndex() const;
[[nodiscard]] QPointF contentOrigin() const;
void trackOriginResidual();
[[nodiscard]] QVariantMap delegateProperties(int index);
// The model's role names, cached because some models rebuild the hash on
// every call and this runs once per delegate per refresh.
[[nodiscard]] const QHash<int, QByteArray>& roleNames();
void invalidateRoleNames();
void rebuildRows();
// Sizes are measured along the main axis, so switching axis invalidates
// every cached size.
void invalidateMainAxisCaches();
// Re-keys the delegate map when rows shift, so an existing item keeps its
// place in the model instead of pointing at the wrong row.
void shiftDelegateIndices(int from, int delta);
void updateAncestorListeners();
void clearListeners();
void connectModel();
void disconnectModel();
void onRowsInserted(const QModelIndex& parent, int first, int last);
void onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last);
void onRowsRemoved(const QModelIndex& parent, int first, int last);
void onDataChanged(
const QModelIndex& topLeft, const QModelIndex& bottomRight);
void onModelReset();
QPointer<QAbstractItemModel> m_model;
QPointer<QQmlComponent> m_delegate;
Qt::Orientation m_orientation = Qt::Vertical;
qreal m_spacing = 0.0;
qreal m_cacheBuffer = 0.0;
qreal m_estimatedSize = -1.0;
bool m_snapSizes = false;
bool m_asynchronous = false;
int m_currentIndex = -1;
QVector<Row> m_rows;
QHash<int, QQuickItem*> m_items;
QVector<int> m_pendingCreate;
// Device-pixel main size of a delegate, so an item that only moves keeps
// the size it already has rather than re-rounding a drifting exact size.
QHash<QQuickItem*, qint64> m_sizeDevCache;
// Cached model role names; valid until the model or its roles change.
QHash<int, QByteArray> m_roleNames;
bool m_roleNamesValid = false;
// Fires the next asynchronous creation batch on the next event loop turn,
// so batches land on different frames instead of one polish flush.
QTimer* m_asyncTimer = nullptr;
// Items watched for sub-pixel moves. QPointer, as SnapLayout does, so a
// teardown order that destroys an item before the view cannot leave a
// dangling pointer to remove a listener from.
QVector<QPointer<QQuickItem>> m_delegateListeners;
QVector<QPointer<QQuickItem>> m_ancestorListeners;
qint64 m_originResidualX = -1;
qint64 m_originResidualY = -1;
bool m_placing = false;
bool m_componentComplete = false;
QList<QMetaObject::Connection> m_modelConnections;
};
} // namespace ZShell::components
+169 -48
View File
@@ -3,23 +3,120 @@
#include <qmath.h>
#include <qpainter.h>
#include <qpen.h>
#include <qquickwindow.h>
#include <algorithm>
#include <cmath>
namespace ZShell::components {
namespace {
qint64 toDev(qreal logical, qreal dpr) {
return std::llround(logical * dpr);
}
qreal fromDev(qint64 device, qreal dpr) {
return qreal(device) / dpr;
}
// A line center whose device position leaves a whole-pixel-wide stroke: an even
// width sits on pixel boundaries, an odd width on pixel centers. Same rule as
// snapLine() in Components/LineGraph.qml.
qreal snapLine(qreal logical, qint64 widthDev, qreal dpr) {
const qreal half = qreal(widthDev) / 2.0;
return (static_cast<qreal>(std::llround(logical * dpr - half)) + half) /
dpr;
}
// y of a cubic bezier at targetX, found by bisection on the parameter. Used to
// place one vertex per device pixel column, so a segment never interpolates its
// edge across several columns.
qreal cubicY(
const QPointF& p1,
const QPointF& c1,
const QPointF& c2,
const QPointF& p2,
qreal targetX) {
qreal lo = 0.0;
qreal hi = 1.0;
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();
if (x < targetX)
lo = t;
else
hi = t;
}
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();
}
// Catmull-Rom controls with the control y clamped to the segment's own y span,
// so a curve never overshoots past its endpoints.
void segmentControls(
const QVector<QPointF>& points,
int i,
bool curveSmoothing,
QPointF& c1,
QPointF& c2) {
const QPointF& p0 = points[qMax(i - 1, 0)];
const QPointF& p1 = points[i];
const QPointF& p2 = points[i + 1];
const QPointF& p3 = points[qMin(i + 2, points.size() - 1)];
if (curveSmoothing && points.size() > 2) {
const qreal k = 1.0 / 6.0;
c1 = p1 + (p2 - p0) * k;
c2 = p2 - (p3 - p1) * k;
const qreal minY = qMin(p1.y(), p2.y());
const qreal maxY = qMax(p1.y(), p2.y());
c1.setY(qBound(minY, c1.y(), maxY));
c2.setY(qBound(minY, c2.y(), maxY));
return;
}
const qreal cx = (p1.x() + p2.x()) * 0.5;
c1 = QPointF(cx, p1.y());
c2 = QPointF(cx, p2.y());
}
} // namespace
SparklineContent::SparklineContent(QQuickItem* parent)
: QQuickPaintedItem(parent) {
setAntialiasing(true);
}
void SparklineContent::itemChange(
ItemChange change, const ItemChangeData& value) {
QQuickPaintedItem::itemChange(change, value);
if (change == ItemSceneChange && value.window)
m_dpr = value.window->effectiveDevicePixelRatio();
else if (change == ItemDevicePixelRatioHasChanged && window())
m_dpr = window()->effectiveDevicePixelRatio();
else
return;
invalidate();
}
qreal SparklineContent::stepX() const {
if (m_historyLength < 2 || m_viewportW <= 0.0) return 0.0;
if (m_sliding) return m_viewportW / static_cast<qreal>(m_historyLength - 1);
const int len = qMax(
2,
(m_line1 && m_line1->count() >= 2) ? m_line1->count()
: (m_line2 ? m_line2->count() : 2));
return m_viewportW / static_cast<qreal>(len - 1);
const qreal logical =
m_sliding ? m_viewportW / static_cast<qreal>(m_historyLength - 1)
: m_viewportW / static_cast<qreal>(len - 1);
// A whole device pixel per step: every sample lands on a pixel column, and
// one slide step moves the content by an exact pixel count.
return fromDev((std::max)(qint64(1), toDev(logical, m_dpr)), m_dpr);
}
void SparklineContent::setViewportSize(
@@ -130,79 +227,97 @@ void SparklineContent::applyGeometry() {
SparklineContent::LineGeometry SparklineContent::buildGeometry(
internal::CircularBuffer* buffer) const {
LineGeometry geom;
if (!buffer || buffer->count() < 2 || m_maxValue <= 0.0 ||
m_viewportW <= 0.0)
// 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)
return geom;
if (m_historyLength < 2) return geom;
const qreal dpr = m_dpr > 0.0 ? m_dpr : 1.0;
const int len = buffer->count();
const qreal step = stepX();
if (step <= 0.0) return geom;
if (!std::isfinite(step) || step <= 0.0) return geom;
const qint64 stepDev = toDev(step, dpr);
const qint64 lineDev = std::isfinite(m_lineWidth)
? (std::max)(qint64(1), toDev(m_lineWidth, dpr))
: qint64(1);
const qint64 heightDev = toDev(m_viewportH, dpr);
qreal startX = 0.0;
if (m_sliding)
startX = m_viewportW - (len - 1) * step + step;
else
startX = 0.0;
const qint64 startDev = toDev(startX, dpr);
const qreal h = m_viewportH;
const qreal strokePad = qCeil(m_lineWidth / 2);
const qreal curvePad = 3.0;
const qreal plotTop = strokePad + curvePad;
const qreal plotBottom = h - strokePad;
const qreal fillBottom = h;
const qreal plotH = qMax<qreal>(1.0, plotBottom - plotTop);
// 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 =
(std::max)(plotTopDev + 1, heightDev - (lineDev + 1) / 2 - 1);
const qint64 plotHDev = plotBottomDev - plotTopDev;
const qreal fillBottom = fromDev(heightDev, dpr);
QVector<QPointF> points;
points.reserve(len);
for (int i = 0; i < len; ++i) {
const qreal value =
qBound<qreal>(0.0, buffer->smoothedAt(i), m_maxValue);
const qreal raw = buffer->smoothedAt(i);
const qreal x = startX + i * step;
const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH;
points.append(QPointF(x, y));
// 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<qreal>(0.0, raw, m_maxValue) : 0.0;
// Vertices on a pixel column, and on the y grid the stroke width needs.
// Item-local, so the parent's placement cannot move the shape. A flat run
// then lands on whole rows; slopes are interpolated, not snapped.
const qreal y = fromDev(
plotTopDev + static_cast<qint64>(std::llround(
(1.0 - value / m_maxValue) *
static_cast<double>(plotHDev))),
dpr);
points.append(QPointF(
fromDev(startDev + qint64(i) * stepDev, dpr),
snapLine(y, lineDev, dpr)));
}
QPainterPath linePath;
linePath.moveTo(points[0]);
linePath.moveTo(points.first());
if (m_curveSmoothing && points.size() > 2) {
const qreal tension = 1.0;
const qreal k = tension / 6.0;
const int n = static_cast<int>(points.size());
for (int i = 0; i < points.size() - 1; ++i) {
const QPointF& p1 = points[i];
const QPointF& p2 = points[i + 1];
QPointF c1;
QPointF c2;
segmentControls(points, i, m_curveSmoothing, c1, c2);
for (int i = 0; i < n - 1; ++i) {
const QPointF& p0 = points[qMax(i - 1, 0)];
const QPointF& p1 = points[i];
const QPointF& p2 = points[i + 1];
const QPointF& p3 = points[qMin(i + 2, n - 1)];
QPointF c1 = p1 + (p2 - p0) * k;
QPointF c2 = p2 - (p3 - p1) * k;
const qreal segMinY = qMin(p1.y(), p2.y());
const qreal segMaxY = qMax(p1.y(), p2.y());
c1.setY(qBound(segMinY, c1.y(), segMaxY));
c2.setY(qBound(segMinY, c2.y(), segMaxY));
linePath.cubicTo(c1, c2, p2);
}
} else {
for (int i = 0; i < points.size() - 1; ++i) {
const QPointF& p0 = points[i];
const QPointF& p1 = points[i + 1];
const qreal ctrlX = (p0.x() + p1.x()) * 0.5;
linePath.cubicTo(QPointF(ctrlX, p0.y()), QPointF(ctrlX, p1.y()), p1);
// One vertex per device pixel column, as Components/LineGraph.qml does: a
// segment spanning several columns would otherwise interpolate its edge
// across all of them. y stays continuous here - snapping it per column
// would step the slope one pixel at a time and read as a staircase.
const qint64 firstCol = std::llround(p1.x() * dpr) + 1;
const qint64 lastCol = std::llround(p2.x() * dpr) - 1;
for (qint64 col = firstCol; col <= lastCol; ++col) {
const qreal x = fromDev(col, dpr);
linePath.lineTo(x, cubicY(p1, c1, c2, p2, x));
}
linePath.lineTo(p2);
}
geom.stroke = linePath;
QPainterPath fillPath = linePath;
fillPath.lineTo(startX + (len - 1) * step, fillBottom);
fillPath.lineTo(startX, fillBottom);
fillPath.lineTo(points.last().x(), fillBottom);
fillPath.lineTo(points.first().x(), fillBottom);
fillPath.closeSubpath();
geom.fill = fillPath;
geom.hasData = true;
@@ -223,6 +338,12 @@ void SparklineContent::paint(QPainter* painter) {
painter->setRenderHint(QPainter::Antialiasing, true);
const qreal dpr = m_dpr > 0.0 ? m_dpr : 1.0;
const qreal lineWidth =
std::isfinite(m_lineWidth)
? fromDev((std::max)(qint64(1), toDev(m_lineWidth, dpr)), dpr)
: 1.0;
auto drawOne =
[&](const LineGeometry& geom, const QColor& color, qreal fillAlpha) {
if (!geom.hasData) return;
@@ -233,7 +354,7 @@ void SparklineContent::paint(QPainter* painter) {
painter->setBrush(fillColor);
painter->drawPath(geom.fill);
QPen pen(color, m_lineWidth);
QPen pen(color, lineWidth);
pen.setCapStyle(Qt::RoundCap);
pen.setJoinStyle(Qt::RoundJoin);
painter->setPen(pen);
@@ -32,6 +32,11 @@ class SparklineContent : public QQuickPaintedItem {
[[nodiscard]] qreal stepX() 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:
struct LineGeometry {
QPainterPath stroke;
@@ -60,6 +65,7 @@ class SparklineContent : public QQuickPaintedItem {
qreal m_viewportW = 0.0;
qreal m_viewportH = 0.0;
qreal m_dpr = 1.0;
LineGeometry m_geom1;
LineGeometry m_geom2;
+9
View File
@@ -1,5 +1,6 @@
#include "wavyline.hpp"
#include "wavylinerenderer.hpp"
#include <QQuickWindow>
namespace ZShell::controls {
@@ -25,6 +26,14 @@ QCanvasPainterItemRenderer* WavyLine::createItemRenderer() const {
return new WavyLineRenderer;
}
void WavyLine::itemChange(ItemChange change, const ItemChangeData& value) {
QCanvasPainterItem::itemChange(change, value);
if (change == ItemSceneChange && value.window)
m_dpr = value.window->effectiveDevicePixelRatio();
else if (change == ItemDevicePixelRatioHasChanged && window())
m_dpr = window()->effectiveDevicePixelRatio();
}
int WavyLine::lineWidth() const {
return m_lineWidth;
}
+6
View File
@@ -103,6 +103,11 @@ class WavyLine : public QCanvasPainterItem {
[[nodiscard]] QCanvasPainterItemRenderer* createItemRenderer()
const override;
// Keeps the renderer's device pixel ratio current: strokes and sample
// spacing are quantized in device pixels, so they must be built with the
// ratio the window actually has.
void itemChange(ItemChange change, const ItemChangeData& value) override;
private:
friend class WavyLineRenderer;
@@ -118,6 +123,7 @@ class WavyLine : public QCanvasPainterItem {
qreal m_fullAngle;
qreal m_radius;
qreal m_value;
qreal m_dpr = 1.0;
};
} // namespace ZShell::controls
+101 -14
View File
@@ -1,6 +1,8 @@
#include "wavylinerenderer.hpp"
#include "wavyline.hpp"
#include <qcanvaspainter.h>
#include <algorithm>
#include <cmath>
namespace ZShell::controls {
@@ -19,13 +21,48 @@ void WavyLineRenderer::synchronizeData(QCanvasPainterItem* item) {
m_fullAngle = wavyLine->m_fullAngle;
m_radius = wavyLine->m_radius;
m_value = wavyLine->m_value;
m_dpr = wavyLine->m_dpr > 0.0 ? wavyLine->m_dpr : 1.0;
}
namespace {
qint64 toDev(qreal logical, qreal dpr) {
return std::llround(logical * dpr);
}
qreal fromDev(qint64 device, qreal dpr) {
return qreal(device) / dpr;
}
// A center coordinate whose device position shares the parity of the stroke's
// device width: an even width centered on a pixel boundary and an odd width
// centered on a pixel center are the two cases where both edges of the stroke
// land on whole device pixels. Floored, so the caller gets the same value from
// the same device size every time; a rounded half could flip between two grid
// 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<double>(sizeDev) / 2.0 - halfParity)
+ halfParity;
}
qint64 snapAmplitude(qint64 widthDev, qreal multiplier) {
return std::llround(static_cast<double>(widthDev) * multiplier);
}
} // namespace
void WavyLineRenderer::paint(QCanvasPainter* painter) {
painter->clearRect(0, 0, width(), height());
// The stroke is quantized in the item's own device pixel space, never
// against its position: the parent layout already places the item on the
// device grid, so a local whole-device value stays whole-device wherever
// the item moves, and a move cannot change the painted path.
const qreal dpr = m_dpr;
m_lineWidthDev = (std::max)(qint64(1), toDev(m_lineWidth, dpr));
m_amplitudeDev = snapAmplitude(m_lineWidthDev, m_amplitudeMultiplier);
painter->setStrokeStyle(m_color);
painter->setLineWidth(m_lineWidth);
painter->setLineWidth(fromDev(m_lineWidthDev, dpr));
painter->setLineCap(QCanvasPainter::LineCap::Round);
if (m_pathType == WavyLine::Arc) {
@@ -36,18 +73,36 @@ void WavyLineRenderer::paint(QCanvasPainter* painter) {
}
void WavyLineRenderer::paintLinear(QCanvasPainter* painter) {
const auto amplitude = m_lineWidth * m_amplitudeMultiplier;
const qreal dpr = m_dpr;
const auto lineWidth = fromDev(m_lineWidthDev, dpr);
const auto phase = m_waveProgress * 2 * M_PI;
const auto centerY = height() / 2;
const qint64 heightDev = toDev(height(), dpr);
const qreal cyDev = snapCenterDev(heightDev, m_lineWidthDev);
const auto centerY = cyDev / dpr;
const auto len = m_fullLength > 0 ? m_fullLength : 1;
const auto start = m_lineWidth / 2.0;
const auto fullEnd = width() - m_lineWidth / 2.0;
const auto start = lineWidth / 2.0;
const auto fullEnd = width() - lineWidth / 2.0;
const auto drawEnd = start + (fullEnd - start) * m_value;
// Keep the crest inside the item's own texture, with one device pixel for
// the antialiasing feather, so a tight item cannot cut it flat.
const qint64 halfDev = (m_lineWidthDev + 1) / 2;
const qint64 roomDev = static_cast<qint64>(
std::floor((std::min)(cyDev, qreal(heightDev) - cyDev)));
const qint64 amplitudeDev = (std::max)(qint64(0), roomDev - halfDev - 1);
const auto amplitude =
fromDev((std::min)(m_amplitudeDev, amplitudeDev), dpr);
painter->beginPath();
bool first = true;
for (int x = m_lineWidth / 2; x <= drawEnd; ++x) {
// One vertex per device pixel column. A segment spanning several columns
// interpolates its edge across all of them, which is what makes a sloped
// stroke look soft rather than crisply anti-aliased.
const qint64 firstCol = toDev(start, dpr);
const qint64 lastCol = toDev(drawEnd, dpr);
for (qint64 col = firstCol; col <= lastCol; ++col) {
const auto x = fromDev(col, dpr);
const auto theta =
m_frequency * 2 * M_PI * (x + m_startX) / len + phase;
const auto waveY = centerY + amplitude * qSin(theta);
@@ -67,16 +122,44 @@ void WavyLineRenderer::paintArc(QCanvasPainter* painter) {
return;
}
const qreal dpr = m_dpr;
const auto amplitude = fromDev(m_amplitudeDev, dpr);
const auto startAngleRad = m_startAngle * M_PI / 180.0;
const auto fullAngleRad = m_fullAngle * M_PI / 180.0;
const auto amplitude = m_lineWidth * m_amplitudeMultiplier;
const auto cx = width() / 2.0;
const auto cy = height() / 2.0;
const auto radius =
m_radius > 0
? m_radius
: (qMin(width(), height()) - m_lineWidth - 2 * amplitude) / 2.0;
// The center comes from the item's own device size and the stroke parity,
// the same rule Components/CircularProgress.qml applies to its background
// Shape arc, so the two rings share one center.
const qint64 widthDev = toDev(width(), dpr);
const qint64 heightDev = toDev(height(), dpr);
const qreal cxDev = snapCenterDev(widthDev, m_lineWidthDev);
const qreal cyDev = snapCenterDev(heightDev, m_lineWidthDev);
const auto cx = cxDev / dpr;
const auto cy = cyDev / dpr;
// The path is painted into a texture exactly the item's size, so anything
// past its edge is cut flat. Keep the outer stroke edge, plus the wave
// crest, inside that rect and leave one device pixel for the antialiasing
// feather. Derived from the item's own size and its local center, so it
// does not depend on where the item is placed.
const qint64 halfDev = (m_lineWidthDev + 1) / 2;
const qint64 roomDev = static_cast<qint64>(
std::floor((std::min)((std::min)(cxDev, qreal(widthDev) - cxDev),
(std::min)(cyDev, qreal(heightDev) - cyDev))));
const qint64 maxRadiusDev = roomDev - m_amplitudeDev - halfDev - 1;
// The radius is a whole device pixel count. Derived from the item's own
// device size, so it does not depend on where the item is.
qint64 radiusDev;
if (m_radius > 0) {
radiusDev = toDev(m_radius, dpr);
} else {
const qint64 inner = (std::min)(widthDev, heightDev) - m_lineWidthDev -
2 * m_amplitudeDev;
radiusDev = (std::max)(qint64(1), inner / 2);
}
radiusDev = (std::max)(qint64(1), (std::min)(radiusDev, maxRadiusDev));
const auto radius = fromDev(radiusDev, dpr);
if (radius <= 0) {
return;
@@ -91,7 +174,11 @@ void WavyLineRenderer::paintArc(QCanvasPainter* painter) {
return;
}
const auto N = qMax(64, qCeil(radius * drawAngleRad));
// Sample every device pixel of arc length, not every logical one: at a
// fractional ratio a logical step is more than one device pixel wide, and
// the edge would then be interpolated across the pixels it crosses.
const auto N =
qMax(64, qCeil(static_cast<double>(toDev(radius, dpr)) * drawAngleRad));
const auto dTheta = drawAngleRad / N;
painter->beginPath();
@@ -27,6 +27,9 @@ class WavyLineRenderer final : public QCanvasPainterItemRenderer {
qreal m_fullAngle;
qreal m_radius;
qreal m_value;
qreal m_dpr = 1.0;
qint64 m_lineWidthDev = 4;
qint64 m_amplitudeDev = 0;
};
} // namespace ZShell::controls
+1
View File
@@ -18,6 +18,7 @@ class Notifs : public settings::ObjectNode {
CONFIG_PROPERTY(bool, actionOnClick, false)
CONFIG_PROPERTY(int, appNotifCooldown, 0)
CONFIG_PROPERTY(qreal, clearThreshold, 0.3)
CONFIG_PROPERTY(int, clearVelocity, 1000)
CONFIG_PROPERTY(int, defaultExpireTimeout, 5000)
CONFIG_PROPERTY(int, expandThreshold, 20)
CONFIG_PROPERTY(bool, expire, true)
File diff suppressed because it is too large Load Diff
+130 -8
View File
@@ -18,6 +18,7 @@ class Notifs;
namespace ZShell::Services {
class NotificationGroup;
class NotificationStore;
class NotifImageProvider;
@@ -58,12 +59,15 @@ class NotificationRow : public QObject {
QML_ELEMENT
QML_UNCREATABLE("NotificationRow instances come from a NotificationStore")
Q_PROPERTY(QQmlListProperty<NotificationActionRow> actions READ actions NOTIFY actionsChanged)
Q_PROPERTY(
QQmlListProperty<NotificationActionRow> actions READ actions NOTIFY
actionsChanged)
Q_PROPERTY(QString appIcon READ appIcon NOTIFY appIconChanged)
Q_PROPERTY(QString appName READ appName NOTIFY appNameChanged)
Q_PROPERTY(QString body READ body NOTIFY bodyChanged)
Q_PROPERTY(bool closed READ closed NOTIFY closedChanged)
Q_PROPERTY(bool hasActionIcons READ hasActionIcons NOTIFY hasActionIconsChanged)
Q_PROPERTY(
bool hasActionIcons READ hasActionIcons NOTIFY hasActionIconsChanged)
Q_PROPERTY(QString image READ image NOTIFY imageChanged)
Q_PROPERTY(QString imageSource READ imageSource NOTIFY imageSourceChanged)
Q_PROPERTY(int notificationId READ notificationId CONSTANT)
@@ -97,9 +101,17 @@ class NotificationRow : public QObject {
[[nodiscard]] int urgency() const;
Q_INVOKABLE void close();
Q_INVOKABLE void dismiss();
Q_INVOKABLE void dismissPopup();
Q_INVOKABLE void holdPopup();
Q_INVOKABLE bool isSameAppIcon(const QString& path) const;
Q_INVOKABLE bool isSameImage(const QString& path) const;
Q_INVOKABLE void lock(QObject* item);
Q_INVOKABLE void pausePopup();
Q_INVOKABLE void ref(QObject* item);
Q_INVOKABLE void releasePopup();
Q_INVOKABLE void resumePopup();
Q_INVOKABLE void unref(QObject* item);
Q_INVOKABLE void unlock(QObject* item);
signals:
@@ -119,12 +131,15 @@ class NotificationRow : public QObject {
void urgencyChanged();
private:
friend class NotificationGroup;
friend NotificationStore;
[[nodiscard]] bool expired(qint64 now) const;
void refreshTimeStr();
QList<NotificationActionRow*> m_actions;
NotificationGroup* m_group = nullptr;
NotificationGroup* m_popupGroup = nullptr;
NotificationStore* m_store = nullptr;
QString m_appIcon;
@@ -144,6 +159,10 @@ class NotificationRow : public QObject {
QSet<QObject*> m_locks;
QJSValue m_jsNotification;
// The store has already dropped this row from its lists, but a delegate is
// still animating it out; the delete waits for the last unlock.
bool m_deleteAfterUnlock = false;
bool m_closed = false;
bool m_hasActionIcons = false;
bool m_liveConnected = false;
@@ -156,15 +175,100 @@ class NotificationRow : public QObject {
int m_urgency = 1;
};
class NotificationGroup : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("NotificationGroup instances come from a NotificationStore")
Q_PROPERTY(
QQmlListProperty<NotificationRow> activeNotifs READ activeNotifs NOTIFY
activeNotifsChanged)
Q_PROPERTY(QString appIcon READ appIcon NOTIFY appIconChanged)
Q_PROPERTY(QString appName READ appName CONSTANT)
Q_PROPERTY(int count READ count NOTIFY countChanged)
Q_PROPERTY(QString firstSummary READ firstSummary NOTIFY firstSummaryChanged)
Q_PROPERTY(QString image READ image NOTIFY imageChanged)
Q_PROPERTY(
QQmlListProperty<NotificationRow> notifs READ notifs NOTIFY
notifsChanged)
Q_PROPERTY(QString timeStr READ timeStr NOTIFY timeStrChanged)
Q_PROPERTY(int urgency READ urgency NOTIFY urgencyChanged)
public:
NotificationGroup(const QString& appName, QObject* parent = nullptr);
[[nodiscard]] QQmlListProperty<NotificationRow> activeNotifs();
[[nodiscard]] QString appIcon() const;
[[nodiscard]] QString appName() const;
[[nodiscard]] int count() const;
[[nodiscard]] QString firstSummary() const;
[[nodiscard]] QString image() const;
[[nodiscard]] QQmlListProperty<NotificationRow> notifs();
[[nodiscard]] QString timeStr() const;
[[nodiscard]] int urgency() const;
Q_INVOKABLE bool isSameAppIcon(const QString& path) const;
Q_INVOKABLE bool isSameImage(const QString& path) const;
Q_INVOKABLE void lock(QObject* item);
Q_INVOKABLE void unlock(QObject* item);
signals:
void activeNotifsChanged();
void appIconChanged();
void countChanged();
void firstSummaryChanged();
void imageChanged();
void notifsChanged();
void timeStrChanged();
void urgencyChanged();
private:
friend NotificationStore;
void addRow(NotificationRow* row);
void refreshActive();
void release();
void removeRow(NotificationRow* row);
void sync();
QList<NotificationRow*> m_active;
QList<NotificationRow*> m_notifs;
// The group reads image/appIcon through the row it took them from, so a
// row swapping between its raw source and the cached blob keeps the card
// in sync instead of holding a stale copy.
QPointer<NotificationRow> m_iconSource;
QPointer<NotificationRow> m_imageSource;
QSet<QObject*> m_locks;
QString m_appName;
QString m_firstSummary;
QString m_timeStr;
int m_count = 0;
int m_urgency = 1;
bool m_releasing = false;
};
class NotificationStore : public QObject {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(bool autoExpire READ autoExpire WRITE setAutoExpire NOTIFY autoExpireChanged)
Q_PROPERTY(
bool autoExpire READ autoExpire WRITE setAutoExpire NOTIFY
autoExpireChanged)
Q_PROPERTY(
QQmlListProperty<NotificationGroup> groups READ groups NOTIFY
groupsChanged)
Q_PROPERTY(
QQmlListProperty<NotificationGroup> popupGroups READ popupGroups NOTIFY
popupGroupsChanged)
Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED)
Q_PROPERTY(int popupTimeout READ popupTimeout WRITE setPopupTimeout NOTIFY
popupTimeoutChanged)
Q_PROPERTY(QQmlListProperty<NotificationRow> rows READ rows NOTIFY rowsChanged)
Q_PROPERTY(
int popupTimeout READ popupTimeout WRITE setPopupTimeout NOTIFY
popupTimeoutChanged)
Q_PROPERTY(
QQmlListProperty<NotificationRow> rows READ rows NOTIFY rowsChanged)
public:
explicit NotificationStore(QObject* parent = nullptr);
@@ -176,17 +280,23 @@ class NotificationStore : public QObject {
[[nodiscard]] bool autoExpire() const;
void setAutoExpire(bool autoExpire);
[[nodiscard]] QQmlListProperty<NotificationGroup> groups();
[[nodiscard]] QQmlListProperty<NotificationGroup> popupGroups();
[[nodiscard]] int popupTimeout() const;
void setPopupTimeout(int timeout);
[[nodiscard]] QQmlListProperty<NotificationRow> rows();
Q_INVOKABLE NotificationRow* insert(const QJSValue& notification, bool popup);
Q_INVOKABLE NotificationRow* insert(
const QJSValue& notification, bool popup);
Q_INVOKABLE void clear();
signals:
void autoExpireChanged();
void groupsChanged();
void pathChanged();
void popupGroupsChanged();
void popupTimeoutChanged();
void rowsChanged();
@@ -198,18 +308,24 @@ class NotificationStore : public QObject {
void attachNotification(
NotificationRow* row, QObject* notification, const QJSValue& js);
void addGroup(NotificationRow* row);
void applyNotifConfig(const config::Notifs* notifs);
void closeDb();
void copyFields(NotificationRow* row, QObject* notification);
void flush();
void handleClosed(NotificationRow* row);
void insertDbRow(NotificationRow* row);
void loadRows();
void markDirty(NotificationRow* row);
void migrateLegacyJson();
[[nodiscard]] bool openDb();
void orphanCleanup();
void persistPopup(const NotificationRow* row);
void prune();
void addPopupGroup(NotificationRow* row);
void removePopupGroupRow(NotificationRow* row);
void updatePopupGroup(NotificationRow* row);
void registerProvider();
void removeGroupRow(NotificationRow* row);
void removeRow(NotificationRow* row);
// Coalesced rowsChanged: a burst of inserts/removes triggers one QML
// rebind on the next event loop turn instead of one per notification.
@@ -238,10 +354,16 @@ class NotificationStore : public QObject {
QString m_uuid;
QString m_path;
QList<NotificationGroup*> m_groups;
QList<NotificationGroup*> m_popupGroups;
QList<NotificationRow*> m_rows;
QHash<QObject*, NotificationRow*> m_liveRows;
QHash<QString, NotificationGroup*> m_groupsByName;
QHash<QString, NotificationGroup*> m_popupGroupsByName;
QSet<NotificationRow*> m_dirty;
NotifImageProvider* m_provider = nullptr;
QTimer* m_flush = nullptr;
QTimer* m_tick = nullptr;
};