diff --git a/core/src/datatypes/ref.h b/core/src/datatypes/ref.h index afca5a1..82841a2 100644 --- a/core/src/datatypes/ref.h +++ b/core/src/datatypes/ref.h @@ -2,12 +2,13 @@ #include "base.h" #include "error/data.h" +#include "utils.h" #include class Instance; class InstanceRef { - std::shared_ptr ref; + nullable std::shared_ptr ref; public: InstanceRef(); InstanceRef(std::weak_ptr); diff --git a/core/src/utils.h b/core/src/utils.h new file mode 100644 index 0000000..759dfd3 --- /dev/null +++ b/core/src/utils.h @@ -0,0 +1,10 @@ +#pragma once + +#ifdef __clang__ +#pragma clang diagnostic ignored "-Wnullability-extension" +#define nullable _Nullable +#define notnull _Nonnull +#else +#define nullable +#define notnull +#endif \ No newline at end of file diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 7213be1..c7d432c 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -26,6 +26,8 @@ set(PROJECT_SOURCES mainglwidget.cpp placedocument.h placedocument.cpp + undohistory.h + undohistory.cpp panes/explorerview.h panes/explorerview.cpp panes/explorermodel.h diff --git a/editor/mainwindow.cpp b/editor/mainwindow.cpp index 30a3a6a..3a47a7f 100644 --- a/editor/mainwindow.cpp +++ b/editor/mainwindow.cpp @@ -8,10 +8,12 @@ #include "objects/service/selection.h" #include "placedocument.h" #include "script/scriptdocument.h" +#include "undohistory.h" #include #include #include #include +#include #include #include #include @@ -45,15 +47,6 @@ inline bool isDarkMode() { QtMessageHandler defaultMessageHandler = nullptr; -// std::map QT_MESSAGE_TYPE_TO_LOG_LEVEL = { -// { QtMsgType::QtInfoMsg, Logger::LogLevel::INFO }, -// { QtMsgType::QtSystemMsg, Logger::LogLevel::INFO }, -// { QtMsgType::QtDebugMsg, Logger::LogLevel::DEBUG }, -// { QtMsgType::QtWarningMsg, Logger::LogLevel::WARNING }, -// { QtMsgType::QtCriticalMsg, Logger::LogLevel::ERROR }, -// { QtMsgType::QtFatalMsg, Logger::LogLevel::FATAL_ERROR }, -// }; - void logQtMessage(QtMsgType type, const QMessageLogContext &context, const QString &msg) { // Logger::log("[Qt] " + msg.toStdString(), QT_MESSAGE_TYPE_TO_LOG_LEVEL[type]); Logger::LogLevel logLevel = type == QtMsgType::QtFatalMsg ? Logger::LogLevel::FATAL_ERROR : Logger::LogLevel::DEBUG; @@ -78,6 +71,8 @@ MainWindow::MainWindow(QWidget *parent) ui->setupUi(this); setMouseTracking(true); + ui->actionRedo->setShortcuts({QKeySequence("Ctrl+Shift+Z"), QKeySequence("Ctrl+Y")}); + QIcon::setThemeSearchPaths(QIcon::themeSearchPaths() + QStringList { "./assets/icons" }); if (isDarkMode()) QIcon::setFallbackThemeName("editor-dark"); @@ -105,6 +100,7 @@ MainWindow::MainWindow(QWidget *parent) ui->mdiArea->currentSubWindow()->showMaximized(); ui->mdiArea->findChild()->setExpanding(false); placeDocument->init(); + ui->propertiesView->init(); ui->mdiArea->setTabsClosable(true); } @@ -278,12 +274,16 @@ void MainWindow::connectActionHandlers() { }); connect(ui->actionDelete, &QAction::triggered, this, [&]() { + UndoState historyState; std::shared_ptr selection = gDataModel->GetService(); for (std::weak_ptr inst : selection->Get()) { if (inst.expired()) continue; + historyState.push_back(UndoStateInstanceRemoved { inst.lock(), inst.lock()->GetParent().value() }); inst.lock()->SetParent(std::nullopt); } selection->Set({}); + historyState.push_back(UndoStateSelectionChanged {selection->Get(), {}}); + undoManager.PushState(historyState); }); connect(ui->actionCopy, &QAction::triggered, this, [&]() { @@ -301,15 +301,20 @@ void MainWindow::connectActionHandlers() { mimeData->setData("application/xml", QByteArray::fromStdString(encoded.str())); QApplication::clipboard()->setMimeData(mimeData); }); + connect(ui->actionCut, &QAction::triggered, this, [&]() { + UndoState historyState; pugi::xml_document rootDoc; std::shared_ptr selection = gDataModel->GetService(); for (std::weak_ptr inst : selection->Get()) { if (inst.expired()) continue; + historyState.push_back(UndoStateInstanceRemoved { inst.lock(), inst.lock()->GetParent().value() }); inst.lock()->Serialize(rootDoc); inst.lock()->SetParent(std::nullopt); } selection->Set({}); + historyState.push_back(UndoStateSelectionChanged {selection->Get(), {}}); + undoManager.PushState(historyState); std::ostringstream encoded; rootDoc.save(encoded); @@ -320,6 +325,7 @@ void MainWindow::connectActionHandlers() { }); connect(ui->actionPaste, &QAction::triggered, this, [&]() { + UndoState historyState; const QMimeData* mimeData = QApplication::clipboard()->mimeData(); if (!mimeData || !mimeData->hasFormat("application/xml")) return; QByteArray bytes = mimeData->data("application/xml"); @@ -331,11 +337,15 @@ void MainWindow::connectActionHandlers() { for (pugi::xml_node instNode : rootDoc.children()) { result, NoSuchInstance> inst = Instance::Deserialize(instNode); if (!inst) { inst.logError(); continue; } + historyState.push_back(UndoStateInstanceCreated { inst.expect(), gWorkspace() }); gWorkspace()->AddChild(inst.expect()); } + + undoManager.PushState(historyState); }); connect(ui->actionPasteInto, &QAction::triggered, this, [&]() { + UndoState historyState; std::shared_ptr selection = gDataModel->GetService(); if (selection->Get().size() != 1) return; @@ -352,17 +362,22 @@ void MainWindow::connectActionHandlers() { for (pugi::xml_node instNode : rootDoc.children()) { result, NoSuchInstance> inst = Instance::Deserialize(instNode); if (!inst) { inst.logError(); continue; } + historyState.push_back(UndoStateInstanceCreated { inst.expect(), selectedParent }); selectedParent->AddChild(inst.expect()); } + + undoManager.PushState(historyState); }); connect(ui->actionGroupObjects, &QAction::triggered, this, [&]() { + UndoState historyState; auto model = Model::New(); std::shared_ptr firstParent; std::shared_ptr selection = gDataModel->GetService(); for (auto object : selection->Get()) { if (firstParent == nullptr && object->GetParent().has_value()) firstParent = object->GetParent().value(); + historyState.push_back(UndoStateInstanceReparented { object, object->GetParent().value(), model }); object->SetParent(model); } @@ -372,13 +387,16 @@ void MainWindow::connectActionHandlers() { // Technically not how it works in the actual studio, but it's not an API-breaking change // and I think this implementation is more useful so I'm sticking with it if (firstParent == nullptr) firstParent = gWorkspace(); + historyState.push_back(UndoStateInstanceCreated { model, firstParent }); model->SetParent(firstParent); + historyState.push_back(UndoStateSelectionChanged { selection->Get(), { model } }); selection->Set({ model }); playSound("./assets/excluded/electronicpingshort.wav"); }); connect(ui->actionUngroupObjects, &QAction::triggered, this, [&]() { + UndoState historyState; std::vector> newSelection; std::shared_ptr selection = gDataModel->GetService(); @@ -387,13 +405,16 @@ void MainWindow::connectActionHandlers() { if (!model->IsA()) { newSelection.push_back(model); continue; } for (auto object : model->GetChildren()) { + historyState.push_back(UndoStateInstanceReparented { object, object->GetParent().value(), model->GetParent().value() }); object->SetParent(model->GetParent()); newSelection.push_back(object); } - model->Destroy(); + historyState.push_back(UndoStateInstanceRemoved { model, model->GetParent().value() }); + model->SetParent(std::nullopt); } + historyState.push_back(UndoStateSelectionChanged { selection->Get(), newSelection }); selection->Set(newSelection); playSound("./assets/excluded/electronicpingshort.wav"); }); @@ -417,6 +438,7 @@ void MainWindow::connectActionHandlers() { }); connect(ui->actionInsertModel, &QAction::triggered, this, [&]() { + UndoState historyState; std::shared_ptr selection = gDataModel->GetService(); if (selection->Get().size() != 1) return; std::shared_ptr selectedParent = selection->Get()[0]; @@ -431,10 +453,19 @@ void MainWindow::connectActionHandlers() { for (pugi::xml_node instNode : modelDoc.child("openblocks").children("Item")) { result, NoSuchInstance> inst = Instance::Deserialize(instNode); if (!inst) { inst.logError(); continue; } + historyState.push_back(UndoStateInstanceCreated { inst.expect(), selectedParent }); selectedParent->AddChild(inst.expect()); } }); + connect(ui->actionUndo, &QAction::triggered, this, [&]() { + undoManager.Undo(); + }); + + connect(ui->actionRedo, &QAction::triggered, this, [&]() { + undoManager.Redo(); + }); + connect(ui->actionAbout, &QAction::triggered, this, [this]() { AboutDialog* aboutDialog = new AboutDialog(this); aboutDialog->open(); diff --git a/editor/mainwindow.h b/editor/mainwindow.h index bef1914..4f8adae 100644 --- a/editor/mainwindow.h +++ b/editor/mainwindow.h @@ -6,6 +6,7 @@ #include "qbasictimer.h" #include "qcoreevent.h" #include "script/scriptdocument.h" +#include "undohistory.h" #include #include #include @@ -49,6 +50,8 @@ class MainWindow : public QMainWindow public: MainWindow(QWidget *parent = nullptr); ~MainWindow(); + + UndoHistory undoManager; SelectedTool selectedTool; GridSnappingMode snappingMode; diff --git a/editor/mainwindow.ui b/editor/mainwindow.ui index 62265fc..7a8c237 100644 --- a/editor/mainwindow.ui +++ b/editor/mainwindow.ui @@ -151,6 +151,8 @@ + + @@ -816,6 +818,34 @@ About... + + + + + + Undo + + + Ctrl+Z + + + QAction::MenuRole::NoRole + + + + + + + + Redo + + + Ctrl+Y + + + QAction::MenuRole::NoRole + + diff --git a/editor/panes/explorerview.cpp b/editor/panes/explorerview.cpp index 70ceb55..b5dd852 100644 --- a/editor/panes/explorerview.cpp +++ b/editor/panes/explorerview.cpp @@ -6,6 +6,7 @@ #include "objects/meta.h" #include "objects/script.h" #include "objects/service/selection.h" +#include "undohistory.h" #include #include #include @@ -116,6 +117,7 @@ void ExplorerView::buildContextMenu() { std::shared_ptr instParent = selection->Get()[0]; std::shared_ptr newInst = type->constructor(); newInst->SetParent(instParent); + M_mainWindow->undoManager.PushState({ UndoStateInstanceCreated { newInst, instParent } }); }); } } diff --git a/editor/panes/propertiesview.cpp b/editor/panes/propertiesview.cpp index fb0c823..61b3841 100644 --- a/editor/panes/propertiesview.cpp +++ b/editor/panes/propertiesview.cpp @@ -4,7 +4,9 @@ #include "datatypes/variant.h" #include "datatypes/primitives.h" #include "error/data.h" +#include "mainwindow.h" #include "objects/base/member.h" +#include "undohistory.h" #include #include @@ -206,6 +208,8 @@ public: : view->itemFromIndex(index.parent())->data(0, Qt::DisplayRole).toString().toStdString(); PropertyMeta meta = inst->GetPropertyMeta(propertyName).expect(); + Variant oldValue = inst->GetProperty(propertyName).expect(); + if (isComposite) { if (meta.type.descriptor == &Vector3::TYPE) { QDoubleSpinBox* spinBox = dynamic_cast(editor); @@ -264,12 +268,15 @@ public: model->setData(index, QString::fromStdString(parsedValue.ToString())); view->rebuildCompositeProperty(view->itemFromIndex(index), meta.type.descriptor, parsedValue); } + + Variant newValue = inst->GetProperty(propertyName).expect(); + view->undoManager->PushState({ UndoStatePropertyChanged { inst, propertyName, oldValue, newValue } }); } }; PropertiesView::PropertiesView(QWidget* parent): QTreeWidget(parent) { - + clear(); setHeaderHidden(true); setColumnCount(2); @@ -300,6 +307,10 @@ QStringList PROPERTY_CATEGORY_NAMES { "Surface Inputs", }; +void PropertiesView::init() { + undoManager = &dynamic_cast(window())->undoManager; +} + QModelIndex PropertiesView::indexAt(const QPoint &point) const { return QTreeWidget::indexAt(point + QPoint(indentation(), 0)); } @@ -402,6 +413,7 @@ void PropertiesView::propertyChanged(QTreeWidgetItem *item, int column) { if (meta.type.descriptor == &BOOL_TYPE) { inst->SetProperty(propertyName, item->checkState(1) == Qt::Checked).expect(); + undoManager->PushState({ UndoStatePropertyChanged { inst, propertyName, item->checkState(1) != Qt::Checked, item->checkState(1) == Qt::Checked } }); } } diff --git a/editor/panes/propertiesview.h b/editor/panes/propertiesview.h index 10ff10f..5572c6a 100644 --- a/editor/panes/propertiesview.h +++ b/editor/panes/propertiesview.h @@ -3,6 +3,7 @@ #include #include "datatypes/base.h" #include "objects/base/instance.h" +#include "undohistory.h" class Ui_MainWindow; class PropertiesItemDelegate; @@ -18,6 +19,8 @@ class PropertiesView : public QTreeWidget { void rebuildCompositeProperty(QTreeWidgetItem *item, const TypeDesc*, Variant); void onPropertyUpdated(std::shared_ptr instance, std::string property, Variant newValue); + UndoHistory* undoManager; + friend PropertiesItemDelegate; protected: void drawBranches(QPainter *painter, const QRect &rect, const QModelIndex &index) const override; @@ -26,5 +29,7 @@ public: PropertiesView(QWidget* parent = nullptr); ~PropertiesView() override; + void init(); + void setSelected(std::optional> instance); }; \ No newline at end of file diff --git a/editor/undohistory.cpp b/editor/undohistory.cpp new file mode 100644 index 0000000..17923c6 --- /dev/null +++ b/editor/undohistory.cpp @@ -0,0 +1,67 @@ +#include "undohistory.h" +#include "common.h" +#include "objects/service/selection.h" + +void UndoHistory::PushState(UndoState state) { + if (processingUndo) return; // Ignore PushState requests when changes are initiated by us + redoHistory = {}; + + if (maxBufferSize != -1 && (int)undoHistory.size() > maxBufferSize) + undoHistory.erase(undoHistory.begin(), undoHistory.begin()+maxBufferSize-(int)undoHistory.size()-1); + + undoHistory.push_back(state); +} + +void UndoHistory::Undo() { + if (undoHistory.size() == 0) return; + UndoState state = undoHistory.back(); + undoHistory.pop_back(); + redoHistory.push(state); + + processingUndo = true; + + for (UndoStateChange& change : state) { + // https://stackoverflow.com/a/63483353 + if (auto v = std::get_if(&change)) { + // The old value used to be valid, so it still should be... + v->affectedInstance->SetProperty(v->property, v->oldValue).expect(); + } else if (auto v = std::get_if(&change)) { + v->instance->SetParent(std::nullopt); + } else if (auto v = std::get_if(&change)) { + v->instance->SetParent(v->oldParent); + } else if (auto v = std::get_if(&change)) { + v->instance->SetParent(v->oldParent); + } else if (auto v = std::get_if(&change)) { + gDataModel->GetService()->Set(v->oldSelection); + } + } + + processingUndo = false; +} + +void UndoHistory::Redo() { + if (redoHistory.size() == 0) return; + UndoState state = redoHistory.top(); + redoHistory.pop(); + undoHistory.push_back(state); + + processingUndo = true; + + for (UndoStateChange& change : state) { + // https://stackoverflow.com/a/63483353 + if (auto v = std::get_if(&change)) { + // The old value used to be valid, so it still should be... + v->affectedInstance->SetProperty(v->property, v->newValue).expect(); + } else if (auto v = std::get_if(&change)) { + v->instance->SetParent(v->newParent); + } else if (auto v = std::get_if(&change)) { + v->instance->SetParent(std::nullopt); + } else if (auto v = std::get_if(&change)) { + v->instance->SetParent(v->newParent); + } else if (auto v = std::get_if(&change)) { + gDataModel->GetService()->Set(v->newSelection); + } + } + + processingUndo = false; +} \ No newline at end of file diff --git a/editor/undohistory.h b/editor/undohistory.h new file mode 100644 index 0000000..bace6d8 --- /dev/null +++ b/editor/undohistory.h @@ -0,0 +1,53 @@ +#pragma once + +#include "datatypes/signal.h" +#include "datatypes/variant.h" +#include "objects/base/instance.h" +#include "utils.h" +#include +#include +#include + +struct UndoStatePropertyChanged { + std::shared_ptr affectedInstance; + std::string property; + Variant oldValue; + Variant newValue; +}; + +struct UndoStateInstanceCreated { + std::shared_ptr instance; + std::shared_ptr newParent; +}; + +struct UndoStateInstanceRemoved { + std::shared_ptr instance; + std::shared_ptr oldParent; +}; + +struct UndoStateInstanceReparented { + std::shared_ptr instance; + nullable std::shared_ptr oldParent; + nullable std::shared_ptr newParent; +}; + +struct UndoStateSelectionChanged { + std::vector> oldSelection; + std::vector> newSelection; +}; + +typedef std::variant UndoStateChange; +typedef std::vector UndoState; + +class UndoHistory { + // Ignore PushState requests + bool processingUndo = false; + std::deque undoHistory; + std::stack redoHistory; +public: + int maxBufferSize = 100; + + void PushState(UndoState); + void Undo(); + void Redo(); +}; \ No newline at end of file