feat(editor): undoing and redoing

This commit is contained in:
maelstrom 2025-06-23 17:40:56 +02:00
parent 6800ac27f3
commit b5fc91eea0
11 changed files with 228 additions and 12 deletions

View file

@ -2,12 +2,13 @@
#include "base.h"
#include "error/data.h"
#include "utils.h"
#include <memory>
class Instance;
class InstanceRef {
std::shared_ptr<Instance> ref;
nullable std::shared_ptr<Instance> ref;
public:
InstanceRef();
InstanceRef(std::weak_ptr<Instance>);

10
core/src/utils.h Normal file
View file

@ -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

View file

@ -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

View file

@ -8,10 +8,12 @@
#include "objects/service/selection.h"
#include "placedocument.h"
#include "script/scriptdocument.h"
#include "undohistory.h"
#include <memory>
#include <qclipboard.h>
#include <qevent.h>
#include <qglobal.h>
#include <qkeysequence.h>
#include <qmessagebox.h>
#include <qmimedata.h>
#include <qnamespace.h>
@ -45,15 +47,6 @@ inline bool isDarkMode() {
QtMessageHandler defaultMessageHandler = nullptr;
// std::map<QtMsgType, Logger::LogLevel> 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<QTabBar*>()->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> selection = gDataModel->GetService<Selection>();
for (std::weak_ptr<Instance> 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> selection = gDataModel->GetService<Selection>();
for (std::weak_ptr<Instance> 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<std::shared_ptr<Instance>, 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> selection = gDataModel->GetService<Selection>();
if (selection->Get().size() != 1) return;
@ -352,17 +362,22 @@ void MainWindow::connectActionHandlers() {
for (pugi::xml_node instNode : rootDoc.children()) {
result<std::shared_ptr<Instance>, 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<Instance> firstParent;
std::shared_ptr<Selection> selection = gDataModel->GetService<Selection>();
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<std::shared_ptr<Instance>> newSelection;
std::shared_ptr<Selection> selection = gDataModel->GetService<Selection>();
@ -387,13 +405,16 @@ void MainWindow::connectActionHandlers() {
if (!model->IsA<Model>()) { 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> selection = gDataModel->GetService<Selection>();
if (selection->Get().size() != 1) return;
std::shared_ptr<Instance> selectedParent = selection->Get()[0];
@ -431,10 +453,19 @@ void MainWindow::connectActionHandlers() {
for (pugi::xml_node instNode : modelDoc.child("openblocks").children("Item")) {
result<std::shared_ptr<Instance>, 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();

View file

@ -6,6 +6,7 @@
#include "qbasictimer.h"
#include "qcoreevent.h"
#include "script/scriptdocument.h"
#include "undohistory.h"
#include <QMainWindow>
#include <QLineEdit>
#include <map>
@ -50,6 +51,8 @@ public:
MainWindow(QWidget *parent = nullptr);
~MainWindow();
UndoHistory undoManager;
SelectedTool selectedTool;
GridSnappingMode snappingMode;
bool editSoundEffects = true;

View file

@ -151,6 +151,8 @@
<addaction name="actionNew"/>
<addaction name="actionOpen"/>
<addaction name="actionSave"/>
<addaction name="actionUndo"/>
<addaction name="actionRedo"/>
</widget>
<widget class="QToolBar" name="transformTools">
<property name="windowTitle">
@ -816,6 +818,34 @@
<string>About...</string>
</property>
</action>
<action name="actionUndo">
<property name="icon">
<iconset theme="edit-undo"/>
</property>
<property name="text">
<string>Undo</string>
</property>
<property name="shortcut">
<string>Ctrl+Z</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionRedo">
<property name="icon">
<iconset theme="edit-redo"/>
</property>
<property name="text">
<string>Redo</string>
</property>
<property name="shortcut">
<string>Ctrl+Y</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
</widget>
<customwidgets>
<customwidget>

View file

@ -6,6 +6,7 @@
#include "objects/meta.h"
#include "objects/script.h"
#include "objects/service/selection.h"
#include "undohistory.h"
#include <memory>
#include <qaction.h>
#include <qtreeview.h>
@ -116,6 +117,7 @@ void ExplorerView::buildContextMenu() {
std::shared_ptr<Instance> instParent = selection->Get()[0];
std::shared_ptr<Instance> newInst = type->constructor();
newInst->SetParent(instParent);
M_mainWindow->undoManager.PushState({ UndoStateInstanceCreated { newInst, instParent } });
});
}
}

View file

@ -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 <QColorDialog>
#include <QComboBox>
@ -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<QDoubleSpinBox*>(editor);
@ -264,6 +268,9 @@ 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 } });
}
};
@ -300,6 +307,10 @@ QStringList PROPERTY_CATEGORY_NAMES {
"Surface Inputs",
};
void PropertiesView::init() {
undoManager = &dynamic_cast<MainWindow*>(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 } });
}
}

