feat: initial commit for pixel-snapping components and helpers
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user