1
0
Fork 0

core/scriptmodel: add expression model for unique lists

This commit is contained in:
outfoxxed 2024-12-27 02:57:36 -08:00
parent 2f194b7894
commit 08836ca1f3
Signed by: outfoxxed
GPG key ID: 4C88A185FB89301E
8 changed files with 431 additions and 2 deletions

View file

@ -18,6 +18,7 @@ Checks: >
-cppcoreguidelines-pro-bounds-array-to-pointer-decay, -cppcoreguidelines-pro-bounds-array-to-pointer-decay,
-cppcoreguidelines-avoid-do-while, -cppcoreguidelines-avoid-do-while,
-cppcoreguidelines-pro-type-reinterpret-cast, -cppcoreguidelines-pro-type-reinterpret-cast,
-cppcoreguidelines-pro-type-vararg,
google-global-names-in-headers, google-global-names-in-headers,
google-readability-casting, google-readability-casting,
google-runtime-int, google-runtime-int,

View file

@ -36,6 +36,7 @@ qt_add_library(quickshell-core STATIC
instanceinfo.cpp instanceinfo.cpp
common.cpp common.cpp
iconprovider.cpp iconprovider.cpp
scriptmodel.cpp
) )
qt_add_qml_module(quickshell-core qt_add_qml_module(quickshell-core

View file

@ -2,6 +2,7 @@
#include <qabstractitemmodel.h> #include <qabstractitemmodel.h>
#include <qhash.h> #include <qhash.h>
#include <qnamespace.h>
#include <qobject.h> #include <qobject.h>
#include <qqmllist.h> #include <qqmllist.h>
#include <qtmetamacros.h> #include <qtmetamacros.h>
@ -14,11 +15,13 @@ qint32 UntypedObjectModel::rowCount(const QModelIndex& parent) const {
} }
QVariant UntypedObjectModel::data(const QModelIndex& index, qint32 role) 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())); 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() { QQmlListProperty<QObject> UntypedObjectModel::values() {
return QQmlListProperty<QObject>( return QQmlListProperty<QObject>(

133
src/core/scriptmodel.cpp Normal file
View file

@ -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"}}; }

74
src/core/scriptmodel.hpp Normal file
View file

@ -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);
};

View file

@ -6,3 +6,4 @@ endfunction()
qs_test(transformwatcher transformwatcher.cpp) qs_test(transformwatcher transformwatcher.cpp)
qs_test(ringbuffer ringbuf.cpp) qs_test(ringbuffer ringbuf.cpp)
qs_test(scriptmodel scriptmodel.cpp)

View file

@ -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);

View file

@ -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();
};