View file

@ -3,6 +3,7 @@
#include <QTreeWidget>
#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> 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<std::shared_ptr<Instance>> instance);
};

67
editor/undohistory.cpp Normal file
View file

@ -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<UndoStatePropertyChanged>(&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<UndoStateInstanceCreated>(&change)) {
v->instance->SetParent(std::nullopt);
} else if (auto v = std::get_if<UndoStateInstanceRemoved>(&change)) {
v->instance->SetParent(v->oldParent);
} else if (auto v = std::get_if<UndoStateInstanceReparented>(&change)) {
v->instance->SetParent(v->oldParent);
} else if (auto v = std::get_if<UndoStateSelectionChanged>(&change)) {
gDataModel->GetService<Selection>()->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<UndoStatePropertyChanged>(&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<UndoStateInstanceCreated>(&change)) {
v->instance->SetParent(v->newParent);
} else if (auto v = std::get_if<UndoStateInstanceRemoved>(&change)) {
v->instance->SetParent(std::nullopt);
} else if (auto v = std::get_if<UndoStateInstanceReparented>(&change)) {
v->instance->SetParent(v->newParent);
} else if (auto v = std::get_if<UndoStateSelectionChanged>(&change)) {
gDataModel->GetService<Selection>()->Set(v->newSelection);
}
}
processingUndo = false;
}

53
editor/undohistory.h Normal file
View file

@ -0,0 +1,53 @@
#pragma once
#include "datatypes/signal.h"
#include "datatypes/variant.h"
#include "objects/base/instance.h"
#include "utils.h"
#include <deque>
#include <memory>
#include <stack>
struct UndoStatePropertyChanged {
std::shared_ptr<Instance> affectedInstance;
std::string property;
Variant oldValue;
Variant newValue;
};
struct UndoStateInstanceCreated {
std::shared_ptr<Instance> instance;
std::shared_ptr<Instance> newParent;
};
struct UndoStateInstanceRemoved {
std::shared_ptr<Instance> instance;
std::shared_ptr<Instance> oldParent;
};
struct UndoStateInstanceReparented {
std::shared_ptr<Instance> instance;
nullable std::shared_ptr<Instance> oldParent;
nullable std::shared_ptr<Instance> newParent;
};
struct UndoStateSelectionChanged {
std::vector<std::shared_ptr<Instance>> oldSelection;
std::vector<std::shared_ptr<Instance>> newSelection;
};
typedef std::variant<UndoStatePropertyChanged, UndoStateInstanceCreated, UndoStateInstanceRemoved, UndoStateInstanceReparented, UndoStateSelectionChanged> UndoStateChange;
typedef std::vector<UndoStateChange> UndoState;
class UndoHistory {
// Ignore PushState requests
bool processingUndo = false;
std::deque<UndoState> undoHistory;
std::stack<UndoState> redoHistory;
public:
int maxBufferSize = 100;
void PushState(UndoState);
void Undo();
void Redo();
};