diff --git a/src/GUI/CMakeLists.txt b/src/GUI/CMakeLists.txt
index 89720fcdf..038d1d7a6 100644
--- a/src/GUI/CMakeLists.txt
+++ b/src/GUI/CMakeLists.txt
@@ -158,6 +158,8 @@ if (HAVE_QT)
                         ThumbnailImageItem.hxx
                         PopupWindowTracker.cxx
                         PopupWindowTracker.hxx
+                        QmlPropertyModel.hxx
+                        QmlPropertyModel.cxx
                         QmlPositioned.hxx
                         QmlPositioned.cxx
                         QmlNavCacheWrapper.hxx
diff --git a/src/GUI/FGQmlPropertyNode.cxx b/src/GUI/FGQmlPropertyNode.cxx
index eae1f7f51..5a07e6c89 100644
--- a/src/GUI/FGQmlPropertyNode.cxx
+++ b/src/GUI/FGQmlPropertyNode.cxx
@@ -1,4 +1,6 @@
-// Copyright (C) 2020  James Turner  <james@flightgear.org>
+// FGQmlPropertyNode.cxx - expose SGPropertyNode to QML
+//
+// Copyright (C) 2019  James Turner  <james@flightgear.org>
 //
 // This program is free software; you can redistribute it and/or
 // modify it under the terms of the GNU General Public License as
@@ -74,32 +76,7 @@ bool FGQmlPropertyNode::set(QVariant newValue)
 
 QVariant FGQmlPropertyNode::value() const
 {
-    if (!_prop)
-        return {};
-
-    switch (_prop->getType()) {
-    case simgear::props::INT:
-    case simgear::props::LONG:
-        return _prop->getIntValue();
-    case simgear::props::BOOL:      return _prop->getBoolValue();
-    case simgear::props::DOUBLE:    return _prop->getDoubleValue();
-    case simgear::props::FLOAT:     return _prop->getFloatValue();
-    case simgear::props::STRING:    return QString::fromStdString(_prop->getStringValue());
-
-    case simgear::props::VEC3D: {
-         const SGVec3d v3 = _prop->getValue<SGVec3d>();
-         return QVariant::fromValue(QVector3D(v3.x(), v3.y(), v3.z()));
-    }
-
-    case simgear::props::VEC4D: {
-         const SGVec4d v4 = _prop->getValue<SGVec4d>();
-         return QVariant::fromValue(QVector4D(v4.x(), v4.y(), v4.z(), v4.w()));
-    }
-    default:
-        break;
-    }
-
-    return {}; // null qvariant
+    return propertyValueAsVariant(_prop);
 }
 
 QString FGQmlPropertyNode::path() const
@@ -146,6 +123,36 @@ SGPropertyNode_ptr FGQmlPropertyNode::node() const
     return _prop;
 }
 
