feat: initial commit for pixel-snapping components and helpers

This commit is contained in:
2026-10-29 17:34:57 +01:00
parent 9d8bde1413
commit 9cbb90f9e1
163 changed files with 2671 additions and 951 deletions
+424
View File
@@ -1,8 +1,14 @@
#include "zutils.hpp"
#include <QtConcurrent/qtconcurrentrun.h>
#include <QtQuick/private/qquickanchors_p_p.h>
#include <QtQuickLayouts/private/qquicklayout_p.h>
#include <QtQuick/qquickitemgrabresult.h>
#include <QtQuick/qquickwindow.h>
#include <QQmlContext>
#include <QQmlExpression>
#include <QDebug>
#include <QUrl>
#include <qcontainerfwd.h>
#include <qdir.h>
#include <qdiriterator.h>
@@ -337,4 +343,422 @@ QStringList ZUtils::listFiles(const QString& dir, const QString& suffix) {
return out;
}
qreal ZUtils::dprFor(QQuickItem* item) {
if (item) {
if (const QQuickWindow* window = item->window()) {
return window->effectiveDevicePixelRatio();
}
}
if (const QScreen* screen = QGuiApplication::primaryScreen()) {
return screen->devicePixelRatio();
}
return 1.0;
}
namespace {
// Per-axis centering detected from the item's own state. Anchor lines are
// Q_GADGET values with no properties, so centering is read from the anchors'
// used-anchor bitmask instead of by evaluating `anchors.verticalCenter`.
// Layout alignment comes from the attached Layout object; an item with no
// attached object uses Qt's default (AlignLeft | AlignVCenter).
struct Centering {
bool horizontal = false;
bool vertical = false;
// anchors.alignWhenCentered: false makes Qt round half-sizes in logical px
// (y = qRound(parentSize/2) - qRound(size/2)) instead of dividing exactly.
bool alignWhenCentered = true;
QString source;
};
Centering detectCentering(QQuickItem* item) {
Centering c;
QQuickAnchors* anchors = QQuickItemPrivate::get(item)->_anchors;
if (anchors) {
const QQuickAnchorsPrivate* ad = QQuickAnchorsPrivate::get(anchors);
c.alignWhenCentered = ad->centerAligned;
if (ad->usedAnchors & QQuickAnchors::HCenterAnchor) {
c.horizontal = true;
c.source = QStringLiteral("hCenter");
}
if (ad->usedAnchors & QQuickAnchors::VCenterAnchor) {
c.vertical = true;
c.source += (c.source.isEmpty() ? "" : " ") + QStringLiteral("vCenter");
}
}
QQuickItem* parent = item->parentItem();
if (parent && parent->inherits("QQuickLayout")) {
QQuickLayoutAttached* info = attachedLayoutObject(item, false);
const Qt::Alignment flags = info ? info->alignment() : Qt::Alignment();
// Filling an axis means the layout stretches that axis, so no centering.
if (info && !info->fillWidth() && (flags & Qt::AlignHCenter)) {
c.horizontal = true;
c.source += (c.source.isEmpty() ? "" : " ") +
QStringLiteral("Layout.HCenter");
}
const bool defaultVCenter = flags == 0 || (flags & Qt::AlignVCenter);
if ((!info || !info->fillHeight()) && defaultVCenter &&
!(flags & (Qt::AlignTop | Qt::AlignBottom))) {
c.vertical = true;
c.source += (c.source.isEmpty() ? "" : " ") +
QStringLiteral("Layout.VCenter");
}
}
return c;
}
// 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);
return static_cast<int>(dev % 2);
}
// Snap a size to a whole device-pixel count whose parity matches `parity`
// (the parent's device-size parity). An item centered by *exact* division
// (Layout alignment, or anchors with alignWhenCentered: true) lands on whole
// device pixels exactly when its device size shares the parent's parity,
// 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);
if (static_cast<int>(n % 2) != parity) {
if (mode > 0) {
++n;
} else if (mode < 0) {
--n;
} else {
// nearest: prefer the side closer to exact; exact ties go up
n = (exact >= static_cast<double>(n)) ? n + 1 : n - 1;
}
}
if (n < 0)
n = 0;
return static_cast<qreal>(n) / dpr;
}
// qRound() semantics: round half away from zero.
qint64 roundAway(double value) {
return value >= 0.0 ? qint64(value + 0.5) : qint64(value - 0.5);
}
// Snap a size for an item centered by anchors with `alignWhenCentered: false`.
// Qt resolves that as `y = qRound(parentSize / 2) + parentPos - size / 2`
// (qquickanchors.cpp: the *anchor target* rounds its half, the anchored item
// 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) {
const double exact = value * dpr;
const double anchorDev = double(parentHalfRounded) * dpr;
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);
for (qint64 step = 0; step < 8; ++step) {
if (base + step >= 0 && (base + step) % 2 == parity)
return qreal(base + step) / dpr;
if (step > 0 && base - step >= 0 && (base - step) % 2 == parity)
return qreal(base - step) / dpr;
}
return qreal(std::max<qint64>(0, base)) / dpr;
}
// Snap a size along `horizontal` for an item whose placement on that axis is
// centered. Anchor-centered items follow Qt's rounding (`alignWhenCentered`);
// anything else only needs the parent's device-size parity.
qreal snapCenteredAxis(
qreal value,
QQuickItem* item,
qreal dpr,
bool horizontal,
const Centering& centering,
int mode) {
QQuickItem* parent = item->parentItem();
if (!centering.alignWhenCentered && parent) {
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);
}
} // namespace
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);
}
return std::round(value * dpr) / dpr;
}
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);
}
return std::ceil(value * dpr - 1e-6) / dpr;
}
qreal ZUtils::snapFloor(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);
}
return std::floor(value * dpr + 1e-6) / dpr;
}
bool ZUtils::logPlacement(QQuickItem* item, const QString& tag) {
if (!item) {
qWarning() << "logPlacement: null item";
return false;
}
constexpr double tol = 1e-3;
const auto isOff = [](double v) {
return std::abs(v - std::round(v)) > tol;
};
const auto fmt = [&](double v) {
return QString::number(v, 'f', 2) +
(isOff(v) ? QStringLiteral("*") : QString());
};
// Centering is detected from the item: anchors.centerIn, the individual
// center anchor lines, and Layout alignment.
const Centering c = detectCentering(item);
const bool centeredH = c.horizontal;
const bool centeredV = c.vertical;
const QString& centerSrc = c.source;
const double dpr = dprFor(item);
const QRectF r =
item->mapRectToScene(QRectF(0, 0, item->width(), item->height()));
const double l = r.left() * dpr;
const double t = r.top() * dpr;
const double rt = r.right() * dpr;
const double b = r.bottom() * dpr;
// Same rule as the audit: a Text's fractional implicit size doesn't blur anything.
const bool isText = item->inherits("QQuickText");
// Centered rule per axis: whole device size, and the center on the grid
// with parity matching that size (even size -> integer center, odd ->
// pixel center at .5). That is what makes both edges land on whole device
// pixels when the center is divided exactly (`alignWhenCentered: true`).
// With `alignWhenCentered: false` Qt rounds the anchor target's half
// 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);
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;
return std::min(err, 1.0 - err) <= tol;
};
bool aligned;
if (centeredH && centeredV) {
aligned = centeredAxisOk(l, rt) && (isText || centeredAxisOk(t, b));
} else if (centeredH) {
aligned = centeredAxisOk(l, rt) && !isOff(t) && (isText || !isOff(b));
} else if (centeredV) {
aligned = !isOff(l) && centeredAxisOk(t, b) && (isText || !isOff(rt));
} else {
aligned = !isOff(l) && !isOff(t) &&
(isText || (!isOff(rt) && !isOff(b)));
}
QString file;
if (const QQmlContext* ctx = qmlContext(item)) {
const QUrl url = ctx->baseUrl();
file = url.isLocalFile() ? url.toLocalFile() : url.toString();
}
qInfo().noquote()
<< (aligned ? "[ALIGNED] " : "[UNALIGNED]")
<< (tag.isEmpty() ? QString::fromUtf8(item->metaObject()->className())
: 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())
<< (centeredH || centeredV
? QString("\n center: x=%1 y=%2 size=%3 x %4")
.arg(QString::number((l + rt) / 2, 'f', 2))
.arg(QString::number((t + b) / 2, 'f', 2))
.arg(QString::number(rt - l, 'f', 2))
.arg(QString::number(b - t, 'f', 2))
: QString())
<< "\n size: " << fmt(item->width() * dpr) << " x "
<< fmt(item->height() * dpr) << "\n local: x=" << item->x()
<< " y=" << item->y() << " w=" << item->width()
<< " h=" << item->height() << "\n dpr=" << dpr
<< (item->window() ? "" : " (NO WINDOW, using fallback dpr)")
<< (file.isEmpty() ? QString() : "\n file: " + file);
return aligned;
}
bool ZUtils::logCurvePlacement(
QQuickItem* item,
const QList<qreal>& samples,
qreal lineWidth,
const QString& tag) {
if (!item) {
qWarning() << "logCurvePlacement: null item";
return false;
}
if (samples.size() < 4 || samples.size() % 2 != 0) {
qWarning() << "logCurvePlacement: need flat x,y pairs (>= 2 points)";
return false;
}
constexpr double tol = 1e-3;
const double dpr = dprFor(item);
const int devW = static_cast<int>(
std::max<qint64>(1, std::llround(lineWidth * dpr)));
const double half = devW / 2.0;
qsizetype offCount = 0;
qsizetype sloped = 0;
double worst = 0.0;
double worstX = 0.0;
double worstY = 0.0;
double worstEdge = 0.0;
int worstSide = 0; // -1 top edge, +1 bottom edge
for (qsizetype i = 0; i < samples.size(); i += 2) {
const double x = samples[i];
const double y = samples[i + 1];
// A sloped stroke's edge is never horizontal in device space, so
// only locally-flat samples can (and must) sit on pixel boundaries
const double yPrev = i >= 2 ? samples[i - 1] : y;
const double yNext = i + 3 < samples.size() ? samples[i + 3] : y;
if (std::abs(yNext - yPrev) > tol) {
++sloped;
continue;
}
const double yc = y * dpr;
const double top = yc - half;
const double bottom = yc + half;
for (int side = -1; side <= 1; side += 2) {
const double edge = side < 0 ? top : bottom;
const double dev = std::abs(edge - std::round(edge));
if (dev > tol) {
++offCount;
if (dev > worst) {
worst = dev;
worstX = x;
worstY = y;
worstEdge = edge;
worstSide = side;
}
}
}
}
const qsizetype total = samples.size() / 2;
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";
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) << ")";
}
return aligned;
}
QList<qreal> ZUtils::distribute(
qreal total, const QList<qreal>& preferred, QQuickItem* item) {
const qsizetype n = preferred.size();
QList<qreal> out(n, 0.0);
if (n == 0) {
return out;
}
const qreal dpr = dprFor(item);
const qint64 totalPx = std::max<qint64>(0, std::llround(total * dpr));
QList<qint64> px;
px.reserve(n);
qint64 sum = 0;
for (const qreal p : preferred) {
const qint64 v =
std::max<qint64>(0, std::llround(std::ceil(p * dpr - 1e-6)));
px.append(v);
sum += v;
}
const qint64 left = totalPx - sum;
if (left >= 0) {
// Extra space: split evenly, remainder pixels to the first items.
const qint64 base = left / n;
const qint64 rem = left % n;
for (qsizetype i = 0; i < n; ++i) {
px[i] += base + (i < rem ? 1 : 0);
}
} else if (sum > 0) {
// Not enough space: shrink proportionally, then hand out the rounding leftovers.
qint64 assigned = 0;
for (qsizetype i = 0; i < n; ++i) {
px[i] = static_cast<qint64>(std::floor(
static_cast<double>(px[i]) * static_cast<double>(totalPx) /
static_cast<double>(sum)));
assigned += px[i];
}
for (qsizetype i = 0; assigned < totalPx; i = (i + 1) % n, ++assigned) {
++px[i];
}
}
for (qsizetype i = 0; i < n; ++i) {
out[i] = static_cast<qreal>(px[i]) / dpr;
}
return out;
}
} // namespace ZShell