From 08836ca1f3af748d38152e79c544b77dc5e4b3e9 Mon Sep 17 00:00:00 2001 From: outfoxxed <outfoxxed@outfoxxed.me> Date: Fri, 27 Dec 2024 02:57:36 -0800 Subject: [PATCH] core/scriptmodel: add expression model for unique lists --- .clang-tidy | 1 + src/core/CMakeLists.txt | 1 + src/core/model.cpp | 7 +- src/core/scriptmodel.cpp | 133 +++++++++++++++++++++++++ src/core/scriptmodel.hpp | 74 ++++++++++++++ src/core/test/CMakeLists.txt | 1 + src/core/test/scriptmodel.cpp | 179 ++++++++++++++++++++++++++++++++++ src/core/test/scriptmodel.hpp | 37 +++++++ 8 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 src/core/scriptmodel.cpp create mode 100644 src/core/scriptmodel.hpp create mode 100644 src/core/test/scriptmodel.cpp create mode 100644 src/core/test/scriptmodel.hpp diff --git a/.clang-tidy b/.clang-tidy index e26363b8..ca6c9549 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -18,6 +18,7 @@ Checks: > -cppcoreguidelines-pro-bounds-array-to-pointer-decay, -cppcoreguidelines-avoid-do-while, -cppcoreguidelines-pro-type-reinterpret-cast, + -cppcoreguidelines-pro-type-vararg, google-global-names-in-headers, google-readability-casting, google-runtime-int, diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index c75dd588..6778e984 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -36,6 +36,7 @@ qt_add_library(quickshell-core STATIC instanceinfo.cpp common.cpp iconprovider.cpp + scriptmodel.cpp ) qt_add_qml_module(quickshell-core diff --git a/src/core/model.cpp b/src/core/model.cpp index 30ded8f0..2aba1846 100644 --- a/src/core/model.cpp +++ b/src/core/model.cpp @@ -2,6 +2,7 @@ #include <qabstractitemmodel.h> #include <qhash.h> +#include <qnamespace.h> #include <qobject.h> #include <qqmllist.h> #include <qtmetamacros.h> @@ -14,11 +15,13 @@ qint32 UntypedObjectModel::rowCount(const QModelIndex& parent) const { } QVariant UntypedObjectModel::data(const QModelIndex& index, qint32 role) const { - if (role != 0) return QVariant(); + if (role != Qt::UserRole) return QVariant(); return QVariant::fromValue(this->valuesList.at(index.row())); } -QHash<int, QByteArray> UntypedObjectModel::roleNames() const { return {{0, "modelData"}}; } +QHash<int, QByteArray> UntypedObjectModel::roleNames() const { + return {{Qt::UserRole, "modelData"}}; +} QQmlListProperty<QObject> UntypedObjectModel::values() { return QQmlListProperty<QObject>( diff --git a/src/core/scriptmodel.cpp b/src/core/scriptmodel.cpp new file mode 100644 index 00000000..259587c1 --- /dev/null +++ b/src/core/scriptmodel.cpp @@ -0,0 +1,133 @@ +#include "scriptmodel.hpp" +#include <algorithm> +#include <iterator> + +#include <qabstractitemmodel.h> +#include <qcontainerfwd.h> +#include <qlist.h> +#include <qnamespace.h> +#include <qtmetamacros.h> +#include <qtversionchecks.h> +#include <qtypes.h> +#include <qvariant.h> + +void ScriptModel::updateValuesUnique(const QVariantList& newValues) { + this->mValues.reserve(newValues.size()); + + auto iter = this->mValues.begin(); + auto newIter = newValues.begin(); + + while (true) { + if (newIter == newValues.end()) { + if (iter == this->mValues.end()) break; + + auto startIndex = static_cast<qint32>(newValues.length()); + auto endIndex = static_cast<qint32>(this->mValues.length() - 1); + + this->beginRemoveRows(QModelIndex(), startIndex, endIndex); + this->mValues.erase(iter, this->mValues.end()); + this->endRemoveRows(); + + break; + } else if (iter == this->mValues.end()) { + // Prior branch ensures length is at least 1. + auto startIndex = static_cast<qint32>(this->mValues.length()); + auto endIndex = static_cast<qint32>(newValues.length() - 1); + + this->beginInsertRows(QModelIndex(), startIndex, endIndex); + this->mValues.append(newValues.sliced(startIndex)); + this->endInsertRows(); + + break; + } else if (*newIter != *iter) { + auto oldIter = std::find(iter, this->mValues.end(), *newIter); + + if (oldIter != this->mValues.end()) { + if (std::find(newIter, newValues.end(), *iter) == newValues.end()) { + // Remove any entries we would otherwise move around that aren't in the new list. + auto startIter = iter; + + do { + ++iter; + } while (iter != this->mValues.end() + && std::find(newIter, newValues.end(), *iter) == newValues.end()); + + auto index = static_cast<qint32>(std::distance(this->mValues.begin(), iter)); + auto startIndex = static_cast<qint32>(std::distance(this->mValues.begin(), startIter)); + + this->beginRemoveRows(QModelIndex(), startIndex, index - 1); + iter = this->mValues.erase(startIter, iter); + this->endRemoveRows(); + } else { + // Advance iters to capture a whole move sequence as a single operation if possible. + auto oldStartIter = oldIter; + do { + ++oldIter; + ++newIter; + } while (oldIter != this->mValues.end() && newIter != newValues.end() + && *oldIter == *newIter); + + auto index = static_cast<qint32>(std::distance(this->mValues.begin(), iter)); + auto oldStartIndex = + static_cast<qint32>(std::distance(this->mValues.begin(), oldStartIter)); + auto oldIndex = static_cast<qint32>(std::distance(this->mValues.begin(), oldIter)); + auto len = oldIndex - oldStartIndex; + + this->beginMoveRows(QModelIndex(), oldStartIndex, oldIndex - 1, QModelIndex(), index); + + // While it is possible to optimize this further, it is currently not worth the time. + for (auto i = 0; i != len; i++) { + this->mValues.move(oldStartIndex + i, index + i); + } + + iter = this->mValues.begin() + (index + len); + this->endMoveRows(); + } + } else { + auto startNewIter = newIter; + + do { + newIter++; + } while (newIter != newValues.end() + && std::find(iter, this->mValues.end(), *newIter) == this->mValues.end()); + + auto index = static_cast<qint32>(std::distance(this->mValues.begin(), iter)); + auto newIndex = static_cast<qint32>(std::distance(newValues.begin(), newIter)); + auto startNewIndex = static_cast<qint32>(std::distance(newValues.begin(), startNewIter)); + auto len = newIndex - startNewIndex; + + this->beginInsertRows(QModelIndex(), index, index + len - 1); +#if QT_VERSION <= QT_VERSION_CHECK(6, 8, 0) + this->mValues.resize(this->mValues.length() + len); +#else + this->mValues.resizeForOverwrite(this->mValues.length() + len); +#endif + iter = this->mValues.begin() + index; // invalidated + std::move_backward(iter, this->mValues.end() - len, this->mValues.end()); + iter = std::copy(startNewIter, newIter, iter); + this->endInsertRows(); + } + } else { + ++iter; + ++newIter; + } + } +} + +void ScriptModel::setValues(const QVariantList& newValues) { + if (newValues == this->mValues) return; + this->updateValuesUnique(newValues); + emit this->valuesChanged(); +} + +qint32 ScriptModel::rowCount(const QModelIndex& parent) const { + if (parent != QModelIndex()) return 0; + return static_cast<qint32>(this->mValues.length()); +} + +QVariant ScriptModel::data(const QModelIndex& index, qint32 role) const { + if (role != Qt::UserRole) return QVariant(); + return this->mValues.at(index.row()); +} + +QHash<int, QByteArray> ScriptModel::roleNames() const { return {{Qt::UserRole, "modelData"}}; } diff --git a/src/core/scriptmodel.hpp b/src/core/scriptmodel.hpp new file mode 100644 index 00000000..b57456b3 --- /dev/null +++ b/src/core/scriptmodel.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include <qabstractitemmodel.h> +#include <qcontainerfwd.h> +#include <qproperty.h> +#include <qqmlintegration.h> +#include <qtmetamacros.h> + +///! QML model reflecting a javascript expression +/// ScriptModel is a QML [Data Model] that generates model operations based on changes +/// to a javascript expression attached to @@values. +/// +/// ### When should I use this +/// ScriptModel should be used when you would otherwise use a javascript expression as a model, +/// [QAbstractItemModel] is accepted, and the data is likely to change over the lifetime of the program. +/// +/// When directly using a javascript expression as a model, types like @@QtQuick.Repeater or @@QtQuick.ListView +/// will destroy all created delegates, and re-create the entire list. In the case of @@QtQuick.ListView this +/// will also prevent animations from working. If you wrap your expression with ScriptModel, only new items +/// will be created, and ListView animations will work as expected. +/// +/// ### Example +/// ```qml +/// // Will cause all delegates to be re-created every time filterText changes. +/// @@QtQuick.Repeater { +/// model: myList.filter(entry => entry.name.startsWith(filterText)) +/// delegate: // ... +/// } +/// +/// // Will add and remove delegates only when required. +/// @@QtQuick.Repeater { +/// model: ScriptModel { +/// values: myList.filter(entry => entry.name.startsWith(filterText)) +/// } +/// +/// delegate: // ... +/// } +/// ``` +class ScriptModel: public QAbstractListModel { + Q_OBJECT; + /// The list of values to reflect in the model. + /// > [!WARNING] ScriptModel currently only works with lists of *unique* values. + /// > There must not be any duplicates in the given list, or behavior of the model is undefined. + /// + /// > [!TIP] @@ObjectModel$s supplied by Quickshell types will only contain unique values, + /// > and can be used like so: + /// > + /// > ```qml + /// > ScriptModel { + /// > values: DesktopEntries.applications.values.filter(...) + /// > } + /// > ``` + /// > + /// > Note that we are using @@DesktopEntries.values because it will cause @@ScriptModel.values + /// > to receive an update on change. + Q_PROPERTY(QVariantList values READ values WRITE setValues NOTIFY valuesChanged); + QML_ELEMENT; + +public: + [[nodiscard]] const QVariantList& values() const { return this->mValues; } + void setValues(const QVariantList& newValues); + + [[nodiscard]] qint32 rowCount(const QModelIndex& parent) const override; + [[nodiscard]] QVariant data(const QModelIndex& index, qint32 role) const override; + [[nodiscard]] QHash<int, QByteArray> roleNames() const override; + +signals: + void valuesChanged(); + +private: + QVariantList mValues; + + void updateValuesUnique(const QVariantList& newValues); +}; diff --git a/src/core/test/CMakeLists.txt b/src/core/test/CMakeLists.txt index c9a82005..d38c2868 100644 --- a/src/core/test/CMakeLists.txt +++ b/src/core/test/CMakeLists.txt @@ -6,3 +6,4 @@ endfunction() qs_test(transformwatcher transformwatcher.cpp) qs_test(ringbuffer ringbuf.cpp) +qs_test(scriptmodel scriptmodel.cpp) diff --git a/src/core/test/scriptmodel.cpp b/src/core/test/scriptmodel.cpp new file mode 100644 index 00000000..bdf9c709 --- /dev/null +++ b/src/core/test/scriptmodel.cpp @@ -0,0 +1,179 @@ +#include "scriptmodel.hpp" + +#include <qabstractitemmodel.h> +#include <qabstractitemmodeltester.h> +#include <qcontainerfwd.h> +#include <qdebug.h> +#include <qlist.h> +#include <qlogging.h> +#include <qobject.h> +#include <qsignalspy.h> +#include <qstring.h> +#include <qtest.h> +#include <qtestcase.h> +#include <qtypes.h> + +#include "../scriptmodel.hpp" + +using OpList = QList<ModelOperation>; + +bool ModelOperation::operator==(const ModelOperation& other) const { + return other.operation == this->operation && other.index == this->index + && other.length == this->length && other.destIndex == this->destIndex; +} + +QDebug& operator<<(QDebug& debug, const ModelOperation& op) { + auto saver = QDebugStateSaver(debug); + debug.nospace(); + + switch (op.operation) { + case ModelOperation::Insert: debug << "Insert"; break; + case ModelOperation::Remove: debug << "Remove"; break; + case ModelOperation::Move: debug << "Move"; break; + } + + debug << "(i: " << op.index << ", l: " << op.length; + + if (op.destIndex != -1) { + debug << ", d: " << op.destIndex; + } + + debug << ')'; + + return debug; +} + +QDebug& operator<<(QDebug& debug, const QVariantList& list) { + auto str = QString(); + + for (const auto& var: list) { + if (var.canConvert<QChar>()) { + str += var.value<QChar>(); + } else { + qFatal() << "QVariantList debug overridden in test"; + } + } + + debug << str; + return debug; +} + +void TestScriptModel::unique_data() { + QTest::addColumn<QString>("oldstr"); + QTest::addColumn<QString>("newstr"); + QTest::addColumn<OpList>("operations"); + + QTest::addRow("append") << "ABCD" << "ABCDEFG" << OpList({{ModelOperation::Insert, 4, 3}}); + + QTest::addRow("prepend") << "EFG" << "ABCDEFG" << OpList({{ModelOperation::Insert, 0, 4}}); + + QTest::addRow("insert") << "ABFG" << "ABCDEFG" << OpList({{ModelOperation::Insert, 2, 3}}); + + QTest::addRow("chop") << "ABCDEFG" << "ABCD" << OpList({{ModelOperation::Remove, 4, 3}}); + + QTest::addRow("slice") << "ABCDEFG" << "DEFG" << OpList({{ModelOperation::Remove, 0, 3}}); + + QTest::addRow("remove_mid") << "ABCDEFG" << "ABFG" << OpList({{ModelOperation::Remove, 2, 3}}); + + QTest::addRow("move_single") << "ABCDEFG" << "AFBCDEG" + << OpList({{ModelOperation::Move, 5, 1, 1}}); + + QTest::addRow("move_range") << "ABCDEFG" << "ADEFBCG" + << OpList({{ModelOperation::Move, 3, 3, 1}}); + + // beginning to end is the same operation + QTest::addRow("move_end_to_beginning") + << "ABCDEFG" << "EFGABCD" << OpList({{ModelOperation::Move, 4, 3, 0}}); + + QTest::addRow("move_overlapping") + << "ABCDEFG" << "ABDEFCG" << OpList({{ModelOperation::Move, 3, 3, 2}}); + + // Ensure iterators arent skipping anything at the end of operations by performing + // multiple back to back. + + QTest::addRow("insert_state_ok") << "ABCDEFG" << "ABXXEFG" + << OpList({ + {ModelOperation::Insert, 2, 2}, // ABXXCDEFG + {ModelOperation::Remove, 4, 2}, // ABXXEFG + }); + + QTest::addRow("remove_state_ok") << "ABCDEFG" << "ABFGE" + << OpList({ + {ModelOperation::Remove, 2, 2}, // ABEFG + {ModelOperation::Move, 3, 2, 2}, // ABFGE + }); + + QTest::addRow("move_state_ok") << "ABCDEFG" << "ABEFXYCDG" + << OpList({ + {ModelOperation::Move, 4, 2, 2}, // ABEFCDG + {ModelOperation::Insert, 4, 2}, // ABEFXYCDG + }); +} + +void TestScriptModel::unique() { + QFETCH(const QString, oldstr); + QFETCH(const QString, newstr); + QFETCH(OpList, operations); + + auto strToVariantList = [](const QString& str) -> QVariantList { + QVariantList list; + + for (auto c: str) { + list.emplace_back(c); + } + + return list; + }; + + auto oldlist = strToVariantList(oldstr); + auto newlist = strToVariantList(newstr); + + auto model = ScriptModel(); + auto modelTester = QAbstractItemModelTester(&model); + + OpList actualOperations; + + auto onInsert = [&](const QModelIndex& parent, int first, int last) { + QCOMPARE(parent, QModelIndex()); + actualOperations << ModelOperation(ModelOperation::Insert, first, last - first + 1); + }; + + auto onRemove = [&](const QModelIndex& parent, int first, int last) { + QCOMPARE(parent, QModelIndex()); + actualOperations << ModelOperation(ModelOperation::Remove, first, last - first + 1); + }; + + auto onMove = [&](const QModelIndex& sourceParent, + int sourceStart, + int sourceEnd, + const QModelIndex& destParent, + int destStart) { + QCOMPARE(sourceParent, QModelIndex()); + QCOMPARE(destParent, QModelIndex()); + actualOperations << ModelOperation( + ModelOperation::Move, + sourceStart, + sourceEnd - sourceStart + 1, + destStart + ); + }; + + QObject::connect(&model, &QAbstractItemModel::rowsInserted, &model, onInsert); + QObject::connect(&model, &QAbstractItemModel::rowsRemoved, &model, onRemove); + QObject::connect(&model, &QAbstractItemModel::rowsMoved, &model, onMove); + + model.setValues(oldlist); + QCOMPARE_EQ(model.values(), oldlist); + QCOMPARE_EQ( + actualOperations, + OpList({{ModelOperation::Insert, 0, static_cast<qint32>(oldlist.length())}}) + ); + + actualOperations.clear(); + + model.setValues(newlist); + QCOMPARE_EQ(model.values(), newlist); + QCOMPARE_EQ(actualOperations, operations); +} + +QTEST_MAIN(TestScriptModel); diff --git a/src/core/test/scriptmodel.hpp b/src/core/test/scriptmodel.hpp new file mode 100644 index 00000000..3b50b328 --- /dev/null +++ b/src/core/test/scriptmodel.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include <qdebug.h> +#include <qobject.h> +#include <qtmetamacros.h> +#include <qtypes.h> + +struct ModelOperation { + enum Enum : quint8 { + Insert, + Remove, + Move, + }; + + ModelOperation(Enum operation, qint32 index, qint32 length, qint32 destIndex = -1) + : operation(operation) + , index(index) + , length(length) + , destIndex(destIndex) {} + + Enum operation; + qint32 index = 0; + qint32 length = 0; + qint32 destIndex = -1; + + [[nodiscard]] bool operator==(const ModelOperation& other) const; +}; + +QDebug& operator<<(QDebug& debug, const ModelOperation& op); + +class TestScriptModel: public QObject { + Q_OBJECT; + +private slots: + static void unique_data(); // NOLINT + static void unique(); +};