Add interface model group reparenting

Add group rename and reparenting, model-to-group moves, drag-and-drop reassignment, and clearer group creation actions in the interface models panel.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Dion Moult
2026-05-14 10:20:43 +10:00
parent cee2dbcc85
commit d18c05d4bd
3 changed files with 339 additions and 16 deletions
@@ -77,6 +77,28 @@ ModelsPanelController::ModelsPanelController(QWidget* host,
session_state_->notifyFederationStructureChanged();
session_state_->setStatusMessage("Models", "Group added");
});
connect(widget_, &ModelsPanel::renameGroupRequested, this,
[this](const QString& id, const QString& name) {
session_state_->federation()->setGroupName(id, name);
session_state_->notifyFederationStructureChanged();
session_state_->setStatusMessage("Models", "Group renamed");
});
connect(widget_, &ModelsPanel::moveGroupRequested, this,
[this](const QString& id, const QString& parent_group_id) {
session_state_->federation()->setGroupParent(id, parent_group_id);
session_state_->notifyFederationStructureChanged();
session_state_->setStatusMessage("Models", parent_group_id.isEmpty() ? "Group moved to root"
: "Group moved");
});
connect(widget_, &ModelsPanel::moveModelsRequested, this,
[this](const QStringList& ids, const QString& parent_group_id) {
for (const auto& id : ids) {
session_state_->federation()->setModelGroup(id, parent_group_id);
}
session_state_->notifyFederationStructureChanged();
session_state_->setStatusMessage("Models", parent_group_id.isEmpty() ? "Model(s) moved to root"
: "Model(s) moved");
});
connect(widget_, &ModelsPanel::removeGroupRequested, this,
[this](const QString& id) {
session_state_->federation()->removeGroup(id);
+314 -16
View File
@@ -23,34 +23,239 @@
#include "../../components/Section.h"
#include "../../components/SvgIcon.h"
#include <QDataStream>
#include <QDragEnterEvent>
#include <QDragMoveEvent>
#include <QHeaderView>
#include <QInputDialog>
#include <QLineEdit>
#include <QMenu>
#include <QMimeData>
#include <QDropEvent>
#include <QSizePolicy>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <functional>
namespace ifcinterface::modules::models {
namespace {
constexpr auto kDragMimeType = "application/x-ifcinterface-model-items";
class ModelsTreeWidget : public QTreeWidget {
public:
explicit ModelsTreeWidget(QWidget* parent = nullptr) : QTreeWidget(parent) {}
std::function<void(const QStringList&, const QString&)> on_model_drop;
std::function<void(const QString&, const QString&)> on_group_drop;
protected:
QStringList mimeTypes() const override {
return {QString::fromUtf8(kDragMimeType)};
}
QMimeData* mimeData(const QList<QTreeWidgetItem*>& items) const override {
Q_UNUSED(items);
const QList<QTreeWidgetItem*> selected = selectedItems();
if (selected.isEmpty()) return nullptr;
const int first_kind = selected.first()->data(0, Qt::UserRole).toInt();
if (first_kind == static_cast<int>(ItemKind::Group) && selected.size() != 1) {
return nullptr;
}
QByteArray payload;
QDataStream stream(&payload, QIODevice::WriteOnly);
stream << first_kind;
if (first_kind == static_cast<int>(ItemKind::Group)) {
stream << selected.first()->data(0, Qt::UserRole + 1).toString();
} else {
QStringList ids;
for (QTreeWidgetItem* item : selected) {
if (item->data(0, Qt::UserRole).toInt() != first_kind) return nullptr;
ids.push_back(item->data(0, Qt::UserRole + 1).toString());
}
ids.removeDuplicates();
stream << ids;
}
auto* mime = new QMimeData();
mime->setData(QString::fromUtf8(kDragMimeType), payload);
return mime;
}
Qt::DropActions supportedDropActions() const override {
return Qt::MoveAction;
}
void dragEnterEvent(QDragEnterEvent* event) override {
if (event->mimeData()->hasFormat(QString::fromUtf8(kDragMimeType))) {
event->acceptProposedAction();
return;
}
QTreeWidget::dragEnterEvent(event);
}
void dragMoveEvent(QDragMoveEvent* event) override {
if (!event->mimeData()->hasFormat(QString::fromUtf8(kDragMimeType))) {
QTreeWidget::dragMoveEvent(event);
return;
}
QString group_id;
if (!decodeDropTarget(event->position().toPoint(), group_id)) {
event->ignore();
return;
}
if (canAcceptDrop(event->mimeData(), itemAt(event->position().toPoint()), group_id)) {
event->acceptProposedAction();
} else {
event->ignore();
}
}
void dropEvent(QDropEvent* event) override {
if (!event->mimeData()->hasFormat(QString::fromUtf8(kDragMimeType))) {
QTreeWidget::dropEvent(event);
return;
}
QString target_group_id;
if (!decodeDropTarget(event->position().toPoint(), target_group_id)) {
event->ignore();
return;
}
QTreeWidgetItem* target_item = itemAt(event->position().toPoint());
if (!canAcceptDrop(event->mimeData(), target_item, target_group_id)) {
event->ignore();
return;
}
QByteArray payload = event->mimeData()->data(QString::fromUtf8(kDragMimeType));
QDataStream stream(&payload, QIODevice::ReadOnly);
int kind = 0;
stream >> kind;
if (kind == static_cast<int>(ItemKind::Group)) {
QString group_id;
stream >> group_id;
if (on_group_drop) on_group_drop(group_id, target_group_id);
} else if (kind == static_cast<int>(ItemKind::Model)) {
QStringList ids;
stream >> ids;
ids.removeDuplicates();
if (!ids.isEmpty() && on_model_drop) on_model_drop(ids, target_group_id);
} else {
event->ignore();
return;
}
event->acceptProposedAction();
}
private:
bool decodeDropTarget(const QPoint& pos, QString& target_group_id) const {
target_group_id.clear();
QTreeWidgetItem* target_item = itemAt(pos);
if (!target_item) return true;
const int kind = target_item->data(0, Qt::UserRole).toInt();
if (kind != static_cast<int>(ItemKind::Group)) return false;
target_group_id = target_item->data(0, Qt::UserRole + 1).toString();
return true;
}
bool canAcceptDrop(const QMimeData* mime,
QTreeWidgetItem* target_item,
const QString& target_group_id) const {
QByteArray payload = mime->data(QString::fromUtf8(kDragMimeType));
QDataStream stream(&payload, QIODevice::ReadOnly);
int kind = 0;
stream >> kind;
if (kind == static_cast<int>(ItemKind::Group)) {
QString group_id;
stream >> group_id;
if (group_id.isEmpty()) return false;
if (!target_item) return true;
if (target_item->data(0, Qt::UserRole).toInt() != static_cast<int>(ItemKind::Group)) return false;
if (group_id == target_group_id) return false;
for (QTreeWidgetItem* cur = target_item; cur != nullptr; cur = cur->parent()) {
if (cur->data(0, Qt::UserRole + 1).toString() == group_id) return false;
}
return true;
}
if (kind == static_cast<int>(ItemKind::Model)) {
QStringList ids;
stream >> ids;
ids.removeDuplicates();
if (ids.isEmpty()) return false;
return target_item == nullptr || !target_group_id.isNull();
}
return false;
}
};
QString promptGroupName(QWidget* parent, const QString& title, const QString& label, const QString& value) {
bool ok = false;
const QString name = QInputDialog::getText(parent, title, label, QLineEdit::Normal, value, &ok);
return ok ? name.trimmed() : QString();
}
QString itemId(QTreeWidgetItem* item) {
return item ? item->data(0, Qt::UserRole + 1).toString() : QString();
}
ItemKind itemKind(QTreeWidgetItem* item) {
return static_cast<ItemKind>(item->data(0, Qt::UserRole).toInt());
}
QList<QTreeWidgetItem*> selectedItemsOfKind(QTreeWidget* tree, ItemKind kind) {
QList<QTreeWidgetItem*> matches;
for (QTreeWidgetItem* item : tree->selectedItems()) {
if (itemKind(item) == kind) matches.push_back(item);
}
return matches;
}
} // namespace
ModelsPanel::ModelsPanel(QWidget* parent)
: components::Panel("Models", nullptr, parent, true)
{
auto* section = new components::Section("", components::SectionHeaderMode::Hidden, this);
section->setBodyExpanding(true);
tree_ = new QTreeWidget(section);
auto* tree = new ModelsTreeWidget(section);
tree_ = tree;
tree_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
tree_->setColumnCount(2);
tree_->setHeaderLabels({"Model", ""});
tree_->setIconSize(QSize(16, 16));
tree_->setSelectionMode(QAbstractItemView::ExtendedSelection);
tree_->setContextMenuPolicy(Qt::CustomContextMenu);
tree_->setDragEnabled(true);
tree_->viewport()->setAcceptDrops(true);
tree_->setDropIndicatorShown(true);
tree_->setDragDropMode(QAbstractItemView::DragDrop);
tree_->setDefaultDropAction(Qt::MoveAction);
tree_->setUniformRowHeights(true);
tree_->header()->setStretchLastSection(false);
tree_->header()->setSectionResizeMode(0, QHeaderView::Stretch);
tree_->header()->setSectionResizeMode(1, QHeaderView::Fixed);
tree_->header()->resizeSection(1, 28);
tree_->header()->hide();
tree->on_model_drop = [this](const QStringList& ids, const QString& target_group_id) {
emit moveModelsRequested(ids, target_group_id);
};
tree->on_group_drop = [this](const QString& id, const QString& target_group_id) {
emit moveGroupRequested(id, target_group_id);
};
section->addBodyWidget(tree_);
addBodyWidget(section);
@@ -65,11 +270,11 @@ ModelsPanel::ModelsPanel(QWidget* parent)
auto* item = tree_->itemAt(pos);
QMenu menu(tree_);
if (!item) {
menu.addAction(components::icons::makeSvgIcon(":/icons/folder-plus.svg"), "Add Group", [this]() {
bool ok = false;
const QString name = QInputDialog::getText(
this, "New Group", "Group name:", QLineEdit::Normal, "Group", &ok);
if (ok && !name.isEmpty()) {
QAction* add_group_action = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder-plus.svg"), "Add Group");
connect(add_group_action, &QAction::triggered, this, [this]() {
const QString name = promptGroupName(this, "New Group", "Group name:", "Group");
if (!name.isEmpty()) {
emit addGroupRequested(QString(), name);
}
});
@@ -77,27 +282,120 @@ ModelsPanel::ModelsPanel(QWidget* parent)
return;
}
const auto kind = static_cast<ItemKind>(item->data(0, Qt::UserRole).toInt());
const QString id = item->data(0, Qt::UserRole + 1).toString();
const auto kind = itemKind(item);
const QString id = itemId(item);
menu.addAction(components::icons::makeSvgIcon(":/icons/eye.svg"), "Toggle Visibility", [this, kind, id]() {
QAction* toggle_visibility_action = menu.addAction(
components::icons::makeSvgIcon(":/icons/eye.svg"), "Toggle Visibility");
connect(toggle_visibility_action, &QAction::triggered, this, [this, kind, id]() {
emit visibilityToggleRequested(kind, id);
});
if (kind == ItemKind::Group) {
menu.addAction(components::icons::makeSvgIcon(":/icons/folder-plus.svg"), "Add Group", [this, id]() {
bool ok = false;
const QString name = QInputDialog::getText(
this, "New Group", "Group name:", QLineEdit::Normal, "Group", &ok);
if (ok && !name.isEmpty()) {
QAction* add_group_action = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder-plus.svg"), "New Subgroup");
connect(add_group_action, &QAction::triggered, this, [this, id]() {
const QString name = promptGroupName(this, "New Group", "Group name:", "Group");
if (!name.isEmpty()) {
emit addGroupRequested(id, name);
}
});
menu.addAction(components::icons::makeSvgIcon(":/icons/folder-minus.svg"), "Remove Group", [this, id]() {
QAction* rename_group_action = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder.svg"), "Rename Group");
connect(rename_group_action, &QAction::triggered, this, [this, item, id]() {
const QString name = promptGroupName(
this, "Rename Group", "Group name:", item->text(0));
if (!name.isEmpty()) emit renameGroupRequested(id, name);
});
QMenu* move_menu = menu.addMenu("Move to Parent");
QAction* move_root_action = move_menu->addAction("(Root)");
connect(move_root_action, &QAction::triggered, this, [this, id]() {
emit moveGroupRequested(id, QString());
});
move_menu->addSeparator();
QList<QTreeWidgetItem*> stack;
for (int i = 0; i < tree_->topLevelItemCount(); ++i) {
stack.push_back(tree_->topLevelItem(i));
}
while (!stack.isEmpty()) {
QTreeWidgetItem* candidate = stack.takeFirst();
if (candidate != item && itemKind(candidate) == ItemKind::Group) {
bool would_cycle = false;
for (QTreeWidgetItem* cur = candidate; cur != nullptr; cur = cur->parent()) {
if (cur == item) {
would_cycle = true;
break;
}
}
auto* action = move_menu->addAction(candidate->text(0));
action->setEnabled(!would_cycle && candidate != item->parent());
connect(action, &QAction::triggered, this, [this, id, candidate]() {
emit moveGroupRequested(id, itemId(candidate));
});
}
for (int i = 0; i < candidate->childCount(); ++i) {
stack.push_back(candidate->child(i));
}
}
QAction* remove_group_action = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder-minus.svg"), "Remove Group");
connect(remove_group_action, &QAction::triggered, this, [this, id]() {
emit removeGroupRequested(id);
});
} else {
menu.addAction(components::icons::makeSvgIcon(":/icons/minus-square.svg"), "Remove Model", [this, id]() {
QString parent_group_id;
if (auto* parent_item = item->parent()) {
if (itemKind(parent_item) == ItemKind::Group) {
parent_group_id = itemId(parent_item);
}
}
QAction* add_group_action = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder-plus.svg"), "New Group");
connect(add_group_action, &QAction::triggered, this, [this, parent_group_id]() {
const QString name = promptGroupName(this, "New Group", "Group name:", "Group");
if (!name.isEmpty()) {
emit addGroupRequested(parent_group_id, name);
}
});
QStringList selected_model_ids;
const QList<QTreeWidgetItem*> selected_models = selectedItemsOfKind(tree_, ItemKind::Model);
if (selected_models.contains(item)) {
for (QTreeWidgetItem* selected : selected_models) {
selected_model_ids.push_back(itemId(selected));
}
selected_model_ids.removeDuplicates();
} else {
selected_model_ids = {id};
}
QMenu* move_menu = menu.addMenu("Move to Group");
QAction* move_root_action = move_menu->addAction("(Root)");
connect(move_root_action, &QAction::triggered, this, [this, selected_model_ids]() {
emit moveModelsRequested(selected_model_ids, QString());
});
move_menu->addSeparator();
QList<QTreeWidgetItem*> stack;
for (int i = 0; i < tree_->topLevelItemCount(); ++i) {
stack.push_back(tree_->topLevelItem(i));
}
while (!stack.isEmpty()) {
QTreeWidgetItem* candidate = stack.takeFirst();
if (itemKind(candidate) == ItemKind::Group) {
const QString group_id = itemId(candidate);
QAction* action = move_menu->addAction(candidate->text(0));
connect(action, &QAction::triggered, this, [this, selected_model_ids, group_id]() {
emit moveModelsRequested(selected_model_ids, group_id);
});
}
for (int i = 0; i < candidate->childCount(); ++i) {
stack.push_back(candidate->child(i));
}
}
QAction* remove_model_action = menu.addAction(
components::icons::makeSvgIcon(":/icons/minus-square.svg"), "Remove Model");
connect(remove_model_action, &QAction::triggered, this, [this, id]() {
emit removeModelRequested(id);
});
}
+3
View File
@@ -40,6 +40,9 @@ public:
signals:
void visibilityToggleRequested(ItemKind kind, const QString& id);
void addGroupRequested(const QString& parent_group_id, const QString& name);
void renameGroupRequested(const QString& id, const QString& name);
void moveGroupRequested(const QString& id, const QString& parent_group_id);
void moveModelsRequested(const QStringList& ids, const QString& parent_group_id);
void removeGroupRequested(const QString& id);
void removeModelRequested(const QString& id);