Files
z-bar-qt/Plugins/ZShell/zutils.cpp

765 lines
22 KiB
C++

#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>
#include <qfileinfo.h>
#include <qfuturewatcher.h>
#include <qjsprimitivevalue.h>
#include <qloggingcategory.h>
#include <qregularexpression.h>
#include <qqmlengine.h>
#include <qfile.h>
#include "util/metaenum.hpp"
Q_LOGGING_CATEGORY(lcZUtils, "ZShell.cutils", QtInfoMsg)
namespace ZShell {
void ZUtils::saveItem(QQuickItem* target, const QUrl& path) {
this->saveItem(target, path, QRect(), QJSValue(), QJSValue());
}
void ZUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect) {
this->saveItem(target, path, rect, QJSValue(), QJSValue());
}
void ZUtils::saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved) {
this->saveItem(target, path, QRect(), onSaved, QJSValue());
}
void ZUtils::saveItem(
QQuickItem* target, const QUrl& path, QJSValue onSaved, QJSValue onFailed) {
this->saveItem(target, path, QRect(), onSaved, onFailed);
}
void ZUtils::saveItem(
QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved) {
this->saveItem(target, path, rect, onSaved, QJSValue());
}
void ZUtils::saveItem(
QQuickItem* target,
const QUrl& path,
const QRect& rect,
QJSValue onSaved,
QJSValue onFailed) {
if (!target) {
qCWarning(lcZUtils) << "saveItem: a target is required";
return;
}
if (!path.isLocalFile()) {
qCWarning(lcZUtils) << "saveItem:" << path << "is not a local file";
return;
}
if (!target->window()) {
qCWarning(lcZUtils) << "saveItem: unable to save target" << target
<< "without a window";
return;
}
auto scaledRect = rect;
const qreal scale = target->window()->devicePixelRatio();
if (rect.isValid() && !qFuzzyCompare(scale + 1.0, 2.0)) {
scaledRect = QRectF(
rect.left() * scale,
rect.top() * scale,
rect.width() * scale,
rect.height() * scale)
.toRect();
}
const QSharedPointer<const QQuickItemGrabResult> grabResult =
target->grabToImage();
QObject::connect(
grabResult.data(),
&QQuickItemGrabResult::ready,
this,
[grabResult, scaledRect, path, onSaved, onFailed, this]() {
const auto future = QtConcurrent::run([=]() {
QImage image = grabResult->image();
if (scaledRect.isValid()) {
image = image.copy(scaledRect);
}
const QString file = path.toLocalFile();
const QString parent = QFileInfo(file).absolutePath();
return QDir().mkpath(parent) && image.save(file);
});
auto* watcher = new QFutureWatcher<bool>(this);
auto* engine = qmlEngine(this);
QObject::connect(
watcher, &QFutureWatcher<bool>::finished, this, [=]() {
if (watcher->result()) {
if (onSaved.isCallable()) {
QJSValueList args = {QJSValue(path.toLocalFile())};
if (engine) {
args << engine->toScriptValue(
QVariant::fromValue(path));
}
onSaved.call(args);
}
} else {
qCWarning(lcZUtils)
<< "saveItem: failed to save" << path;
if (onFailed.isCallable()) {
if (engine) {
onFailed.call({engine->toScriptValue(
QVariant::fromValue(path))});
} else {
onFailed.call();
}
}
}
watcher->deleteLater();
});
watcher->setFuture(future);
});
}
bool ZUtils::copyFile(const QUrl& source, const QUrl& target, bool overwrite) {
if (!source.isLocalFile()) {
qCWarning(lcZUtils)
<< "copyFile: source" << source << "is not a local file";
return false;
}
if (!target.isLocalFile()) {
qCWarning(lcZUtils)
<< "copyFile: target" << target << "is not a local file";
return false;
}
if (overwrite && QFile::exists(target.toLocalFile())) {
if (!QFile::remove(target.toLocalFile())) {
qCWarning(lcZUtils)
<< "copyFile: overwrite was specified but failed to remove"
<< target.toLocalFile();
return false;
}
}
return QFile::copy(source.toLocalFile(), target.toLocalFile());
}
bool ZUtils::deleteFile(const QUrl& path) {
if (!path.isLocalFile()) {
qCWarning(lcZUtils)
<< "deleteFile: path" << path << "is not a local file";
return false;
}
return QFile::remove(path.toLocalFile());
}
QString ZUtils::toLocalFile(const QUrl& url) {
if (!url.isLocalFile()) {
qCWarning(lcZUtils)
<< "toLocalFile: given url is not a local file" << url;
return QString();
}
return url.toLocalFile();
}
qreal ZUtils::clamp(qreal value, qreal min, qreal max) {
return qBound(min, value, max);
}
QString ZUtils::enumToString(
QObject* target, const QString& property, const QVariant& value) {
if (!target) {
qCWarning(lcZUtils) << "enumToString: a target is required";
return {};
}
const auto* meta = target->metaObject();
const auto index = meta->indexOfProperty(property.toUtf8().constData());
if (index < 0) {
qCWarning(lcZUtils)
<< "enumToString:" << target << "has no property" << property;
return {};
}
const auto prop = meta->property(index);
const auto metaEnum = prop.isEnumType()
? prop.enumerator()
: util::metaEnumFor(prop.metaType());
if (!metaEnum.isValid() || metaEnum.is64Bit()) {
qCWarning(lcZUtils) << "enumToString: property" << property << "of"
<< target << "is not a supported enum";
return {};
}
const auto val = value.isValid() ? value : prop.read(target);
const auto* key = util::enumKeyFor(metaEnum, val);
if (!key) {
qCWarning(
lcZUtils,
"enumToString: no enumerator of %s::%s has the value %lld",
metaEnum.scope(),
metaEnum.name(),
val.toLongLong());
return {};
}
return QString::fromUtf8(key);
}
namespace {
template <typename Predicate>
QQuickItem* findChildDfs(QQuickItem* root, Predicate&& match) {
const auto children = root->childItems();
for (QQuickItem* const child : children) {
if (match(child)) {
return child;
}
if (QQuickItem* const found = findChildDfs(child, match)) {
return found;
}
}
return nullptr;
}
template <typename Predicate>
void findChildrenDfs(
QQuickItem* root, Predicate&& match, QList<QQuickItem*>& out) {
const auto children = root->childItems();
for (QQuickItem* const child : children) {
if (match(child)) {
out.append(child);
}
findChildrenDfs(child, match, out);
}
}
} // namespace
QQuickItem* ZUtils::findChild(QQuickItem* root, const QString& name) {
if (!root) {
return nullptr;
}
return findChildDfs(root, [&name](const QQuickItem* item) {
return item->objectName() == name;
});
}
QList<QQuickItem*> ZUtils::findChildren(QQuickItem* root, const QString& name) {
QList<QQuickItem*> children;
if (root) {
findChildrenDfs(
root,
[&name](const QQuickItem* item) {
return item->objectName() == name;
},
children);
}
return children;
}
QList<QQuickItem*> ZUtils::findChildrenMatching(
QQuickItem* root, const QString& pattern) {
QList<QQuickItem*> children;
if (root) {
const QRegularExpression re(pattern);
findChildrenDfs(
root,
[&re](const QQuickItem* item) {
return re.match(item->objectName()).hasMatch();
},
children);
}
return children;
}
#ifndef ZSHELL_VERSION
#define ZSHELL_VERSION ""
#endif
QString ZUtils::version() const {
return QStringLiteral(ZSHELL_VERSION);
}
QString ZUtils::qtVersion() const {
return QStringLiteral(QT_VERSION_STR);
}
QString ZUtils::gitRevision() {
#ifdef GIT_REVISION
return QStringLiteral(GIT_REVISION);
#else
return QString();
#endif
}
QString ZUtils::readTextFile(const QString& path) {
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return QString();
}
return QString::fromUtf8(file.readAll());
}
bool ZUtils::writeTextFile(const QString& path, const QString& text) {
const QFileInfo info(path);
if (!QDir().mkpath(info.absolutePath())) {
qCWarning(lcZUtils) << "Failed to create directory for" << path;
return false;
}
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
qCWarning(lcZUtils) << "Failed to open" << path << "for writing";
return false;
}
return file.write(text.toUtf8()) >= 0;
}
QStringList ZUtils::listFiles(const QString& dir, const QString& suffix) {
QStringList out;
QDirIterator it(dir, QDirIterator::Subdirectories);
while (it.hasNext()) {
const QString path = it.next();
if (it.fileInfo().isFile() && path.endsWith(suffix)) {
out.append(path);
}
}
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