+QVariant FGQmlPropertyNode::propertyValueAsVariant(SGPropertyNode* p)
+{
+    if (!p)
+        return {};
+
+    switch (p->getType()) {
+    case simgear::props::INT:
+    case simgear::props::LONG:
+        return p->getIntValue();
+    case simgear::props::BOOL: return p->getBoolValue();
+    case simgear::props::DOUBLE: return p->getDoubleValue();
+    case simgear::props::FLOAT: return p->getFloatValue();
+    case simgear::props::STRING: return QString::fromStdString(p->getStringValue());
+
+    case simgear::props::VEC3D: {
+        const SGVec3d v3 = p->getValue<SGVec3d>();
+        return QVariant::fromValue(QVector3D(v3.x(), v3.y(), v3.z()));
+    }
+
+    case simgear::props::VEC4D: {
+        const SGVec4d v4 = p->getValue<SGVec4d>();
+        return QVariant::fromValue(QVector4D(v4.x(), v4.y(), v4.z(), v4.w()));
+    }
+    default:
+        break;
+    }
+
+    return {}; // null qvariant
+}
+
 void FGQmlPropertyNode::valueChanged(SGPropertyNode *node)
 {
     if (node != _prop) {
diff --git a/src/GUI/FGQmlPropertyNode.hxx b/src/GUI/FGQmlPropertyNode.hxx
index 5f7877eed..ebdbe22ec 100644
--- a/src/GUI/FGQmlPropertyNode.hxx
+++ b/src/GUI/FGQmlPropertyNode.hxx
@@ -1,4 +1,6 @@
-// Copyright (C) 2020  James Turner  <james@flightgear.org>
+// FGQmlPropertyNode.hxx - expose SGPropertyNode to QML
+//
+// Copyright (C) 2019  James Turner  <james@flightgear.org>
 //
 // This program is free software; you can redistribute it and/or
 // modify it under the terms of the GNU General Public License as
@@ -65,6 +67,8 @@ public:
     int childCount() const;
     FGQmlPropertyNode* childAt(int index) const;
 
+    static QVariant propertyValueAsVariant(SGPropertyNode* p);
+
 protected:
     // SGPropertyChangeListener API
 
diff --git a/src/GUI/QmlPropertyModel.cxx b/src/GUI/QmlPropertyModel.cxx
new file mode 100644
index 000000000..846934536
--- /dev/null
+++ b/src/GUI/QmlPropertyModel.cxx
@@ -0,0 +1,240 @@
+// Copyright (C) 2020  James Turner  <james@flightgear.org>
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License as
+// published by the Free Software Foundation; either version 2 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+#include "config.h"
+
+#include "QmlPropertyModel.hxx"
+
+#include <algorithm>
+
+#include <QDebug>
+#include <QVector>
+
+#include <GUI/FGQmlPropertyNode.hxx>
+#include <GUI/QmlPropertyModel.hxx>
+#include <Main/fg_props.hxx>
+#include <simgear/props/props.hxx>
+
+class FGQmlPropertyModel::PropertyModelPrivate : public SGPropertyChangeListener
+{
+public:
+    void clear()
+    {
+        if (_props) {
+            _props->removeChangeListener(this);
+            _props.clear();
+            _roles.clear();
+        }
+    }
+
+    void computeProps()
+    {
+        _props = fgGetNode(_rootPath.toStdString());
+        if (!_props) {
+            qWarning() << "Passed non-existant path to QmlPropertyModel:" << _rootPath;
+        }
+
+        _props->addChangeListener(this);
+
+        if (_childName.isEmpty()) {
+            // all children, no filtering by name
+            const auto sz = _props->nChildren();
+            _directChildren.resize(sz);
+            for (auto i = 0; i < sz; ++i) {
+                _directChildren[i] = _props->getChild(i);
+            }
+        } else {
+            _directChildren = _props->getChildren(_childName.toStdString());
+        }
+
+        cacheRoleNames();
+    }
+
+    void cacheRoleNames()
+    {
+        auto oldRoles = _roles;
+        _roles.clear();
+        for (auto p : _directChildren) {
+            const auto nc = p->nChildren();
+            for (int i = 0; i < nc; ++i) {
+                roleForNode(p->getChild(i)->getNameString());
+            }
+        }
+
+        if (_roles != oldRoles) {
+            // we need to model-reset to tell QML about new names
+            p->beginResetModel();
+            p->endResetModel();
+        }
+    }
+
+    int roleForNode(const std::string& s)
+    {
+        auto it = std::find(_roles.begin(), _roles.end(), s);
+        if (it != _roles.end()) {
+            return static_cast<int>(std::distance(_roles.begin(), it)) + Qt::UserRole;
+        }
+
+        qDebug() << Q_FUNC_INFO << "adding" << QString::fromStdString(s);
+        _roles.push_back(s);
+        return Qt::UserRole + static_cast<int>(_roles.size() - 1);
+    }
+
+    void valueChanged(SGPropertyNode* node) override
+    {
+        auto it = std::find(_directChildren.begin(), _directChildren.end(), node);
+        if (it == _directChildren.end())
+            return;
+
+        doDataChanged(it, node);
+    }
+
+    void doDataChanged(const simgear::PropertyList::iterator& it, SGPropertyNode* node)
+    {
+        QVector<int> roles;
+        roles.append(roleForNode(node->getNameString()));
+        QModelIndex m = p->index(std::distance(_directChildren.begin(), it));
+        p->dataChanged(m, m, roles);
+    }
+
+    void childAdded(SGPropertyNode* parent, SGPropertyNode* child) override
+    {
+        if (parent == _props) {
+            if (!_childName.isEmpty() && (child->getNameString() != _childName.toStdString())) {
+                // doesn't pass name filter, don't care
+                return;
+            }
+
+            int insertRow = child->getIndex();
+            if (!_childName.isEmpty()) {
+                // always an append
+                insertRow = _directChildren.size();
+            }
+
+            p->beginInsertRows(QModelIndex{}, insertRow, insertRow);
+            _directChildren.push_back(child);
+            p->endInsertRows();
+            return;
+        }
+
+        auto it = std::find(_directChildren.begin(), _directChildren.end(), parent);
+        if (it == _directChildren.end())
+            return;
+
+        doDataChanged(it, child);
+    }
+
+    void childRemoved(SGPropertyNode* parent, SGPropertyNode* child) override
+    {
+        if (parent == _props) {
+            if (!_childName.isEmpty() && (child->getNameString() != _childName.toStdString())) {
+                // doesn't pass name filter, don't care
+                return;
+            }
+
+            auto it = std::find(_directChildren.begin(), _directChildren.end(), child);
+            if (it == _directChildren.end()) {
+                SG_LOG(SG_GUI, SG_DEV_ALERT, "Bug in QmlPropertyModel - child not found when removing:" << parent->getPath() << " - " << child->getName());
+                return;
+            }
+
+            int row = static_cast<int>(std::distance(_directChildren.begin(), it));
+            p->beginRemoveRows(QModelIndex{}, row, row);
+            _directChildren.erase(it);
+            p->endInsertRows();
+            return;
+        }
+
+        auto it = std::find(_directChildren.begin(), _directChildren.end(), parent);
+        if (it == _directChildren.end())
+            return;
+
+        // actually the value will be null now
+        doDataChanged(it, child);
+    }
+
+    QString _rootPath;
+    QString _childName;
+
+    FGQmlPropertyModel* p;
+    SGPropertyNode_ptr _props;
+    simgear::PropertyList _directChildren;
+    std::vector<std::string> _roles;
+};
+
+FGQmlPropertyModel::~FGQmlPropertyModel()
+{
+    d->clear();
+}
+
+QString FGQmlPropertyModel::rootPath() const
+{
+    return d->_rootPath;
+}
+
+QString FGQmlPropertyModel::childName() const
+{
+    return d->_childName;
+}
+
+QHash<int, QByteArray> FGQmlPropertyModel::roleNames() const
+{
+    QHash<int, QByteArray> r;
+    for (int i = 0; i < d->_roles.size(); ++i) {
+        r[i] = QByteArray::fromStdString(d->_roles.at(i));
+    }
+    return r;
+}
+
+QVariant FGQmlPropertyModel::data(const QModelIndex& m, int role) const
+{
+    auto node = d->_directChildren.at(m.row());
+    const int r = role - Qt::UserRole;
+    assert(r < d->_roles.size());
+    const auto& propName = d->_roles.at(r);
+    const auto prop = node->getChild(propName);
+    if (!prop)
+        return {}; // no data for role
+    return FGQmlPropertyNode::propertyValueAsVariant(prop);
+}
+
+void FGQmlPropertyModel::setRootPath(QString rootPath)
+{
+    if (d->_rootPath == rootPath)
+        return;
+
+    beginResetModel();
+    d->_rootPath = rootPath;
+    d->clear();
+    d->computeProps();
+    endResetModel();
+
+    emit rootPathChanged(d->_rootPath);
+}
+
+void FGQmlPropertyModel::setChildName(QString childName)
+{
+    if (d->_childName == childName)
+        return;
+
+    beginResetModel();
+    d->_childName = childName;
+    d->clear();
+    d->computeProps();
+    endResetModel();
+
+    emit childNameChanged(d->_childName);
+}
diff --git a/src/GUI/QmlPropertyModel.hxx b/src/GUI/QmlPropertyModel.hxx
new file mode 100644
index 000000000..3bb4652f5
--- /dev/null
+++ b/src/GUI/QmlPropertyModel.hxx
@@ -0,0 +1,55 @@
+// Copyright (C) 2020  James Turner  <james@flightgear.org>
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License as
+// published by the Free Software Foundation; either version 2 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+#ifndef QmlPropertyModel_hpp
+#define QmlPropertyModel_hpp
+
+#include <QAbstractListModel>
+#include <memory>
+
+class FGQmlPropertyModel : public QAbstractListModel
+{
+    Q_OBJECT
+
+    Q_PROPERTY(QString rootPath READ rootPath WRITE setRootPath NOTIFY rootPathChanged);
+    Q_PROPERTY(QString childName READ childName WRITE setChildName NOTIFY childNameChanged);
+
+public:
+    FGQmlPropertyModel(QObject* parent = nullptr);
+    ~FGQmlPropertyModel() override;
+    QString rootPath() const;
+
+    QString childName() const;
+
+    QHash<int, QByteArray> roleNames() const override;
+
+    QVariant data(const QModelIndex& m, int role) const override;
+public slots:
+    void setRootPath(QString rootPath);
+
+    void setChildName(QString childName);
+
+signals:
+    void rootPathChanged(QString rootPath);
+
+    void childNameChanged(QString childName);
+
+private:
+    class PropertyModelPrivate;
+    std::unique_ptr<PropertyModelPrivate> d;
+};
+
+#endif /* QmlPropertyModel_hpp */