forked from quickshell/quickshell
core/scriptmodel: add expression model for unique lists
This commit is contained in:
parent
2f194b7894
commit
08836ca1f3
|
@ -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,
|
||||
|
|
|
@ -36,6 +36,7 @@ qt_add_library(quickshell-core STATIC
|
|||
instanceinfo.cpp
|
||||
common.cpp
|
||||
iconprovider.cpp
|
||||
scriptmodel.cpp
|
||||
)
|
||||
|
||||
qt_add_qml_module(quickshell-core
|
||||
|
|
|
@ -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>(
|
||||
|
|
133
src/core/scriptmodel.cpp
Normal file
133
src/core/scriptmodel.cpp
Normal 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
74
src/core/scriptmodel.hpp
Normal 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);
|
||||
};
|
|
@ -6,3 +6,4 @@ endfunction()
|
|||
|
||||
qs_test(transformwatcher transformwatcher.cpp)
|
||||
qs_test(ringbuffer ringbuf.cpp)
|
||||
qs_test(scriptmodel scriptmodel.cpp)
|
||||
|
|
179
src/core/test/scriptmodel.cpp
Normal file
179
src/core/test/scriptmodel.cpp
Normal 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);
|
37
src/core/test/scriptmodel.hpp
Normal file
37
src/core/test/scriptmodel.hpp
Normal 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();
|
||||
};
|
Loading…
Reference in a new issue