mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
1388 lines
48 KiB
C++
1388 lines
48 KiB
C++
#include "controller.h"
|
|
#include "generator.h"
|
|
#include "pluginmanager.h"
|
|
#include <QApplication>
|
|
#include <QMainWindow>
|
|
#include <QMdiArea>
|
|
#include <QMdiSubWindow>
|
|
#include <QMenuBar>
|
|
#include <QToolBar>
|
|
#include <QStatusBar>
|
|
#include <QLabel>
|
|
#include <QSplitter>
|
|
#include <QTabWidget>
|
|
#include <QTabBar>
|
|
#include <QPointer>
|
|
#include <QFileDialog>
|
|
#include <QFileInfo>
|
|
#include <QMessageBox>
|
|
#include <QAction>
|
|
#include <QActionGroup>
|
|
#include <QMap>
|
|
#include <QTimer>
|
|
#include <QDir>
|
|
#include <QMetaObject>
|
|
#include <QFontDatabase>
|
|
#include <QPainter>
|
|
#include <QSvgRenderer>
|
|
#include <QSettings>
|
|
#include <QDockWidget>
|
|
#include <QTreeView>
|
|
#include <QStandardItemModel>
|
|
#include <QListWidget>
|
|
#include <QPushButton>
|
|
#include "workspace_model.h"
|
|
#include <QTableWidget>
|
|
#include <QHeaderView>
|
|
#include <QDialogButtonBox>
|
|
#include <QVBoxLayout>
|
|
#include <QDialog>
|
|
#include <Qsci/qsciscintilla.h>
|
|
#include <Qsci/qscilexercpp.h>
|
|
#include <QProxyStyle>
|
|
|
|
#ifdef _WIN32
|
|
#include <windows.h>
|
|
#include <dbghelp.h>
|
|
#include <cstdio>
|
|
|
|
static LONG WINAPI crashHandler(EXCEPTION_POINTERS* ep) {
|
|
fprintf(stderr, "\n=== UNHANDLED EXCEPTION ===\n");
|
|
fprintf(stderr, "Code : 0x%08lX\n", ep->ExceptionRecord->ExceptionCode);
|
|
fprintf(stderr, "Addr : %p\n", ep->ExceptionRecord->ExceptionAddress);
|
|
|
|
HANDLE process = GetCurrentProcess();
|
|
HANDLE thread = GetCurrentThread();
|
|
SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME);
|
|
SymInitialize(process, NULL, TRUE);
|
|
|
|
CONTEXT* ctx = ep->ContextRecord;
|
|
STACKFRAME64 frame = {};
|
|
DWORD machineType;
|
|
#ifdef _M_X64
|
|
machineType = IMAGE_FILE_MACHINE_AMD64;
|
|
frame.AddrPC.Offset = ctx->Rip;
|
|
frame.AddrFrame.Offset = ctx->Rbp;
|
|
frame.AddrStack.Offset = ctx->Rsp;
|
|
#else
|
|
machineType = IMAGE_FILE_MACHINE_I386;
|
|
frame.AddrPC.Offset = ctx->Eip;
|
|
frame.AddrFrame.Offset = ctx->Ebp;
|
|
frame.AddrStack.Offset = ctx->Esp;
|
|
#endif
|
|
frame.AddrPC.Mode = AddrModeFlat;
|
|
frame.AddrFrame.Mode = AddrModeFlat;
|
|
frame.AddrStack.Mode = AddrModeFlat;
|
|
|
|
fprintf(stderr, "\nStack trace:\n");
|
|
for (int i = 0; i < 64; i++) {
|
|
if (!StackWalk64(machineType, process, thread, &frame, ctx,
|
|
NULL, SymFunctionTableAccess64,
|
|
SymGetModuleBase64, NULL))
|
|
break;
|
|
if (frame.AddrPC.Offset == 0) break;
|
|
|
|
char buf[sizeof(SYMBOL_INFO) + 256];
|
|
SYMBOL_INFO* sym = reinterpret_cast<SYMBOL_INFO*>(buf);
|
|
sym->SizeOfStruct = sizeof(SYMBOL_INFO);
|
|
sym->MaxNameLen = 255;
|
|
|
|
DWORD64 disp64 = 0;
|
|
DWORD disp32 = 0;
|
|
IMAGEHLP_LINE64 line = {};
|
|
line.SizeOfStruct = sizeof(line);
|
|
|
|
bool hasSym = SymFromAddr(process, frame.AddrPC.Offset, &disp64, sym);
|
|
bool hasLine = SymGetLineFromAddr64(process, frame.AddrPC.Offset,
|
|
&disp32, &line);
|
|
if (hasSym && hasLine) {
|
|
fprintf(stderr, " [%2d] %s+0x%llx (%s:%lu)\n",
|
|
i, sym->Name, (unsigned long long)disp64,
|
|
line.FileName, line.LineNumber);
|
|
} else if (hasSym) {
|
|
fprintf(stderr, " [%2d] %s+0x%llx\n",
|
|
i, sym->Name, (unsigned long long)disp64);
|
|
} else {
|
|
fprintf(stderr, " [%2d] 0x%llx\n",
|
|
i, (unsigned long long)frame.AddrPC.Offset);
|
|
}
|
|
}
|
|
|
|
SymCleanup(process);
|
|
fprintf(stderr, "=== END CRASH ===\n");
|
|
fflush(stderr);
|
|
return EXCEPTION_EXECUTE_HANDLER;
|
|
}
|
|
#endif
|
|
|
|
class MenuBarStyle : public QProxyStyle {
|
|
public:
|
|
using QProxyStyle::QProxyStyle;
|
|
QSize sizeFromContents(ContentsType type, const QStyleOption* opt,
|
|
const QSize& sz, const QWidget* w) const override {
|
|
QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w);
|
|
if (type == CT_MenuBarItem)
|
|
s.setHeight(s.height() + qRound(s.height() * 0.5));
|
|
return s;
|
|
}
|
|
};
|
|
|
|
namespace rcx {
|
|
|
|
class MainWindow : public QMainWindow {
|
|
Q_OBJECT
|
|
public:
|
|
explicit MainWindow(QWidget* parent = nullptr);
|
|
|
|
private slots:
|
|
void newFile();
|
|
void newDocument();
|
|
void selfTest();
|
|
void openFile();
|
|
void saveFile();
|
|
void saveFileAs();
|
|
|
|
|
|
void addNode();
|
|
void removeNode();
|
|
void changeNodeType();
|
|
void renameNodeAction();
|
|
void duplicateNodeAction();
|
|
void splitView();
|
|
void unsplitView();
|
|
|
|
void undo();
|
|
void redo();
|
|
void about();
|
|
void setEditorFont(const QString& fontName);
|
|
void exportCpp();
|
|
void showTypeAliasesDialog();
|
|
|
|
public:
|
|
// Project Lifecycle API
|
|
QMdiSubWindow* project_new();
|
|
QMdiSubWindow* project_open(const QString& path = {});
|
|
bool project_save(QMdiSubWindow* sub = nullptr, bool saveAs = false);
|
|
void project_close(QMdiSubWindow* sub = nullptr);
|
|
|
|
private:
|
|
enum ViewMode { VM_Reclass, VM_Rendered };
|
|
|
|
QMdiArea* m_mdiArea;
|
|
QLabel* m_statusLabel;
|
|
PluginManager m_pluginManager;
|
|
|
|
struct SplitPane {
|
|
QTabWidget* tabWidget = nullptr;
|
|
RcxEditor* editor = nullptr;
|
|
QsciScintilla* rendered = nullptr;
|
|
ViewMode viewMode = VM_Reclass;
|
|
uint64_t lastRenderedRootId = 0;
|
|
};
|
|
|
|
struct TabState {
|
|
RcxDocument* doc;
|
|
RcxController* ctrl;
|
|
QSplitter* splitter;
|
|
QVector<SplitPane> panes;
|
|
int activePaneIdx = 0;
|
|
};
|
|
QMap<QMdiSubWindow*, TabState> m_tabs;
|
|
|
|
|
|
void createMenus();
|
|
void createStatusBar();
|
|
void showPluginsDialog();
|
|
QIcon makeIcon(const QString& svgPath);
|
|
|
|
RcxController* activeController() const;
|
|
TabState* activeTab();
|
|
QMdiSubWindow* createTab(RcxDocument* doc);
|
|
void updateWindowTitle();
|
|
|
|
void setViewMode(ViewMode mode);
|
|
void updateRenderedView(TabState& tab, SplitPane& pane);
|
|
void updateAllRenderedPanes(TabState& tab);
|
|
uint64_t findRootStructForNode(const NodeTree& tree, uint64_t nodeId) const;
|
|
void setupRenderedSci(QsciScintilla* sci);
|
|
|
|
SplitPane createSplitPane(TabState& tab);
|
|
void applyTabWidgetStyle(QTabWidget* tw);
|
|
SplitPane* findPaneByTabWidget(QTabWidget* tw);
|
|
SplitPane* findActiveSplitPane();
|
|
RcxEditor* activePaneEditor();
|
|
|
|
// Workspace dock
|
|
QDockWidget* m_workspaceDock = nullptr;
|
|
QTreeView* m_workspaceTree = nullptr;
|
|
QStandardItemModel* m_workspaceModel = nullptr;
|
|
void createWorkspaceDock();
|
|
void rebuildWorkspaceModel();
|
|
};
|
|
|
|
MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
|
|
setWindowTitle("ReclassX");
|
|
resize(1200, 800);
|
|
|
|
m_mdiArea = new QMdiArea(this);
|
|
m_mdiArea->setViewMode(QMdiArea::TabbedView);
|
|
m_mdiArea->setTabsClosable(true);
|
|
m_mdiArea->setTabsMovable(true);
|
|
m_mdiArea->setStyleSheet(QStringLiteral(
|
|
"QTabBar::tab {"
|
|
" background: #1e1e1e;"
|
|
" color: #585858;"
|
|
" padding: 6px 16px;"
|
|
" border: none;"
|
|
"}"
|
|
"QTabBar::tab:selected {"
|
|
" color: #d4d4d4;"
|
|
" background: #252526;"
|
|
"}"
|
|
"QTabBar::tab:hover {"
|
|
" color: #d4d4d4;"
|
|
" background: #2b2b2b;"
|
|
"}"
|
|
));
|
|
setCentralWidget(m_mdiArea);
|
|
|
|
createWorkspaceDock();
|
|
createMenus();
|
|
createStatusBar();
|
|
|
|
|
|
// Larger click targets on menu bar
|
|
{
|
|
menuBar()->setStyle(new MenuBarStyle(menuBar()->style()));
|
|
}
|
|
|
|
// Load plugins
|
|
m_pluginManager.LoadPlugins();
|
|
|
|
connect(m_mdiArea, &QMdiArea::subWindowActivated,
|
|
this, [this](QMdiSubWindow*) {
|
|
updateWindowTitle();
|
|
rebuildWorkspaceModel();
|
|
});
|
|
|
|
// Track which split pane has focus (for menu-driven view switching)
|
|
connect(qApp, &QApplication::focusChanged, this, [this](QWidget*, QWidget* now) {
|
|
if (!now) return;
|
|
auto* tab = activeTab();
|
|
if (!tab) return;
|
|
for (int i = 0; i < tab->panes.size(); ++i) {
|
|
if (tab->panes[i].tabWidget && tab->panes[i].tabWidget->isAncestorOf(now)) {
|
|
tab->activePaneIdx = i;
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
QIcon MainWindow::makeIcon(const QString& svgPath) {
|
|
return QIcon(svgPath);
|
|
}
|
|
|
|
void MainWindow::createMenus() {
|
|
// File
|
|
auto* file = menuBar()->addMenu("&File");
|
|
file->addAction("&New", QKeySequence::New, this, &MainWindow::newDocument);
|
|
file->addAction("New &Tab", QKeySequence(Qt::CTRL | Qt::Key_T), this, &MainWindow::newFile);
|
|
file->addAction(makeIcon(":/vsicons/folder-opened.svg"), "&Open...", QKeySequence::Open, this, &MainWindow::openFile);
|
|
file->addSeparator();
|
|
file->addAction(makeIcon(":/vsicons/save.svg"), "&Save", QKeySequence::Save, this, &MainWindow::saveFile);
|
|
file->addAction(makeIcon(":/vsicons/save-as.svg"), "Save &As...", QKeySequence::SaveAs, this, &MainWindow::saveFileAs);
|
|
file->addSeparator();
|
|
file->addAction(makeIcon(":/vsicons/export.svg"), "Export &C++ Header...", this, &MainWindow::exportCpp);
|
|
file->addSeparator();
|
|
file->addAction(makeIcon(":/vsicons/close.svg"), "E&xit", QKeySequence(Qt::Key_Close), this, &QMainWindow::close);
|
|
|
|
// Edit
|
|
auto* edit = menuBar()->addMenu("&Edit");
|
|
edit->addAction(makeIcon(":/vsicons/arrow-left.svg"), "&Undo", QKeySequence::Undo, this, &MainWindow::undo);
|
|
edit->addAction(makeIcon(":/vsicons/arrow-right.svg"), "&Redo", QKeySequence::Redo, this, &MainWindow::redo);
|
|
edit->addSeparator();
|
|
edit->addAction("&Type Aliases...", this, &MainWindow::showTypeAliasesDialog);
|
|
|
|
// View
|
|
auto* view = menuBar()->addMenu("&View");
|
|
view->addAction(makeIcon(":/vsicons/split-horizontal.svg"), "Split &Horizontal", this, &MainWindow::splitView);
|
|
view->addAction(makeIcon(":/vsicons/chrome-close.svg"), "&Unsplit", this, &MainWindow::unsplitView);
|
|
view->addSeparator();
|
|
auto* fontMenu = view->addMenu(makeIcon(":/vsicons/text-size.svg"), "&Font");
|
|
auto* fontGroup = new QActionGroup(this);
|
|
fontGroup->setExclusive(true);
|
|
auto* actConsolas = fontMenu->addAction("Consolas");
|
|
actConsolas->setCheckable(true);
|
|
actConsolas->setActionGroup(fontGroup);
|
|
auto* actIosevka = fontMenu->addAction("Iosevka");
|
|
actIosevka->setCheckable(true);
|
|
actIosevka->setActionGroup(fontGroup);
|
|
// Load saved preference
|
|
QSettings settings("ReclassX", "ReclassX");
|
|
QString savedFont = settings.value("font", "Consolas").toString();
|
|
if (savedFont == "Iosevka") actIosevka->setChecked(true);
|
|
else actConsolas->setChecked(true);
|
|
connect(actConsolas, &QAction::triggered, this, [this]() { setEditorFont("Consolas"); });
|
|
connect(actIosevka, &QAction::triggered, this, [this]() { setEditorFont("Iosevka"); });
|
|
|
|
view->addSeparator();
|
|
view->addAction(m_workspaceDock->toggleViewAction());
|
|
|
|
// Node
|
|
auto* node = menuBar()->addMenu("&Node");
|
|
node->addAction(makeIcon(":/vsicons/add.svg"), "&Add Field", QKeySequence(Qt::Key_Insert), this, &MainWindow::addNode);
|
|
node->addAction(makeIcon(":/vsicons/remove.svg"), "&Remove Field", QKeySequence::Delete, this, &MainWindow::removeNode);
|
|
node->addAction(makeIcon(":/vsicons/symbol-structure.svg"), "Change &Type", QKeySequence(Qt::Key_T), this, &MainWindow::changeNodeType);
|
|
node->addAction(makeIcon(":/vsicons/edit.svg"), "Re&name", QKeySequence(Qt::Key_F2), this, &MainWindow::renameNodeAction);
|
|
node->addAction(makeIcon(":/vsicons/files.svg"), "D&uplicate", this, &MainWindow::duplicateNodeAction)->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_D));
|
|
|
|
// Plugins
|
|
auto* plugins = menuBar()->addMenu("&Plugins");
|
|
plugins->addAction("&Manage Plugins...", this, &MainWindow::showPluginsDialog);
|
|
|
|
// Help
|
|
auto* help = menuBar()->addMenu("&Help");
|
|
help->addAction(makeIcon(":/vsicons/question.svg"), "&About ReclassX", this, &MainWindow::about);
|
|
}
|
|
|
|
void MainWindow::createStatusBar() {
|
|
m_statusLabel = new QLabel("Ready");
|
|
m_statusLabel->setContentsMargins(10, 0, 0, 0);
|
|
statusBar()->addWidget(m_statusLabel, 1);
|
|
statusBar()->setStyleSheet("QStatusBar { background: #252526; color: #858585; }");
|
|
|
|
QSettings settings("ReclassX", "ReclassX");
|
|
QString fontName = settings.value("font", "Consolas").toString();
|
|
QFont f(fontName, 12);
|
|
f.setFixedPitch(true);
|
|
statusBar()->setFont(f);
|
|
}
|
|
|
|
void MainWindow::applyTabWidgetStyle(QTabWidget* tw) {
|
|
QSettings settings("ReclassX", "ReclassX");
|
|
QString fontName = settings.value("font", "Consolas").toString();
|
|
QFont tabFont(fontName, 12);
|
|
tabFont.setFixedPitch(true);
|
|
tw->tabBar()->setFont(tabFont);
|
|
tw->setStyleSheet(QStringLiteral(
|
|
"QTabWidget::pane { border: none; }"
|
|
"QTabBar::tab {"
|
|
" background: #1e1e1e;"
|
|
" color: #585858;"
|
|
" padding: 4px 12px;"
|
|
" border: none;"
|
|
" min-width: 60px;"
|
|
"}"
|
|
"QTabBar::tab:selected {"
|
|
" color: #d4d4d4;"
|
|
"}"
|
|
"QTabBar::tab:hover {"
|
|
" color: #d4d4d4;"
|
|
" background: #2b2b2b;"
|
|
"}"
|
|
));
|
|
tw->tabBar()->setExpanding(false);
|
|
}
|
|
|
|
MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) {
|
|
SplitPane pane;
|
|
|
|
pane.tabWidget = new QTabWidget;
|
|
pane.tabWidget->setTabPosition(QTabWidget::South);
|
|
applyTabWidgetStyle(pane.tabWidget);
|
|
|
|
// Create editor via controller (parent = tabWidget for ownership)
|
|
pane.editor = tab.ctrl->addSplitEditor(pane.tabWidget);
|
|
pane.tabWidget->addTab(pane.editor, "Reclass"); // index 0
|
|
|
|
// Create per-pane rendered C++ view
|
|
pane.rendered = new QsciScintilla;
|
|
setupRenderedSci(pane.rendered);
|
|
pane.tabWidget->addTab(pane.rendered, "C/C++"); // index 1
|
|
|
|
pane.tabWidget->setCurrentIndex(0);
|
|
pane.viewMode = VM_Reclass;
|
|
|
|
// Add to splitter
|
|
tab.splitter->addWidget(pane.tabWidget);
|
|
|
|
// Connect per-pane tab bar switching
|
|
QTabWidget* tw = pane.tabWidget;
|
|
connect(tw, &QTabWidget::currentChanged, this, [this, tw](int index) {
|
|
// Find which pane this QTabWidget belongs to
|
|
SplitPane* p = findPaneByTabWidget(tw);
|
|
if (!p) return;
|
|
|
|
if (index == 1) p->viewMode = VM_Rendered;
|
|
else p->viewMode = VM_Reclass;
|
|
|
|
if (index == 1) {
|
|
// Find the TabState that owns this pane and update rendered view
|
|
for (auto& tab : m_tabs) {
|
|
for (auto& pane : tab.panes) {
|
|
if (&pane == p) {
|
|
updateRenderedView(tab, pane);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return pane;
|
|
}
|
|
|
|
MainWindow::SplitPane* MainWindow::findPaneByTabWidget(QTabWidget* tw) {
|
|
for (auto& tab : m_tabs) {
|
|
for (auto& pane : tab.panes) {
|
|
if (pane.tabWidget == tw)
|
|
return &pane;
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
MainWindow::SplitPane* MainWindow::findActiveSplitPane() {
|
|
auto* tab = activeTab();
|
|
if (!tab || tab->panes.isEmpty()) return nullptr;
|
|
int idx = qBound(0, tab->activePaneIdx, tab->panes.size() - 1);
|
|
return &tab->panes[idx];
|
|
}
|
|
|
|
RcxEditor* MainWindow::activePaneEditor() {
|
|
auto* pane = findActiveSplitPane();
|
|
return pane ? pane->editor : nullptr;
|
|
}
|
|
|
|
QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
|
|
auto* splitter = new QSplitter(Qt::Horizontal);
|
|
auto* ctrl = new RcxController(doc, splitter);
|
|
|
|
auto* sub = m_mdiArea->addSubWindow(splitter);
|
|
sub->setWindowTitle(doc->filePath.isEmpty()
|
|
? "Untitled" : QFileInfo(doc->filePath).fileName());
|
|
sub->setAttribute(Qt::WA_DeleteOnClose);
|
|
sub->showMaximized();
|
|
|
|
m_tabs[sub] = { doc, ctrl, splitter, {}, 0 };
|
|
auto& tab = m_tabs[sub];
|
|
|
|
// Create the initial split pane
|
|
tab.panes.append(createSplitPane(tab));
|
|
|
|
connect(sub, &QObject::destroyed, this, [this, sub]() {
|
|
auto it = m_tabs.find(sub);
|
|
if (it != m_tabs.end()) {
|
|
it->doc->deleteLater();
|
|
m_tabs.erase(it);
|
|
}
|
|
rebuildWorkspaceModel();
|
|
});
|
|
|
|
connect(ctrl, &RcxController::nodeSelected,
|
|
this, [this, ctrl, sub](int nodeIdx) {
|
|
if (nodeIdx >= 0 && nodeIdx < ctrl->document()->tree.nodes.size()) {
|
|
auto& node = ctrl->document()->tree.nodes[nodeIdx];
|
|
auto* ap = findActiveSplitPane();
|
|
if (ap && ap->viewMode == VM_Rendered)
|
|
m_statusLabel->setText(
|
|
QString("Rendered: %1 %2")
|
|
.arg(kindToString(node.kind))
|
|
.arg(node.name));
|
|
else
|
|
m_statusLabel->setText(
|
|
QString("%1 %2 offset: 0x%3 size: %4 bytes")
|
|
.arg(kindToString(node.kind))
|
|
.arg(node.name)
|
|
.arg(node.offset, 4, 16, QChar('0'))
|
|
.arg(node.byteSize()));
|
|
} else {
|
|
m_statusLabel->setText("Ready");
|
|
}
|
|
// Update all rendered panes on selection change
|
|
auto it = m_tabs.find(sub);
|
|
if (it != m_tabs.end())
|
|
updateAllRenderedPanes(*it);
|
|
});
|
|
connect(ctrl, &RcxController::selectionChanged,
|
|
this, [this](int count) {
|
|
if (count == 0)
|
|
m_statusLabel->setText("Ready");
|
|
else if (count > 1)
|
|
m_statusLabel->setText(QString("%1 nodes selected").arg(count));
|
|
});
|
|
|
|
// Update rendered panes and workspace on document changes and undo/redo
|
|
connect(doc, &RcxDocument::documentChanged,
|
|
this, [this, sub]() {
|
|
auto it = m_tabs.find(sub);
|
|
if (it != m_tabs.end())
|
|
QTimer::singleShot(0, this, [this, sub]() {
|
|
auto it2 = m_tabs.find(sub);
|
|
if (it2 != m_tabs.end()) updateAllRenderedPanes(*it2);
|
|
rebuildWorkspaceModel();
|
|
});
|
|
});
|
|
connect(&doc->undoStack, &QUndoStack::indexChanged,
|
|
this, [this, sub](int) {
|
|
auto it = m_tabs.find(sub);
|
|
if (it != m_tabs.end())
|
|
QTimer::singleShot(0, this, [this, sub]() {
|
|
auto it2 = m_tabs.find(sub);
|
|
if (it2 != m_tabs.end()) updateAllRenderedPanes(*it2);
|
|
});
|
|
});
|
|
|
|
// Auto-focus on first root struct (don't show all roots)
|
|
for (const auto& n : doc->tree.nodes) {
|
|
if (n.parentId == 0 && n.kind == NodeKind::Struct) {
|
|
ctrl->setViewRootId(n.id);
|
|
break;
|
|
}
|
|
}
|
|
|
|
ctrl->refresh();
|
|
rebuildWorkspaceModel();
|
|
return sub;
|
|
}
|
|
|
|
// Build Ball + Material demo structs into a tree
|
|
static void buildBallDemo(NodeTree& tree) {
|
|
// Ball struct (128 bytes = 0x80)
|
|
Node ball;
|
|
ball.kind = NodeKind::Struct;
|
|
ball.name = "aBall";
|
|
ball.structTypeName = "Ball";
|
|
ball.parentId = 0;
|
|
ball.offset = 0;
|
|
int bi = tree.addNode(ball);
|
|
uint64_t ballId = tree.nodes[bi].id;
|
|
|
|
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_00"; n.parentId = ballId; n.offset = 0; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_08"; n.parentId = ballId; n.offset = 8; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Vec4; n.name = "position"; n.parentId = ballId; n.offset = 16; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Vec3; n.name = "velocity"; n.parentId = ballId; n.offset = 32; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex32; n.name = "field_2C"; n.parentId = ballId; n.offset = 44; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = ballId; n.offset = 48; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 52; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Float; n.name = "radius"; n.parentId = ballId; n.offset = 56; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex32; n.name = "field_3C"; n.parentId = ballId; n.offset = 60; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Float; n.name = "mass"; n.parentId = ballId; n.offset = 64; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_44"; n.parentId = ballId; n.offset = 68; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Bool; n.name = "bouncy"; n.parentId = ballId; n.offset = 76; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex8; n.name = "field_4D"; n.parentId = ballId; n.offset = 77; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex16; n.name = "field_4E"; n.parentId = ballId; n.offset = 78; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 80; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex32; n.name = "field_54"; n.parentId = ballId; n.offset = 84; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_58"; n.parentId = ballId; n.offset = 88; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_60"; n.parentId = ballId; n.offset = 96; tree.addNode(n); }
|
|
|
|
// Material struct (renamed from Physics, 40 bytes = 0x28)
|
|
Node mat;
|
|
mat.kind = NodeKind::Struct;
|
|
mat.name = "aMaterial";
|
|
mat.structTypeName = "Material";
|
|
mat.parentId = 0;
|
|
mat.offset = 0;
|
|
int mi = tree.addNode(mat);
|
|
uint64_t matId = tree.nodes[mi].id;
|
|
|
|
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_00"; n.parentId = matId; n.offset = 0; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_08"; n.parentId = matId; n.offset = 8; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_10"; n.parentId = matId; n.offset = 16; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_18"; n.parentId = matId; n.offset = 24; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_20"; n.parentId = matId; n.offset = 32; tree.addNode(n); }
|
|
|
|
// Pointer to Material in Ball struct
|
|
{ Node n; n.kind = NodeKind::Pointer64; n.name = "material"; n.parentId = ballId; n.offset = 104; n.refId = matId; n.collapsed = true; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_70"; n.parentId = ballId; n.offset = 112; tree.addNode(n); }
|
|
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_78"; n.parentId = ballId; n.offset = 120; tree.addNode(n); }
|
|
}
|
|
|
|
void MainWindow::newFile() {
|
|
project_new();
|
|
}
|
|
|
|
void MainWindow::newDocument() {
|
|
auto* tab = activeTab();
|
|
if (!tab) {
|
|
project_new();
|
|
return;
|
|
}
|
|
auto* doc = tab->doc;
|
|
auto* ctrl = tab->ctrl;
|
|
|
|
// Clear everything
|
|
doc->undoStack.clear();
|
|
doc->tree = NodeTree();
|
|
doc->tree.baseAddress = 0x00400000;
|
|
doc->filePath.clear();
|
|
doc->typeAliases.clear();
|
|
doc->modified = false;
|
|
|
|
// Build Ball + Material structs
|
|
buildBallDemo(doc->tree);
|
|
|
|
// Cross-platform writable buffer, zeroed (256 bytes covers Ball + spare)
|
|
QByteArray data(256, '\0');
|
|
doc->provider = std::make_shared<BufferProvider>(data);
|
|
|
|
// Focus on Ball struct
|
|
ctrl->setViewRootId(0);
|
|
for (const auto& n : doc->tree.nodes) {
|
|
if (n.parentId == 0 && n.kind == NodeKind::Struct) {
|
|
ctrl->setViewRootId(n.id);
|
|
break;
|
|
}
|
|
}
|
|
ctrl->clearSelection();
|
|
emit doc->documentChanged();
|
|
|
|
auto* sub = m_mdiArea->activeSubWindow();
|
|
if (sub) sub->setWindowTitle("Untitled");
|
|
updateWindowTitle();
|
|
rebuildWorkspaceModel();
|
|
}
|
|
|
|
void MainWindow::selfTest() {
|
|
project_new();
|
|
}
|
|
|
|
void MainWindow::openFile() {
|
|
project_open();
|
|
}
|
|
|
|
void MainWindow::saveFile() {
|
|
project_save(nullptr, false);
|
|
}
|
|
|
|
void MainWindow::saveFileAs() {
|
|
project_save(nullptr, true);
|
|
}
|
|
|
|
void MainWindow::addNode() {
|
|
auto* ctrl = activeController();
|
|
if (!ctrl) return;
|
|
|
|
uint64_t parentId = ctrl->viewRootId(); // default to current view root
|
|
auto* primary = activePaneEditor();
|
|
if (primary && primary->isEditing()) return;
|
|
if (primary) {
|
|
int ni = primary->currentNodeIndex();
|
|
if (ni >= 0) {
|
|
auto& node = ctrl->document()->tree.nodes[ni];
|
|
if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array)
|
|
parentId = node.id;
|
|
else
|
|
parentId = node.parentId;
|
|
}
|
|
}
|
|
ctrl->insertNode(parentId, -1, NodeKind::Hex64, "newField");
|
|
}
|
|
|
|
void MainWindow::removeNode() {
|
|
auto* ctrl = activeController();
|
|
if (!ctrl) return;
|
|
auto* primary = activePaneEditor();
|
|
if (!primary || primary->isEditing()) return;
|
|
QSet<int> indices = primary->selectedNodeIndices();
|
|
if (indices.size() > 1) {
|
|
ctrl->batchRemoveNodes(indices.values());
|
|
} else if (indices.size() == 1) {
|
|
ctrl->removeNode(*indices.begin());
|
|
}
|
|
}
|
|
|
|
void MainWindow::changeNodeType() {
|
|
auto* ctrl = activeController();
|
|
if (!ctrl) return;
|
|
auto* primary = activePaneEditor();
|
|
if (!primary) return;
|
|
primary->beginInlineEdit(EditTarget::Type);
|
|
}
|
|
|
|
void MainWindow::renameNodeAction() {
|
|
auto* ctrl = activeController();
|
|
if (!ctrl) return;
|
|
auto* primary = activePaneEditor();
|
|
if (!primary) return;
|
|
primary->beginInlineEdit(EditTarget::Name);
|
|
}
|
|
|
|
void MainWindow::duplicateNodeAction() {
|
|
auto* ctrl = activeController();
|
|
if (!ctrl) return;
|
|
auto* primary = activePaneEditor();
|
|
if (!primary || primary->isEditing()) return;
|
|
int ni = primary->currentNodeIndex();
|
|
if (ni >= 0) ctrl->duplicateNode(ni);
|
|
}
|
|
|
|
void MainWindow::splitView() {
|
|
auto* tab = activeTab();
|
|
if (!tab) return;
|
|
tab->panes.append(createSplitPane(*tab));
|
|
}
|
|
|
|
void MainWindow::unsplitView() {
|
|
auto* tab = activeTab();
|
|
if (!tab || tab->panes.size() <= 1) return;
|
|
auto pane = tab->panes.takeLast();
|
|
tab->ctrl->removeSplitEditor(pane.editor);
|
|
pane.tabWidget->deleteLater();
|
|
tab->activePaneIdx = qBound(0, tab->activePaneIdx, tab->panes.size() - 1);
|
|
}
|
|
|
|
void MainWindow::undo() {
|
|
auto* tab = activeTab();
|
|
if (tab) tab->doc->undoStack.undo();
|
|
}
|
|
|
|
void MainWindow::redo() {
|
|
auto* tab = activeTab();
|
|
if (tab) tab->doc->undoStack.redo();
|
|
}
|
|
|
|
void MainWindow::about() {
|
|
QMessageBox::about(this, "About ReclassX",
|
|
"ReclassX - Structured Binary Editor\n"
|
|
"Built with Qt 6 + QScintilla\n\n"
|
|
"Margin-driven UI with offset display,\n"
|
|
"fold markers, and status flags.");
|
|
}
|
|
|
|
void MainWindow::setEditorFont(const QString& fontName) {
|
|
QSettings settings("ReclassX", "ReclassX");
|
|
settings.setValue("font", fontName);
|
|
QFont f(fontName, 12);
|
|
f.setFixedPitch(true);
|
|
for (auto& state : m_tabs) {
|
|
state.ctrl->setEditorFont(fontName);
|
|
for (auto& pane : state.panes) {
|
|
// Update rendered view font
|
|
if (pane.rendered) {
|
|
pane.rendered->setFont(f);
|
|
if (auto* lex = pane.rendered->lexer()) {
|
|
lex->setFont(f);
|
|
for (int i = 0; i <= 127; i++)
|
|
lex->setFont(f, i);
|
|
}
|
|
pane.rendered->setMarginsFont(f);
|
|
}
|
|
// Update per-pane tab bar font
|
|
if (pane.tabWidget)
|
|
applyTabWidgetStyle(pane.tabWidget);
|
|
}
|
|
}
|
|
// Sync workspace tree font
|
|
if (m_workspaceTree)
|
|
m_workspaceTree->setFont(f);
|
|
// Sync status bar font
|
|
statusBar()->setFont(f);
|
|
}
|
|
|
|
RcxController* MainWindow::activeController() const {
|
|
auto* sub = m_mdiArea->activeSubWindow();
|
|
if (sub && m_tabs.contains(sub))
|
|
return m_tabs[sub].ctrl;
|
|
return nullptr;
|
|
}
|
|
|
|
MainWindow::TabState* MainWindow::activeTab() {
|
|
auto* sub = m_mdiArea->activeSubWindow();
|
|
if (sub && m_tabs.contains(sub))
|
|
return &m_tabs[sub];
|
|
return nullptr;
|
|
}
|
|
|
|
void MainWindow::updateWindowTitle() {
|
|
auto* sub = m_mdiArea->activeSubWindow();
|
|
if (sub && m_tabs.contains(sub)) {
|
|
auto& tab = m_tabs[sub];
|
|
QString name = tab.doc->filePath.isEmpty() ? "Untitled"
|
|
: QFileInfo(tab.doc->filePath).fileName();
|
|
if (tab.doc->modified) name += " *";
|
|
setWindowTitle(name + " - ReclassX");
|
|
} else {
|
|
setWindowTitle("ReclassX");
|
|
}
|
|
}
|
|
|
|
// ── Rendered view setup ──
|
|
|
|
void MainWindow::setupRenderedSci(QsciScintilla* sci) {
|
|
QSettings settings("ReclassX", "ReclassX");
|
|
QString fontName = settings.value("font", "Consolas").toString();
|
|
QFont f(fontName, 12);
|
|
f.setFixedPitch(true);
|
|
|
|
sci->setFont(f);
|
|
sci->setReadOnly(false);
|
|
sci->setWrapMode(QsciScintilla::WrapNone);
|
|
sci->setTabWidth(4);
|
|
sci->setIndentationsUseTabs(false);
|
|
sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRAASCENT, (long)2);
|
|
sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRADESCENT, (long)2);
|
|
|
|
// Line number margin
|
|
sci->setMarginType(0, QsciScintilla::NumberMargin);
|
|
sci->setMarginWidth(0, "00000");
|
|
sci->setMarginsBackgroundColor(QColor("#252526"));
|
|
sci->setMarginsForegroundColor(QColor("#858585"));
|
|
sci->setMarginsFont(f);
|
|
|
|
// Hide other margins
|
|
sci->setMarginWidth(1, 0);
|
|
sci->setMarginWidth(2, 0);
|
|
|
|
// C++ lexer for syntax highlighting — must be set BEFORE colors below,
|
|
// because setLexer() resets caret line, selection, and paper colors.
|
|
auto* lexer = new QsciLexerCPP(sci);
|
|
lexer->setFont(f);
|
|
lexer->setColor(QColor("#569cd6"), QsciLexerCPP::Keyword);
|
|
lexer->setColor(QColor("#569cd6"), QsciLexerCPP::KeywordSet2);
|
|
lexer->setColor(QColor("#b5cea8"), QsciLexerCPP::Number);
|
|
lexer->setColor(QColor("#ce9178"), QsciLexerCPP::DoubleQuotedString);
|
|
lexer->setColor(QColor("#ce9178"), QsciLexerCPP::SingleQuotedString);
|
|
lexer->setColor(QColor("#6a9955"), QsciLexerCPP::Comment);
|
|
lexer->setColor(QColor("#6a9955"), QsciLexerCPP::CommentLine);
|
|
lexer->setColor(QColor("#6a9955"), QsciLexerCPP::CommentDoc);
|
|
lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Default);
|
|
lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Identifier);
|
|
lexer->setColor(QColor("#c586c0"), QsciLexerCPP::PreProcessor);
|
|
lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Operator);
|
|
for (int i = 0; i <= 127; i++) {
|
|
lexer->setPaper(QColor("#1e1e1e"), i);
|
|
lexer->setFont(f, i);
|
|
}
|
|
sci->setLexer(lexer);
|
|
sci->setBraceMatching(QsciScintilla::NoBraceMatch);
|
|
|
|
// Colors applied AFTER setLexer() — the lexer resets these on attach
|
|
sci->setPaper(QColor("#1e1e1e"));
|
|
sci->setColor(QColor("#d4d4d4"));
|
|
sci->setCaretForegroundColor(QColor("#d4d4d4"));
|
|
sci->setCaretLineVisible(true);
|
|
sci->setCaretLineBackgroundColor(QColor(43, 43, 43)); // Match Reclass M_HOVER
|
|
sci->setSelectionBackgroundColor(QColor("#264f78")); // Match Reclass edit selection
|
|
sci->setSelectionForegroundColor(QColor("#d4d4d4"));
|
|
}
|
|
|
|
// ── View mode / generator switching ──
|
|
|
|
void MainWindow::setViewMode(ViewMode mode) {
|
|
auto* pane = findActiveSplitPane();
|
|
if (!pane) return;
|
|
pane->viewMode = mode;
|
|
int idx = (mode == VM_Rendered) ? 1 : 0;
|
|
pane->tabWidget->setCurrentIndex(idx);
|
|
// The QTabWidget::currentChanged signal will handle updating the rendered view
|
|
}
|
|
|
|
// ── Find the root-level struct ancestor for a node ──
|
|
|
|
uint64_t MainWindow::findRootStructForNode(const NodeTree& tree, uint64_t nodeId) const {
|
|
QSet<uint64_t> visited;
|
|
uint64_t cur = nodeId;
|
|
uint64_t lastStruct = 0;
|
|
while (cur != 0 && !visited.contains(cur)) {
|
|
visited.insert(cur);
|
|
int idx = tree.indexOfId(cur);
|
|
if (idx < 0) break;
|
|
const Node& n = tree.nodes[idx];
|
|
if (n.kind == NodeKind::Struct)
|
|
lastStruct = n.id;
|
|
if (n.parentId == 0)
|
|
return (n.kind == NodeKind::Struct) ? n.id : lastStruct;
|
|
cur = n.parentId;
|
|
}
|
|
return lastStruct;
|
|
}
|
|
|
|
// ── Update the rendered view for a single pane ──
|
|
|
|
void MainWindow::updateRenderedView(TabState& tab, SplitPane& pane) {
|
|
if (pane.viewMode != VM_Rendered) return;
|
|
if (!pane.rendered) return;
|
|
|
|
// Determine which struct to render based on selection
|
|
uint64_t rootId = 0;
|
|
QSet<uint64_t> selIds = tab.ctrl->selectedIds();
|
|
if (selIds.size() >= 1) {
|
|
uint64_t selId = *selIds.begin();
|
|
selId &= ~kFooterIdBit;
|
|
rootId = findRootStructForNode(tab.doc->tree, selId);
|
|
}
|
|
|
|
// Generate text
|
|
const QHash<NodeKind, QString>* aliases =
|
|
tab.doc->typeAliases.isEmpty() ? nullptr : &tab.doc->typeAliases;
|
|
QString text;
|
|
if (rootId != 0)
|
|
text = renderCpp(tab.doc->tree, rootId, aliases);
|
|
else
|
|
text = renderCppAll(tab.doc->tree, aliases);
|
|
|
|
// Scroll restoration: save if same root, reset if different
|
|
int restoreLine = 0;
|
|
if (rootId != 0 && rootId == pane.lastRenderedRootId) {
|
|
restoreLine = (int)pane.rendered->SendScintilla(
|
|
QsciScintillaBase::SCI_GETFIRSTVISIBLELINE);
|
|
}
|
|
pane.lastRenderedRootId = rootId;
|
|
|
|
// Set text
|
|
pane.rendered->setText(text);
|
|
|
|
// Update margin width for line count
|
|
int lineCount = pane.rendered->lines();
|
|
QString marginStr = QString(QString::number(lineCount).size() + 2, '0');
|
|
pane.rendered->setMarginWidth(0, marginStr);
|
|
|
|
// Restore scroll
|
|
if (restoreLine > 0) {
|
|
pane.rendered->SendScintilla(QsciScintillaBase::SCI_SETFIRSTVISIBLELINE,
|
|
(unsigned long)restoreLine);
|
|
}
|
|
}
|
|
|
|
void MainWindow::updateAllRenderedPanes(TabState& tab) {
|
|
for (auto& pane : tab.panes) {
|
|
if (pane.viewMode == VM_Rendered)
|
|
updateRenderedView(tab, pane);
|
|
}
|
|
}
|
|
|
|
// ── Export C++ header to file ──
|
|
|
|
void MainWindow::exportCpp() {
|
|
auto* tab = activeTab();
|
|
if (!tab) return;
|
|
|
|
QString path = QFileDialog::getSaveFileName(this,
|
|
"Export C++ Header", {}, "C++ Header (*.h);;All Files (*)");
|
|
if (path.isEmpty()) return;
|
|
|
|
const QHash<NodeKind, QString>* aliases =
|
|
tab->doc->typeAliases.isEmpty() ? nullptr : &tab->doc->typeAliases;
|
|
QString text = renderCppAll(tab->doc->tree, aliases);
|
|
QFile file(path);
|
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
|
QMessageBox::warning(this, "Export Failed",
|
|
"Could not write to: " + path);
|
|
return;
|
|
}
|
|
file.write(text.toUtf8());
|
|
m_statusLabel->setText("Exported to " + QFileInfo(path).fileName());
|
|
}
|
|
|
|
// ── Type Aliases Dialog ──
|
|
|
|
void MainWindow::showTypeAliasesDialog() {
|
|
auto* tab = activeTab();
|
|
if (!tab) return;
|
|
|
|
QDialog dlg(this);
|
|
dlg.setWindowTitle("Type Aliases");
|
|
dlg.resize(500, 400);
|
|
|
|
auto* layout = new QVBoxLayout(&dlg);
|
|
|
|
auto* table = new QTableWidget(&dlg);
|
|
table->setColumnCount(2);
|
|
table->setHorizontalHeaderLabels({"NodeKind", "Alias (C type)"});
|
|
table->horizontalHeader()->setStretchLastSection(true);
|
|
table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
|
|
table->setSelectionMode(QAbstractItemView::SingleSelection);
|
|
|
|
// Populate with all NodeKind entries
|
|
int rowCount = static_cast<int>(std::size(kKindMeta));
|
|
table->setRowCount(rowCount);
|
|
for (int i = 0; i < rowCount; i++) {
|
|
const auto& meta = kKindMeta[i];
|
|
auto* kindItem = new QTableWidgetItem(QString::fromLatin1(meta.name));
|
|
kindItem->setFlags(kindItem->flags() & ~Qt::ItemIsEditable);
|
|
table->setItem(i, 0, kindItem);
|
|
|
|
QString alias = tab->doc->typeAliases.value(meta.kind);
|
|
table->setItem(i, 1, new QTableWidgetItem(alias));
|
|
}
|
|
|
|
layout->addWidget(table);
|
|
|
|
auto* buttons = new QDialogButtonBox(
|
|
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dlg);
|
|
layout->addWidget(buttons);
|
|
|
|
connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept);
|
|
connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject);
|
|
|
|
if (dlg.exec() != QDialog::Accepted) return;
|
|
|
|
// Collect new aliases
|
|
QHash<NodeKind, QString> newAliases;
|
|
for (int i = 0; i < rowCount; i++) {
|
|
QString val = table->item(i, 1)->text().trimmed();
|
|
if (!val.isEmpty())
|
|
newAliases[kKindMeta[i].kind] = val;
|
|
}
|
|
|
|
tab->doc->typeAliases = newAliases;
|
|
tab->doc->modified = true;
|
|
tab->ctrl->refresh();
|
|
updateWindowTitle();
|
|
}
|
|
|
|
// ── Project Lifecycle API ──
|
|
|
|
QMdiSubWindow* MainWindow::project_new() {
|
|
auto* doc = new RcxDocument(this);
|
|
|
|
// Cross-platform writable buffer, zeroed (256 bytes covers Ball struct + spare)
|
|
QByteArray data(256, '\0');
|
|
doc->loadData(data);
|
|
doc->tree.baseAddress = 0x00400000;
|
|
|
|
// Build Ball + Material demo structs
|
|
buildBallDemo(doc->tree);
|
|
|
|
auto* sub = createTab(doc);
|
|
rebuildWorkspaceModel();
|
|
return sub;
|
|
}
|
|
|
|
QMdiSubWindow* MainWindow::project_open(const QString& path) {
|
|
QString filePath = path;
|
|
if (filePath.isEmpty()) {
|
|
filePath = QFileDialog::getOpenFileName(this,
|
|
"Open Definition", {}, "ReclassX (*.rcx);;JSON (*.json);;All (*)");
|
|
if (filePath.isEmpty()) return nullptr;
|
|
}
|
|
|
|
auto* doc = new RcxDocument(this);
|
|
if (!doc->load(filePath)) {
|
|
QMessageBox::warning(this, "Error", "Failed to load: " + filePath);
|
|
delete doc;
|
|
return nullptr;
|
|
}
|
|
auto* sub = createTab(doc);
|
|
rebuildWorkspaceModel();
|
|
return sub;
|
|
}
|
|
|
|
bool MainWindow::project_save(QMdiSubWindow* sub, bool saveAs) {
|
|
if (!sub) sub = m_mdiArea->activeSubWindow();
|
|
if (!sub || !m_tabs.contains(sub)) return false;
|
|
auto& tab = m_tabs[sub];
|
|
|
|
if (saveAs || tab.doc->filePath.isEmpty()) {
|
|
QString path = QFileDialog::getSaveFileName(this,
|
|
"Save Definition", {}, "ReclassX (*.rcx);;JSON (*.json)");
|
|
if (path.isEmpty()) return false;
|
|
tab.doc->save(path);
|
|
} else {
|
|
tab.doc->save(tab.doc->filePath);
|
|
}
|
|
updateWindowTitle();
|
|
return true;
|
|
}
|
|
|
|
void MainWindow::project_close(QMdiSubWindow* sub) {
|
|
if (!sub) sub = m_mdiArea->activeSubWindow();
|
|
if (!sub) return;
|
|
sub->close();
|
|
rebuildWorkspaceModel();
|
|
}
|
|
|
|
// ── Workspace Dock ──
|
|
|
|
void MainWindow::createWorkspaceDock() {
|
|
m_workspaceDock = new QDockWidget("Workspace", this);
|
|
m_workspaceDock->setObjectName("WorkspaceDock");
|
|
m_workspaceDock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
|
|
|
|
m_workspaceTree = new QTreeView(m_workspaceDock);
|
|
m_workspaceModel = new QStandardItemModel(this);
|
|
m_workspaceModel->setHorizontalHeaderLabels({"Name"});
|
|
m_workspaceTree->setModel(m_workspaceModel);
|
|
m_workspaceTree->setHeaderHidden(true);
|
|
m_workspaceTree->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
|
|
|
// Match editor font
|
|
{
|
|
QSettings settings("ReclassX", "ReclassX");
|
|
QString fontName = settings.value("font", "Consolas").toString();
|
|
QFont f(fontName, 12);
|
|
f.setFixedPitch(true);
|
|
m_workspaceTree->setFont(f);
|
|
}
|
|
|
|
m_workspaceDock->setWidget(m_workspaceTree);
|
|
addDockWidget(Qt::LeftDockWidgetArea, m_workspaceDock);
|
|
m_workspaceDock->hide();
|
|
|
|
connect(m_workspaceTree, &QTreeView::doubleClicked, this, [this](const QModelIndex& index) {
|
|
// Data roles: UserRole=QMdiSubWindow*, UserRole+1=structId, UserRole+2=nodeId
|
|
auto subVar = index.data(Qt::UserRole);
|
|
if (!subVar.isValid()) return;
|
|
|
|
auto* sub = static_cast<QMdiSubWindow*>(subVar.value<void*>());
|
|
if (!sub || !m_tabs.contains(sub)) return;
|
|
|
|
m_mdiArea->setActiveSubWindow(sub);
|
|
|
|
auto structIdVar = index.data(Qt::UserRole + 1);
|
|
auto nodeIdVar = index.data(Qt::UserRole + 2);
|
|
|
|
if (structIdVar.isValid()) {
|
|
// Double-clicked a struct: set as view root
|
|
uint64_t structId = structIdVar.toULongLong();
|
|
auto& tree = m_tabs[sub].doc->tree;
|
|
int ni = tree.indexOfId(structId);
|
|
if (ni >= 0) tree.nodes[ni].collapsed = false;
|
|
m_tabs[sub].ctrl->setViewRootId(structId);
|
|
m_tabs[sub].ctrl->scrollToNodeId(structId);
|
|
} else if (nodeIdVar.isValid()) {
|
|
// Double-clicked a field: find its root struct, set as view root, scroll to field
|
|
uint64_t nodeId = nodeIdVar.toULongLong();
|
|
auto& tree = m_tabs[sub].doc->tree;
|
|
// Walk up to find root struct
|
|
uint64_t rootId = 0;
|
|
uint64_t cur = nodeId;
|
|
while (cur != 0) {
|
|
int idx = tree.indexOfId(cur);
|
|
if (idx < 0) break;
|
|
if (tree.nodes[idx].parentId == 0) { rootId = cur; break; }
|
|
cur = tree.nodes[idx].parentId;
|
|
}
|
|
if (rootId != 0) {
|
|
int ri = tree.indexOfId(rootId);
|
|
if (ri >= 0) tree.nodes[ri].collapsed = false;
|
|
m_tabs[sub].ctrl->setViewRootId(rootId);
|
|
}
|
|
m_tabs[sub].ctrl->scrollToNodeId(nodeId);
|
|
} else if (!index.parent().isValid()) {
|
|
// Double-clicked project root: clear view root to show all
|
|
m_tabs[sub].ctrl->setViewRootId(0);
|
|
}
|
|
});
|
|
}
|
|
|
|
void MainWindow::rebuildWorkspaceModel() {
|
|
m_workspaceModel->clear();
|
|
|
|
auto* sub = m_mdiArea->activeSubWindow();
|
|
if (!sub || !m_tabs.contains(sub)) return;
|
|
|
|
TabState& tab = m_tabs[sub];
|
|
QString tabName = tab.doc->filePath.isEmpty()
|
|
? "Untitled" : QFileInfo(tab.doc->filePath).fileName();
|
|
|
|
buildWorkspaceModel(m_workspaceModel, tab.doc->tree, tabName,
|
|
static_cast<void*>(sub));
|
|
m_workspaceTree->expandAll();
|
|
}
|
|
|
|
void MainWindow::showPluginsDialog() {
|
|
QDialog dialog(this);
|
|
dialog.setWindowTitle("Plugins");
|
|
dialog.resize(600, 400);
|
|
|
|
auto* layout = new QVBoxLayout(&dialog);
|
|
|
|
auto* list = new QListWidget();
|
|
layout->addWidget(list);
|
|
|
|
auto refreshList = [&]() {
|
|
list->clear();
|
|
|
|
// Populate plugin list
|
|
for (IPlugin* plugin : m_pluginManager.plugins()) {
|
|
QString typeStr;
|
|
switch (plugin->Type())
|
|
{
|
|
case IPlugin::ProviderPlugin: typeStr = "Provider"; break;
|
|
default: typeStr = "Unknown"; break;
|
|
}
|
|
|
|
QString text = QString("%1 v%2\n %3\n Type: %4\n Author: %5")
|
|
.arg(QString::fromStdString(plugin->Name()))
|
|
.arg(QString::fromStdString(plugin->Version()))
|
|
.arg(QString::fromStdString(plugin->Description()))
|
|
.arg(typeStr)
|
|
.arg(QString::fromStdString(plugin->Author()));
|
|
|
|
auto* item = new QListWidgetItem(plugin->Icon(), text);
|
|
item->setData(Qt::UserRole, QString::fromStdString(plugin->Name()));
|
|
list->addItem(item);
|
|
}
|
|
|
|
if (m_pluginManager.plugins().isEmpty()) {
|
|
list->addItem("No plugins loaded");
|
|
}
|
|
};
|
|
|
|
refreshList();
|
|
|
|
// Button row
|
|
auto* btnLayout = new QHBoxLayout();
|
|
|
|
auto* btnLoad = new QPushButton("Load Plugin...");
|
|
connect(btnLoad, &QPushButton::clicked, [&, refreshList]() {
|
|
QString path = QFileDialog::getOpenFileName(&dialog, "Load Plugin",
|
|
QCoreApplication::applicationDirPath() + "/Plugins",
|
|
"Plugins (*.dll *.so *.dylib);;All Files (*)");
|
|
|
|
if (!path.isEmpty()) {
|
|
if (m_pluginManager.LoadPluginFromPath(path)) {
|
|
refreshList();
|
|
m_statusLabel->setText("Plugin loaded successfully");
|
|
} else {
|
|
QMessageBox::warning(&dialog, "Failed to Load Plugin",
|
|
"Could not load the selected plugin.\nCheck the console for details.");
|
|
}
|
|
}
|
|
});
|
|
|
|
auto* btnUnload = new QPushButton("Unload Selected");
|
|
connect(btnUnload, &QPushButton::clicked, [&, list, refreshList]() {
|
|
auto* item = list->currentItem();
|
|
if (!item) {
|
|
QMessageBox::information(&dialog, "No Selection", "Please select a plugin to unload.");
|
|
return;
|
|
}
|
|
|
|
QString pluginName = item->data(Qt::UserRole).toString();
|
|
if (pluginName.isEmpty()) return;
|
|
|
|
auto reply = QMessageBox::question(&dialog, "Unload Plugin",
|
|
QString("Are you sure you want to unload '%1'?").arg(pluginName),
|
|
QMessageBox::Yes | QMessageBox::No);
|
|
|
|
if (reply == QMessageBox::Yes) {
|
|
if (m_pluginManager.UnloadPlugin(pluginName)) {
|
|
refreshList();
|
|
m_statusLabel->setText("Plugin unloaded");
|
|
} else {
|
|
QMessageBox::warning(&dialog, "Failed to Unload",
|
|
"Could not unload the selected plugin.");
|
|
}
|
|
}
|
|
});
|
|
|
|
auto* btnClose = new QPushButton("Close");
|
|
connect(btnClose, &QPushButton::clicked, &dialog, &QDialog::accept);
|
|
|
|
btnLayout->addWidget(btnLoad);
|
|
btnLayout->addWidget(btnUnload);
|
|
btnLayout->addStretch();
|
|
btnLayout->addWidget(btnClose);
|
|
|
|
layout->addLayout(btnLayout);
|
|
|
|
dialog.exec();
|
|
}
|
|
|
|
} // namespace rcx
|
|
|
|
// ── Entry point ──
|
|
|
|
int main(int argc, char* argv[]) {
|
|
#ifdef _WIN32
|
|
SetUnhandledExceptionFilter(crashHandler);
|
|
#endif
|
|
|
|
QApplication app(argc, argv);
|
|
app.setApplicationName("ReclassX");
|
|
app.setOrganizationName("ReclassX");
|
|
app.setStyle("Fusion"); // Fusion style respects dark palette well
|
|
|
|
// Load embedded fonts
|
|
int fontId = QFontDatabase::addApplicationFont(":/fonts/Iosevka-Regular.ttf");
|
|
if (fontId == -1)
|
|
qWarning("Failed to load embedded Iosevka font");
|
|
// Apply saved font preference before creating any editors
|
|
{
|
|
QSettings settings("ReclassX", "ReclassX");
|
|
QString savedFont = settings.value("font", "Consolas").toString();
|
|
rcx::RcxEditor::setGlobalFontName(savedFont);
|
|
}
|
|
|
|
// Global dark palette
|
|
QPalette darkPalette;
|
|
darkPalette.setColor(QPalette::Window, QColor("#1e1e1e"));
|
|
darkPalette.setColor(QPalette::WindowText, QColor("#d4d4d4"));
|
|
darkPalette.setColor(QPalette::Base, QColor("#252526"));
|
|
darkPalette.setColor(QPalette::AlternateBase, QColor("#2a2d2e"));
|
|
darkPalette.setColor(QPalette::Text, QColor("#d4d4d4"));
|
|
darkPalette.setColor(QPalette::Button, QColor("#333333"));
|
|
darkPalette.setColor(QPalette::ButtonText, QColor("#d4d4d4"));
|
|
darkPalette.setColor(QPalette::Highlight, QColor("#2b2b2b"));
|
|
darkPalette.setColor(QPalette::HighlightedText, QColor("#d4d4d4"));
|
|
darkPalette.setColor(QPalette::ToolTipBase, QColor("#252526"));
|
|
darkPalette.setColor(QPalette::ToolTipText, QColor("#d4d4d4"));
|
|
darkPalette.setColor(QPalette::Mid, QColor("#3c3c3c"));
|
|
darkPalette.setColor(QPalette::Dark, QColor("#1e1e1e"));
|
|
darkPalette.setColor(QPalette::Light, QColor("#505050"));
|
|
app.setPalette(darkPalette);
|
|
|
|
// ── Global widget styling ──
|
|
// QMenu: grey hover, amber accent border (replaces Fusion outline artifact)
|
|
// QToolTip: dark theme
|
|
app.setStyleSheet(QStringLiteral(
|
|
"QMenu {"
|
|
" background-color: #252526;"
|
|
" color: #d4d4d4;"
|
|
" border: 1px solid #3c3c3c;"
|
|
" padding: 4px 6px;"
|
|
"}"
|
|
"QMenu::item {"
|
|
" padding: 4px 24px;"
|
|
"}"
|
|
"QMenu::item:selected {"
|
|
" background-color: #2b2b2b;"
|
|
"}"
|
|
"QMenu::separator {"
|
|
" height: 1px;"
|
|
" background: #3c3c3c;"
|
|
" margin: 4px 8px;"
|
|
"}"
|
|
"QMenu::item:disabled {"
|
|
" color: #585858;"
|
|
"}"
|
|
"QToolTip {"
|
|
" background-color: #252526;"
|
|
" color: #d4d4d4;"
|
|
" border: 1px solid #3c3c3c;"
|
|
"}"
|
|
));
|
|
|
|
rcx::MainWindow window;
|
|
|
|
bool screenshotMode = app.arguments().contains("--screenshot");
|
|
if (screenshotMode)
|
|
window.setWindowOpacity(0.0);
|
|
window.show();
|
|
|
|
// Auto-open demo project from saved .rcx file
|
|
QMetaObject::invokeMethod(&window, "selfTest");
|
|
|
|
if (screenshotMode) {
|
|
QString out = "screenshot.png";
|
|
int idx = app.arguments().indexOf("--screenshot");
|
|
if (idx + 1 < app.arguments().size())
|
|
out = app.arguments().at(idx + 1);
|
|
|
|
QTimer::singleShot(1000, [&window, out]() {
|
|
QDir().mkpath(QFileInfo(out).absolutePath());
|
|
window.grab().save(out);
|
|
::_Exit(0); // immediate exit — no need for clean shutdown in screenshot mode
|
|
});
|
|
}
|
|
|
|
return app.exec();
|
|
}
|
|
|
|
#include "main.moc"
|