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