diff --git a/src/interface/modules/models/Controller.cpp b/src/interface/modules/models/Controller.cpp index f05b514124..17401f0c1a 100644 --- a/src/interface/modules/models/Controller.cpp +++ b/src/interface/modules/models/Controller.cpp @@ -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); diff --git a/src/interface/modules/models/Panel.cpp b/src/interface/modules/models/Panel.cpp index fc60f7ecc8..e7f992a9da 100644 --- a/src/interface/modules/models/Panel.cpp +++ b/src/interface/modules/models/Panel.cpp @@ -23,34 +23,239 @@ #include "../../components/Section.h" #include "../../components/SvgIcon.h" +#include +#include +#include #include #include #include #include +#include +#include #include #include #include +#include + 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 on_model_drop; + std::function on_group_drop; + +protected: + QStringList mimeTypes() const override { + return {QString::fromUtf8(kDragMimeType)}; + } + + QMimeData* mimeData(const QList& items) const override { + Q_UNUSED(items); + const QList selected = selectedItems(); + if (selected.isEmpty()) return nullptr; + + const int first_kind = selected.first()->data(0, Qt::UserRole).toInt(); + if (first_kind == static_cast(ItemKind::Group) && selected.size() != 1) { + return nullptr; + } + + QByteArray payload; + QDataStream stream(&payload, QIODevice::WriteOnly); + stream << first_kind; + if (first_kind == static_cast(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(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(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(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(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(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(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(item->data(0, Qt::UserRole).toInt()); +} + +QList selectedItemsOfKind(QTreeWidget* tree, ItemKind kind) { + QList 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(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 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 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 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); }); } diff --git a/src/interface/modules/models/Panel.h b/src/interface/modules/models/Panel.h index 36404234ac..bd2e5bc056 100644 --- a/src/interface/modules/models/Panel.h +++ b/src/interface/modules/models/Panel.h @@ -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);