From 67863fcade3b0a3e58bb0cd0dd3a79260a038a32 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:01:00 +0700 Subject: [PATCH 1/6] feat(workbench): canonical bounded inline hierarchy projection Add virtualized Qt detail model with bounded lazy expansion, selection, current-value updates, and large-model regression gates. No extra semantic tree, implicit MMS read, or protocol-runtime changes. --- apps/ied_simulator/src/MmsLiveTreeModel.cpp | 159 +++++++++++++++++- apps/ied_simulator/src/MmsLiveTreeModel.hpp | 53 ++++++ .../src/ied_browser_large_model_qa.cpp | 25 ++- 3 files changed, 235 insertions(+), 2 deletions(-) diff --git a/apps/ied_simulator/src/MmsLiveTreeModel.cpp b/apps/ied_simulator/src/MmsLiveTreeModel.cpp index beb0491bb..da05000dd 100644 --- a/apps/ied_simulator/src/MmsLiveTreeModel.cpp +++ b/apps/ied_simulator/src/MmsLiveTreeModel.cpp @@ -35,7 +35,9 @@ QString logicalNodeDescription(const QString& logicalNodeClass) { } } // namespace -MmsLiveTreeModel::MmsLiveTreeModel(QObject* parent) : QAbstractListModel(parent) {} +MmsLiveTreeModel::MmsLiveTreeModel(QObject* parent) : QAbstractListModel(parent) { + detailModel_ = new MmsLiveDetailModel(this); +} int MmsLiveTreeModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : visible_.size(); @@ -114,6 +116,7 @@ int MmsLiveTreeModel::appendNode(Node node) { } void MmsLiveTreeModel::applyDocument(const ar::iec61850::mms::MmsLiveModelDocument& document) { + detailModel_->resetForDocument(); beginResetModel(); nodes_.clear(); visible_.clear(); @@ -193,12 +196,14 @@ void MmsLiveTreeModel::applyDocument(const ar::iec61850::mms::MmsLiveModelDocume } endResetModel(); + detailModel_->setRoot(0); rebuildVisible(); emit countsChanged(); emit selectionChanged(); } void MmsLiveTreeModel::clear() { + detailModel_->resetForDocument(); beginResetModel(); nodes_.clear(); visible_.clear(); @@ -375,6 +380,7 @@ bool MmsLiveTreeModel::selectNode(const int nodeIndex) { if (nodeIndex < 0 || nodeIndex >= nodes_.size() || nodeIndex == selectedNode_) return false; const auto previousRow = selectedRow(); selectedNode_ = nodeIndex; + detailModel_->setRoot(nodeIndex); for (int cursor = nodes_.at(selectedNode_).parent; cursor >= 0; cursor = nodes_.at(cursor).parent) { nodes_[cursor].expanded = true; } @@ -421,6 +427,7 @@ void MmsLiveTreeModel::selectRow(const int row) { if (next == selectedNode_) return; const auto previousRow = selectedRow(); selectedNode_ = next; + detailModel_->setRoot(next); const auto currentRow = selectedRow(); if (previousRow >= 0) emit dataChanged(index(previousRow), index(previousRow), {SelectedRole}); if (currentRow >= 0) emit dataChanged(index(currentRow), index(currentRow), {SelectedRole}); @@ -440,6 +447,7 @@ bool MmsLiveTreeModel::selectMmsItem(const QString& domain, const QString& item) const auto found = mmsIndex_.constFind(nodeKey(domain, item)); if (found == mmsIndex_.cend()) return false; selectedNode_ = found.value(); + detailModel_->setRoot(selectedNode_); for (int cursor = nodes_.at(selectedNode_).parent; cursor >= 0; cursor = nodes_.at(cursor).parent) { nodes_[cursor].expanded = true; } @@ -558,6 +566,7 @@ void MmsLiveTreeModel::applyReadValue(const QString& key, const QString& display auto& node = nodes_[nodeIndex]; if (node.value == displayValue) return; node.value = displayValue; + detailModel_->nodeValueChanged(nodeIndex); const auto row = visibleRowForNode(nodeIndex); if (row >= 0) emit dataChanged(index(row), index(row), {ValueRole}); if (!filterText_.trimmed().isEmpty()) rebuildVisible(); @@ -565,3 +574,151 @@ void MmsLiveTreeModel::applyReadValue(const QString& key, const QString& display emit selectionChanged(); } } + + +// Detail projection contains only visible row indices into the canonical model. +// Expanding a branch is a local UI operation, never an MMS read. +MmsLiveDetailModel::MmsLiveDetailModel(MmsLiveTreeModel* owner) + : QAbstractListModel(owner), owner_(owner) {} + +int MmsLiveDetailModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : rows_.size(); +} + +QHash MmsLiveDetailModel::roleNames() const { + return {{LabelRole, "label"}, {KindRole, "kind"}, {ReferenceRole, "reference"}, + {DepthRole, "depth"}, {HasChildrenRole, "hasChildren"}, + {ExpandedRole, "expanded"}, {SelectedRole, "selected"}, + {FunctionalConstraintRole, "functionalConstraint"}, + {DescriptionRole, "description"}, {ValueRole, "value"}}; +} + +QVariant MmsLiveDetailModel::data(const QModelIndex& index, const int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= rows_.size()) return {}; + const auto row = rows_.at(index.row()); + const auto& n = owner_->nodes_.at(row.node); + switch (role) { + case LabelRole: return n.label; + case KindRole: return MmsLiveTreeModel::kindName(n.kind); + case ReferenceRole: return n.reference; + case DepthRole: return row.depth; + case HasChildrenRole: return !n.children.isEmpty(); + case ExpandedRole: return expanded_.contains(row.node); + case SelectedRole: return selected_ == row.node; + case FunctionalConstraintRole: return n.functionalConstraint; + case DescriptionRole: return n.description; + case ValueRole: { + if (!n.value.isEmpty()) return n.value; + if (n.kind != MmsLiveTreeModel::NodeKind::dataObject) return QString{}; + static const QStringList preferred{QStringLiteral("stVal"), QStringLiteral("mag"), + QStringLiteral("setVal"), QStringLiteral("instMag"), + QStringLiteral("general")}; + for (const auto& label : preferred) + for (const auto child : n.children) { + const auto& leaf = owner_->nodes_.at(child); + if (leaf.label.compare(label, Qt::CaseInsensitive) == 0 && + !leaf.value.isEmpty()) return leaf.value; + } + return QString{}; + } + default: return {}; + } +} + +QString MmsLiveDetailModel::rootLabel() const { + return root_ >= 0 && root_ < owner_->nodes_.size() ? owner_->nodes_.at(root_).label : QString{}; +} +QString MmsLiveDetailModel::rootKind() const { + return root_ >= 0 && root_ < owner_->nodes_.size() + ? MmsLiveTreeModel::kindName(owner_->nodes_.at(root_).kind) : QString{}; +} +int MmsLiveDetailModel::rootChildCount() const { + return root_ >= 0 && root_ < owner_->nodes_.size() + ? owner_->nodes_.at(root_).children.size() : 0; +} +void MmsLiveDetailModel::append(int node, int depth) { + if (rows_.size() >= maximumRows_) { truncated_ = true; return; } + rows_.push_back({node, depth}); + if (!expanded_.contains(node)) return; + for (const auto child : owner_->nodes_.at(node).children) { + append(child, depth + 1); + if (truncated_) return; + } +} +void MmsLiveDetailModel::rebuild() { + beginResetModel(); + rows_.clear(); + truncated_ = false; + if (root_ >= 0 && root_ < owner_->nodes_.size()) + for (const auto child : owner_->nodes_.at(root_).children) { + append(child, 0); + if (truncated_) break; + } + endResetModel(); + emit projectionChanged(); +} +void MmsLiveDetailModel::setRoot(int node) { + const int valid = node >= 0 && node < owner_->nodes_.size() ? node : -1; + if (root_ == valid) return; + root_ = valid; + selected_ = -1; + rebuild(); + emit rootChanged(); +} +void MmsLiveDetailModel::resetForDocument() { + expanded_.clear(); + root_ = -1; + selected_ = -1; + rebuild(); + emit rootChanged(); +} +int MmsLiveDetailModel::rowOf(int node) const { + for (int row = 0; row < rows_.size(); ++row) + if (rows_.at(row).node == node) return row; + return -1; +} +void MmsLiveDetailModel::toggle(int row) { + if (row < 0 || row >= rows_.size()) return; + const int node = rows_.at(row).node; + if (owner_->nodes_.at(node).children.isEmpty()) return; + if (expanded_.contains(node)) expanded_.remove(node); + else expanded_.insert(node); + rebuild(); +} +void MmsLiveDetailModel::select(int row) { + if (row < 0 || row >= rows_.size()) return; + const int node = rows_.at(row).node; + if (selected_ == node && owner_->selectedNode_ == node) return; + const int oldDetail = rowOf(selected_); + const int oldTree = owner_->selectedRow(); + selected_ = node; + owner_->selectedNode_ = node; // keep panel root stable; read/control target canonical node + const int newTree = owner_->selectedRow(); + if (oldDetail >= 0) emit dataChanged(index(oldDetail), index(oldDetail), {SelectedRole}); + emit dataChanged(index(row), index(row), {SelectedRole}); + if (oldTree >= 0) + emit owner_->dataChanged(owner_->index(oldTree), owner_->index(oldTree), + {MmsLiveTreeModel::SelectedRole}); + if (newTree >= 0) + emit owner_->dataChanged(owner_->index(newTree), owner_->index(newTree), + {MmsLiveTreeModel::SelectedRole}); + emit owner_->selectionChanged(); +} +void MmsLiveDetailModel::drillInto(int row) { + if (row < 0 || row >= rows_.size()) return; + select(row); + const int node = rows_.at(row).node; + if (!owner_->nodes_.at(node).children.isEmpty()) toggle(row); +} +void MmsLiveDetailModel::selectParent() { + if (root_ < 0 || root_ >= owner_->nodes_.size()) return; + const int parent = owner_->nodes_.at(root_).parent; + if (parent >= 0) owner_->selectNode(parent); +} +void MmsLiveDetailModel::nodeValueChanged(int node) { + const int row = rowOf(node); + if (row >= 0) emit dataChanged(index(row), index(row), {ValueRole}); + const int parent = owner_->nodes_.at(node).parent; + const int parentRow = rowOf(parent); + if (parentRow >= 0) emit dataChanged(index(parentRow), index(parentRow), {ValueRole}); +} diff --git a/apps/ied_simulator/src/MmsLiveTreeModel.hpp b/apps/ied_simulator/src/MmsLiveTreeModel.hpp index 148aa345e..4fb32eb30 100644 --- a/apps/ied_simulator/src/MmsLiveTreeModel.hpp +++ b/apps/ied_simulator/src/MmsLiveTreeModel.hpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -14,6 +15,54 @@ #include + +class MmsLiveTreeModel; + +// Virtualized, bounded view of canonical MMS nodes; never a second semantic tree. +class MmsLiveDetailModel final : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(QString rootLabel READ rootLabel NOTIFY rootChanged) + Q_PROPERTY(QString rootKind READ rootKind NOTIFY rootChanged) + Q_PROPERTY(int rootChildCount READ rootChildCount NOTIFY rootChanged) + Q_PROPERTY(bool truncated READ truncated NOTIFY projectionChanged) +public: + enum DetailRole { + LabelRole = Qt::UserRole + 1, KindRole, ReferenceRole, DepthRole, + HasChildrenRole, ExpandedRole, SelectedRole, FunctionalConstraintRole, + DescriptionRole, ValueRole + }; + explicit MmsLiveDetailModel(MmsLiveTreeModel* owner); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role) const override; + QHash roleNames() const override; + QString rootLabel() const; + QString rootKind() const; + int rootChildCount() const; + bool truncated() const noexcept { return truncated_; } + Q_INVOKABLE void toggle(int row); + Q_INVOKABLE void select(int row); + Q_INVOKABLE void drillInto(int row); + Q_INVOKABLE void selectParent(); + void setRoot(int nodeIndex); + void resetForDocument(); + void nodeValueChanged(int nodeIndex); +signals: + void rootChanged(); + void projectionChanged(); +private: + struct Row { int node; int depth; }; + void rebuild(); + void append(int node, int depth); + int rowOf(int node) const; + MmsLiveTreeModel* owner_; + QVector rows_; + QSet expanded_; + int root_{-1}; + int selected_{-1}; + bool truncated_{}; + static constexpr int maximumRows_{4096}; +}; + class MmsLiveTreeModel : public QAbstractListModel { Q_OBJECT QML_ELEMENT @@ -21,6 +70,7 @@ class MmsLiveTreeModel : public QAbstractListModel { Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged) Q_PROPERTY(int selectedRow READ selectedRow WRITE selectRow NOTIFY selectionChanged) Q_PROPERTY(QVariantMap selectedNode READ selectedNode NOTIFY selectionChanged) + Q_PROPERTY(QAbstractItemModel* detailModel READ detailModel CONSTANT) Q_PROPERTY(QVariantMap presentationNode READ presentationNode NOTIFY selectionChanged) Q_PROPERTY(QVariantList selectedChildren READ selectedChildren NOTIFY selectionChanged) Q_PROPERTY(int selectedChildCount READ selectedChildCount NOTIFY selectionChanged) @@ -76,6 +126,7 @@ class MmsLiveTreeModel : public QAbstractListModel { [[nodiscard]] int selectedRow() const noexcept; [[nodiscard]] QVariantMap selectedNode() const; + [[nodiscard]] QAbstractItemModel* detailModel() const noexcept { return detailModel_; } [[nodiscard]] QVariantMap presentationNode() const; [[nodiscard]] QVariantList selectedChildren() const; [[nodiscard]] int selectedChildCount() const noexcept; @@ -110,6 +161,7 @@ class MmsLiveTreeModel : public QAbstractListModel { void countsChanged(); private: + friend class MmsLiveDetailModel; enum class NodeKind : quint8 { ied, logicalDevice, logicalNode, dataObject, dataAttribute }; struct Node final { @@ -149,6 +201,7 @@ class MmsLiveTreeModel : public QAbstractListModel { [[nodiscard]] QVector readTargetsForNode(int nodeIndex) const; [[nodiscard]] int appendNode(Node node); + MmsLiveDetailModel* detailModel_{}; QVector nodes_; QVector visible_; QHash mmsIndex_; diff --git a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp index b47668047..d6665dcae 100644 --- a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp @@ -97,6 +97,29 @@ int main(int argc, char** argv) { return 12; } + // Inline projection remains bounded and does not change canonical selection root. + auto* detail = qobject_cast(model.detailModel()); + if (!detail || detail->rootKind() != QStringLiteral("DO") || detail->rowCount() != 5) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL inline_root\n"; + return 13; + } + model.selectRoot(); + if (detail->rowCount() != 5) return 14; + detail->toggle(4); + if (detail->rowCount() != 25 || + detail->data(detail->index(5), MmsLiveDetailModel::DepthRole).toInt() != 1) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL inline_expansion\n"; + return 15; + } + detail->toggle(4); + if (detail->rowCount() != 5) return 16; + detail->select(0); + if (detail->rootKind() != QStringLiteral("IED") || + model.selectedNode().value(QStringLiteral("kind")).toString() != QStringLiteral("LD")) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL selection_context\n"; + return 17; + } + QElapsedTimer filterTimer; filterTimer.start(); model.setFilterText(QStringLiteral("LD4/LLN19.DO99")); @@ -165,6 +188,6 @@ int main(int argc, char** argv) { << " apply_ms=" << applyMs << " filter_ms=" << filterMs << " select_ms=" << selectMs - << " eager_expansion=false drilldown=canonical\n"; + << " eager_expansion=false drilldown=canonical inline_projection=bounded\n"; return passes ? 0 : 11; } From 803ea424816e402ea99ef4bcbefa14c78b5e243d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:02:33 +0700 Subject: [PATCH 2/6] feat(workbench): virtualized inline detail QML Use canonical detail projection with click-to-select and inline disclosure without automatic MMS reads. Lock renderer interaction regression. --- .../qml/BrowserDataModelPane.qml | 88 +++++++++---------- .../ied_simulator/test_browser_convergence.py | 8 +- 2 files changed, 46 insertions(+), 50 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserDataModelPane.qml b/apps/ied_simulator/qml/BrowserDataModelPane.qml index 79e2cfae2..8bcf9d57b 100644 --- a/apps/ied_simulator/qml/BrowserDataModelPane.qml +++ b/apps/ied_simulator/qml/BrowserDataModelPane.qml @@ -12,7 +12,7 @@ Rectangle { readonly property var treeModel: modelProvider.treeModel readonly property var currentNode: treeModel.presentationNode - property int highlightedRow: -1 + readonly property var detail: treeModel.detailModel color: theme.background @@ -21,20 +21,8 @@ Rectangle { ? "—" : String(value) } - function drillInto(row) { - if (!treeModel.selectChild(row)) - return - highlightedRow = -1 - if (treeModel.presentationNode.hasChildren !== true - && client.connected && !client.operationBusy - && treeModel.presentationNode.readable === true) - client.readEngineeringSelected() - } - - Connections { - target: root.treeModel - function onSelectionChanged() { root.highlightedRow = -1 } - } + // Inline inspection is local; network reads remain explicit ribbon commands. + function drillInto(row) { detail.drillInto(row) } ColumnLayout { anchors.fill: parent @@ -55,11 +43,11 @@ Rectangle { Button { objectName: "browserDataModelBack" - visible: Boolean(root.currentNode.kind) && root.currentNode.kind !== "IED" + visible: root.detail.rootKind !== "" && root.detail.rootKind !== "IED" implicitWidth: 24 implicitHeight: 22 text: "‹" - onClicked: root.treeModel.selectParent() + onClicked: root.detail.selectParent() ToolTip.visible: hovered ToolTip.text: "Up one model level" } @@ -72,7 +60,7 @@ Rectangle { Label { id: nodeKind anchors.centerIn: parent - text: root.currentNode.kind || "MODEL" + text: root.detail.rootKind || "MODEL" color: "#ffffff" font.pixelSize: 7 font.weight: Font.Bold @@ -81,7 +69,7 @@ Rectangle { Label { Layout.fillWidth: true - text: root.currentNode.label || "Data Model" + text: root.detail.rootLabel || "Data Model" color: theme.text font.pixelSize: 10 font.weight: Font.DemiBold @@ -89,8 +77,8 @@ Rectangle { } Label { - visible: root.treeModel.selectedChildCount > 0 - text: root.treeModel.selectedChildCount + " item(s)" + visible: root.detail.rootChildCount > 0 + text: root.detail.rootChildCount + " item(s)" color: theme.muted font.pixelSize: 8 } @@ -98,7 +86,7 @@ Rectangle { } Rectangle { - visible: root.treeModel.selectedChildCount > 0 + visible: root.detail.rootChildCount > 0 Layout.fillWidth: true Layout.preferredHeight: 25 color: theme.chrome @@ -130,22 +118,22 @@ Rectangle { ListView { id: childList objectName: "browserDataModelChildren" - visible: root.treeModel.selectedChildCount > 0 + visible: root.detail.rootChildCount > 0 Layout.fillWidth: true Layout.fillHeight: true clip: true reuseItems: true cacheBuffer: 0 - model: root.treeModel.selectedChildren + model: root.detail ScrollBar.vertical: ScrollBar {} delegate: Rectangle { id: childRow required property int index - required property var modelData + required property var model width: childList.width height: 25 - color: root.highlightedRow === index ? theme.accent + color: childRow.model.selected ? theme.accent : childMouse.containsMouse ? theme.accentSoft : index % 2 ? theme.surfaceSoft : theme.surface border.width: 1 @@ -153,50 +141,50 @@ Rectangle { RowLayout { anchors.fill: parent - anchors.leftMargin: 7 + anchors.leftMargin: 7 + Math.min(childRow.model.depth, 6) * 14 anchors.rightMargin: 7 spacing: 6 Label { Layout.preferredWidth: 10 - text: childRow.modelData.hasChildren === true ? "›" : "" - color: root.highlightedRow === childRow.index ? "#ffffff" : theme.muted + text: childRow.model.hasChildren ? (childRow.model.expanded ? "▾" : "▸") : "" + color: childRow.model.selected ? "#ffffff" : theme.muted font.pixelSize: 9 } Rectangle { Layout.preferredWidth: 20 Layout.preferredHeight: 17 radius: 2 - color: root.highlightedRow === childRow.index ? "#ffffff" : theme.accent + color: childRow.model.selected ? "#ffffff" : theme.accent Label { anchors.centerIn: parent - text: childRow.modelData.kind || "" - color: root.highlightedRow === childRow.index ? theme.accent : "#ffffff" + text: childRow.model.kind || "" + color: childRow.model.selected ? theme.accent : "#ffffff" font.pixelSize: 7 font.weight: Font.Bold } } Label { Layout.fillWidth: true - text: childRow.modelData.label || childRow.modelData.reference || "—" - color: root.highlightedRow === childRow.index ? "#ffffff" : theme.text + text: childRow.model.label || childRow.model.reference || "—" + color: childRow.model.selected ? "#ffffff" : theme.text font.pixelSize: 9 elide: Text.ElideMiddle } Label { - visible: childRow.modelData.functionalConstraint - && String(childRow.modelData.functionalConstraint).length > 0 - text: "[" + childRow.modelData.functionalConstraint + "]" - color: root.highlightedRow === childRow.index ? "#dff7f3" : theme.muted + visible: childRow.model.functionalConstraint + && String(childRow.model.functionalConstraint).length > 0 + text: "[" + childRow.model.functionalConstraint + "]" + color: childRow.model.selected ? "#dff7f3" : theme.muted font.pixelSize: 8 } Label { Layout.preferredWidth: Math.max(180, root.width * 0.42) - text: childRow.modelData.value - || childRow.modelData.description - || (childRow.modelData.childCount > 0 - ? childRow.modelData.childCount + " item(s)" : "—") - color: root.highlightedRow === childRow.index ? "#ffffff" : theme.textSoft + text: childRow.model.value + || childRow.model.description + || (childRow.model.childCount > 0 + ? childRow.model.childCount + " item(s)" : "—") + color: childRow.model.selected ? "#ffffff" : theme.textSoft font.pixelSize: 9 elide: Text.ElideRight } @@ -206,14 +194,20 @@ Rectangle { id: childMouse anchors.fill: parent hoverEnabled: true - onClicked: root.highlightedRow = childRow.index + onClicked: function(event) { + if (childRow.model.hasChildren && + event.x < 24 + Math.min(childRow.model.depth, 6) * 14) + root.detail.toggle(childRow.index) + else + root.detail.select(childRow.index) + } onDoubleClicked: root.drillInto(childRow.index) } } } ScrollView { - visible: root.treeModel.selectedChildCount === 0 + visible: root.detail.rootChildCount === 0 Layout.fillWidth: true Layout.fillHeight: true contentWidth: availableWidth @@ -279,10 +273,10 @@ Rectangle { } Label { - visible: root.treeModel.selectedChildrenTruncated + visible: root.detail.truncated Layout.fillWidth: true Layout.preferredHeight: 24 - text: "Showing the first 4096 direct children. Refine the canonical tree selection to continue." + text: "Showing the first 4096 visible rows. Collapse branches or narrow the selection to continue." color: theme.amber font.pixelSize: 8 horizontalAlignment: Text.AlignHCenter diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 45162ae0b..d6c5489a8 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -44,10 +44,12 @@ ] data_model_required = [ 'objectName: "browserDataModelChildren"', - 'model: root.treeModel.selectedChildren', + 'model: root.detail', + 'root.detail.select(childRow.index)', + 'root.detail.toggle(childRow.index)', 'onDoubleClicked: root.drillInto(childRow.index)', - 'root.treeModel.selectParent()', - 'root.treeModel.selectedChildrenTruncated', + 'root.detail.selectParent()', + 'root.detail.truncated', ] for label, source, required in [ From a98243f02e04f95147cd2a2ff3d4197b2991e6a6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:05:34 +0700 Subject: [PATCH 3/6] perf(workbench): preserve scroll with incremental detail row changes Replace full-model reset on ordinary branch expansion with bounded insert/remove notifications. Keep stable selection when collapsing a selected descendant; rebuild only when a capped projection must be rehydrated. --- apps/ied_simulator/src/MmsLiveTreeModel.cpp | 65 +++++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/apps/ied_simulator/src/MmsLiveTreeModel.cpp b/apps/ied_simulator/src/MmsLiveTreeModel.cpp index da05000dd..c365206d2 100644 --- a/apps/ied_simulator/src/MmsLiveTreeModel.cpp +++ b/apps/ied_simulator/src/MmsLiveTreeModel.cpp @@ -680,10 +680,67 @@ int MmsLiveDetailModel::rowOf(int node) const { void MmsLiveDetailModel::toggle(int row) { if (row < 0 || row >= rows_.size()) return; const int node = rows_.at(row).node; - if (owner_->nodes_.at(node).children.isEmpty()) return; - if (expanded_.contains(node)) expanded_.remove(node); - else expanded_.insert(node); - rebuild(); + const int depth = rows_.at(row).depth; + const auto& children = owner_->nodes_.at(node).children; + if (children.isEmpty()) return; + + // An earlier bounded projection might have withheld later siblings. + // Rebuild in that rare case to restore complete ordering after collapse. + if (truncated_) { + if (expanded_.contains(node)) expanded_.remove(node); + else expanded_.insert(node); + rebuild(); + return; + } + + if (expanded_.contains(node)) { + int last = row + 1; + while (last < rows_.size() && rows_.at(last).depth > depth) ++last; + // A selected descendant must not silently disappear from view. + const int selectedRow = rowOf(selected_); + if (selectedRow > row && selectedRow < last) select(row); + expanded_.remove(node); + if (last > row + 1) { + beginRemoveRows({}, row + 1, last - 1); + rows_.remove(row + 1, last - row - 1); + endRemoveRows(); + } + } else { + expanded_.insert(node); + QVector added; + added.reserve(std::min(maximumRows_ - rows_.size(), int(children.size()))); + bool clipped = false; + const int capacity = maximumRows_ - rows_.size(); + const auto expand = [&](const auto& self, int child, int level) -> void { + if (added.size() >= capacity) { clipped = true; return; } + added.push_back({child, level}); + if (expanded_.contains(child)) + for (const auto descendant : owner_->nodes_.at(child).children) { + self(self, descendant, level + 1); + if (clipped) return; + } + }; + for (const auto child : children) { + expand(expand, child, depth + 1); + if (clipped) break; + } + if (!added.isEmpty()) { + const int offset = row + 1; + const int oldSize = rows_.size(); + beginInsertRows({}, offset, offset + added.size() - 1); + rows_.resize(oldSize + added.size()); + for (int pos = oldSize - 1; pos >= offset; --pos) + rows_[pos + added.size()] = rows_.at(pos); + for (int pos = 0; pos < added.size(); ++pos) + rows_[offset + pos] = added.at(pos); + endInsertRows(); + } + if (clipped) { + truncated_ = true; + emit projectionChanged(); + } + } + emit dataChanged(index(row), index(row), {ExpandedRole}); } void MmsLiveDetailModel::select(int row) { if (row < 0 || row >= rows_.size()) return; From 21788b3966e19904e580aa2a3212ce430649af1a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:06:13 +0700 Subject: [PATCH 4/6] fix(workbench): use explicit int capacity for bounded row projection --- apps/ied_simulator/src/MmsLiveTreeModel.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ied_simulator/src/MmsLiveTreeModel.cpp b/apps/ied_simulator/src/MmsLiveTreeModel.cpp index c365206d2..67b39f21e 100644 --- a/apps/ied_simulator/src/MmsLiveTreeModel.cpp +++ b/apps/ied_simulator/src/MmsLiveTreeModel.cpp @@ -708,9 +708,9 @@ void MmsLiveDetailModel::toggle(int row) { } else { expanded_.insert(node); QVector added; - added.reserve(std::min(maximumRows_ - rows_.size(), int(children.size()))); + const int capacity = maximumRows_ - int(rows_.size()); + added.reserve(std::min(capacity, int(children.size()))); bool clipped = false; - const int capacity = maximumRows_ - rows_.size(); const auto expand = [&](const auto& self, int child, int level) -> void { if (added.size() >= capacity) { clipped = true; return; } added.push_back({child, level}); From 466848626f37aa90b31cc533211bc97291d2ee7d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:07:25 +0700 Subject: [PATCH 5/6] fix(workbench): keep double-click disclosure from reversing first arrow click --- apps/ied_simulator/qml/BrowserDataModelPane.qml | 10 +++++++++- apps/ied_simulator/test_browser_convergence.py | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/qml/BrowserDataModelPane.qml b/apps/ied_simulator/qml/BrowserDataModelPane.qml index 8bcf9d57b..89936b51d 100644 --- a/apps/ied_simulator/qml/BrowserDataModelPane.qml +++ b/apps/ied_simulator/qml/BrowserDataModelPane.qml @@ -201,7 +201,15 @@ Rectangle { else root.detail.select(childRow.index) } - onDoubleClicked: root.drillInto(childRow.index) + onDoubleClicked: function(event) { + // First arrow click has already changed expansion; a + // double-click on that arrow must not undo it. + if (childRow.model.hasChildren && + event.x < 24 + Math.min(childRow.model.depth, 6) * 14) + root.detail.select(childRow.index) + else + root.drillInto(childRow.index) + } } } } diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index d6c5489a8..749f77937 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -50,6 +50,7 @@ 'onDoubleClicked: root.drillInto(childRow.index)', 'root.detail.selectParent()', 'root.detail.truncated', + 'onDoubleClicked: function(event)', ] for label, source, required in [ From 0f9fe86515d36e4eebf8a709d4c744cb1fef800e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:08:59 +0700 Subject: [PATCH 6/6] test(workbench): align double-click contract with inline disclosure handler --- apps/ied_simulator/test_browser_convergence.py | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 749f77937..dfa09d550 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -47,7 +47,6 @@ 'model: root.detail', 'root.detail.select(childRow.index)', 'root.detail.toggle(childRow.index)', - 'onDoubleClicked: root.drillInto(childRow.index)', 'root.detail.selectParent()', 'root.detail.truncated', 'onDoubleClicked: function(event)',