#include "mainwindow.h" #include "ui_mainwindow.h" #include "aboutdialog.h" #include "audiopreviewwidget.h" #include "batchexportdialog.h" #include "definitionviewer.h" #include "exportmanager.h" #include "hexviewerwidget.h" #include "preferenceeditor.h" #include "reportissuedialog.h" #include "settings.h" #include "statusbarmanager.h" #include "logmanager.h" #include "xtreewidget.h" #include "xtreewidgetitem.h" #include "compression.h" #include "scripttypeeditorwidget.h" #include "dsluischema.h" #include "recompiler.h" #include "operationjournal.h" #include "dirtystatemanager.h" #include "utils.h" #include "imagepreviewwidget.h" #include "textviewerwidget.h" #include "listpreviewwidget.h" // Debug logging is controlled via Settings -> LogManager::debug() #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "fieldeditcommand.h" #include #include #include #include #include // Generate themed app icon by replacing red with accent color static QIcon generateThemedIcon(const QColor &accentColor) { QImage image(":/images/images/XPlor.png"); if (image.isNull()) { return QIcon(); } image = image.convertToFormat(QImage::Format_ARGB32); for (int y = 0; y < image.height(); y++) { QRgb *line = reinterpret_cast(image.scanLine(y)); for (int x = 0; x < image.width(); x++) { QColor pixel(line[x]); int r = pixel.red(); int g = pixel.green(); int b = pixel.blue(); int a = pixel.alpha(); // Detect red-ish pixels (high red, low green/blue) if (r > 100 && g < 80 && b < 80 && a > 0) { float intensity = static_cast(r) / 255.0f; QColor newColor = accentColor; newColor.setRed(static_cast(accentColor.red() * intensity)); newColor.setGreen(static_cast(accentColor.green() * intensity)); newColor.setBlue(static_cast(accentColor.blue() * intensity)); newColor.setAlpha(a); line[x] = newColor.rgba(); } } } return QIcon(QPixmap::fromImage(image)); } // Check if data looks like RCB pixel format (Avatar g4rc DXT1 texture) static bool looksLikeRCBPixel(const QByteArray &data) { if (data.size() < 0x28) return false; const uchar *d = reinterpret_cast(data.constData()); // RCB pixel has format byte 0x52 at offset 0x09 // and data size at offset 0x10 (big-endian) uchar formatByte = d[0x09]; if (formatByte != 0x52) return false; quint32 rcbDataSize = (d[0x10] << 24) | (d[0x11] << 16) | (d[0x12] << 8) | d[0x13]; // Check if header + data size fits in the buffer return rcbDataSize > 0 && rcbDataSize + 0x24 <= (quint32)data.size(); } // Check if data looks like a known image format static bool looksLikeImage(const QByteArray &data) { if (data.size() < 8) return false; // PNG if (data.startsWith("\x89PNG")) return true; // JPEG if (data.startsWith("\xFF\xD8\xFF")) return true; // BMP if (data.startsWith("BM")) return true; // DDS if (data.startsWith("DDS ")) return true; // TGA - check for valid image type if (data.size() >= 18) { uchar imageType = static_cast(data[2]); if (imageType == 2 || imageType == 10) return true; // Uncompressed/RLE true-color } // RCB pixel format (Avatar textures) if (looksLikeRCBPixel(data)) return true; return false; } MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) , ui(new Ui::MainWindow) , mTreeBuilder(nullptr, mTypeRegistry) // Placeholder, will reset after mTreeWidget created { ui->setupUi(this); setAcceptDrops(true); // Initialize global undo stack mUndoStack = new QUndoStack(this); // Connect to theme changes connect(&Settings::instance(), &Settings::themeChanged, this, &MainWindow::applyTheme); // Create ribbon at absolute top of window QWidget *menuContainer = new QWidget(this); QVBoxLayout *menuLayout = new QVBoxLayout(menuContainer); menuLayout->setContentsMargins(0, 0, 0, 0); menuLayout->setSpacing(0); // Accent ribbon stripe mRibbon = new QFrame(menuContainer); mRibbon->setFixedHeight(4); menuLayout->addWidget(mRibbon); // Move the menu bar into our container menuLayout->addWidget(menuBar()); setMenuWidget(menuContainer); // Apply initial theme from settings applyTheme(Settings::instance().theme()); mTreeWidget = new XTreeWidget(this); // Initialize tree builder with the actual tree widget mTreeBuilder = TreeBuilder(mTreeWidget, mTypeRegistry); mTreeWidget->setColumnCount(1); mLogWidget = new QPlainTextEdit(this); mProgressBar = new QProgressBar(this); mProgressBar->setMaximum(100); // Default max value mProgressBar->setVisible(false); // Initially hidden connect(&StatusBarManager::instance(), &StatusBarManager::statusUpdated, this, &MainWindow::HandleStatusUpdate); connect(&StatusBarManager::instance(), &StatusBarManager::progressUpdated, this, &MainWindow::HandleProgressUpdate); connect(&LogManager::instance(), &LogManager::entryAdded, this, &MainWindow::HandleLogEntry); // Flush any buffered log entries from before the signal was connected LogManager::instance().flushBufferedEntries(); statusBar()->addPermanentWidget(mProgressBar); // Connect dirty state manager to update tab titles and tree items connect(&DirtyStateManager::instance(), &DirtyStateManager::dirtyStateChanged, this, [this](QWidget* tab, bool isDirty) { int index = ui->tabWidget->indexOf(tab); if (index < 0) return; QString title = ui->tabWidget->tabText(index); if (isDirty && !title.endsWith(" *")) { ui->tabWidget->setTabText(index, title + " *"); } else if (!isDirty && title.endsWith(" *")) { ui->tabWidget->setTabText(index, title.chopped(2)); } // Also update tree item indicator QVariant treeItemVar = tab->property("treeItem"); if (treeItemVar.isValid()) { auto* treeItem = treeItemVar.value(); if (treeItem) { treeItem->setModified(isDirty); } } }); // Update save actions when tab changes connect(ui->tabWidget, &QTabWidget::currentChanged, this, [this](int index) { QWidget* tab = ui->tabWidget->widget(index); bool canSave = tab && tab->property("formEditor").isValid(); actionSave->setEnabled(canSave); actionSaveAs->setEnabled(canSave); }); // Handle tab close requests (tabCloseRequested signal) ui->tabWidget->setTabsClosable(true); connect(ui->tabWidget, &QTabWidget::tabCloseRequested, this, [this](int index) { QWidget* tab = ui->tabWidget->widget(index); if (!tab) return; if (DirtyStateManager::instance().isDirty(tab)) { auto result = QMessageBox::question(this, "Unsaved Changes", "This tab has unsaved changes.\n\nSave changes before closing?", QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel, QMessageBox::Save); if (result == QMessageBox::Cancel) { return; // Don't close } else if (result == QMessageBox::Save) { // Try to save - if save fails, don't close if (!saveTab(tab)) { return; } } // Discard falls through to close } DirtyStateManager::instance().removeTab(tab); ui->tabWidget->removeTab(index); }); ui->tabWidget->setContextMenuPolicy(Qt::CustomContextMenu); connect(ui->tabWidget, &QTabWidget::customContextMenuRequested, this, [this](const QPoint &pos) { if (pos.isNull()) return; int tabIndex = ui->tabWidget->tabBar()->tabAt(pos); if (tabIndex < 0) return; QWidget* tabWidget = ui->tabWidget->widget(tabIndex); if (!tabWidget) return; QMenu *contextMenu = new QMenu(this); // Get data associated with this tab for export QByteArray tabData = tabWidget->property("TAB_DATA").toByteArray(); QString tabName = ui->tabWidget->tabText(tabIndex); QVariantMap tabVars = tabWidget->property("TAB_VARS").toMap(); // Export options (only if tab has data) if (!tabData.isEmpty()) { ExportManager& exp = ExportManager::instance(); auto contentType = exp.detectContentType(tabVars); if (contentType == ExportManager::Unknown) { contentType = exp.detectContentType(tabData, tabName); } // Quick Export QAction *quickExportAction = new QAction("Quick Export"); quickExportAction->setShortcut(QKeySequence("Ctrl+Shift+E")); contextMenu->addAction(quickExportAction); connect(quickExportAction, &QAction::triggered, this, [this, tabData, tabName, contentType]() { ExportManager& exp = ExportManager::instance(); if (exp.quickExport(tabData, tabName, contentType, this)) { StatusBarManager::instance().updateStatus("Export completed successfully", 3000); } }); // Export with dialog QAction *exportDialogAction = new QAction("Export..."); exportDialogAction->setShortcut(QKeySequence("Ctrl+E")); contextMenu->addAction(exportDialogAction); connect(exportDialogAction, &QAction::triggered, this, [this, tabData, tabName, contentType]() { ExportManager& exp = ExportManager::instance(); if (exp.exportWithDialog(tabData, tabName, contentType, this)) { StatusBarManager::instance().updateStatus("Export completed successfully", 3000); } }); // Raw data export QAction *rawExportAction = new QAction("Export Raw Data..."); contextMenu->addAction(rawExportAction); connect(rawExportAction, &QAction::triggered, this, [this, tabData, tabName]() { ExportManager& exp = ExportManager::instance(); if (exp.exportRawData(tabData, tabName, this)) { StatusBarManager::instance().updateStatus("Export completed successfully", 3000); } }); contextMenu->addSeparator(); } QAction *closeAction = new QAction("Close"); contextMenu->addAction(closeAction); connect(closeAction, &QAction::triggered, this, [this, tabIndex](bool checked) { Q_UNUSED(checked); ui->tabWidget->removeTab(tabIndex); }); QMenu *closeMultipleAction = new QMenu("Close Multiple Tabs"); QAction *closeAllAction = new QAction("Close All"); closeMultipleAction->addAction(closeAllAction); connect(closeAllAction, &QAction::triggered, this, [this](bool checked) { Q_UNUSED(checked); ui->tabWidget->clear(); }); QAction *closeAllButAction = new QAction("Close All BUT This"); closeMultipleAction->addAction(closeAllButAction); connect(closeAllButAction, &QAction::triggered, this, [this, tabIndex](bool checked) { Q_UNUSED(checked); for (int i = ui->tabWidget->count() - 1; i >= 0; i--) { if (i != tabIndex) { ui->tabWidget->removeTab(i); } } }); QAction *closeLeftAction = new QAction("Close All to the Left"); closeMultipleAction->addAction(closeLeftAction); connect(closeLeftAction, &QAction::triggered, this, [this, tabIndex](bool checked) { Q_UNUSED(checked); for (int i = tabIndex - 1; i >= 0; i--) { ui->tabWidget->removeTab(i); } }); QAction *closeRightAction = new QAction("Close All to the Right"); closeMultipleAction->addAction(closeRightAction); connect(closeRightAction, &QAction::triggered, this, [this, tabIndex](bool checked) { Q_UNUSED(checked); for (int i = ui->tabWidget->count() - 1; i > tabIndex; i--) { ui->tabWidget->removeTab(i); } }); contextMenu->addMenu(closeMultipleAction); QPoint pt(pos); contextMenu->exec(ui->tabWidget->mapToGlobal(pt)); delete contextMenu; }); connect(ui->tabWidget, &QTabWidget::tabCloseRequested, this, [this](int index) { ui->tabWidget->removeTab(index); }); connect(mTreeWidget, &XTreeWidget::Cleared, this, [this]() { ui->tabWidget->clear(); }); // Handle export requests from tree context menu connect(mTreeWidget, &XTreeWidget::exportRequested, this, [this](const QString& format, QTreeWidgetItem* item) { if (!item) return; QVariantMap vars = item->data(0, Qt::UserRole + 3).toMap(); QString name = item->text(0); // Get data from vars (check _preview first, then data field) QByteArray data; if (DslKeys::contains(vars, DslKey::Preview)) { QVariant preview = DslKeys::get(vars, DslKey::Preview); if (preview.typeId() == QMetaType::QVariantMap) { data = preview.toMap().value("data").toByteArray(); } else { data = preview.toByteArray(); } } else if (vars.contains("data")) { data = vars.value("data").toByteArray(); } if (data.isEmpty()) { StatusBarManager::instance().updateStatus("No data available to export", 3000); return; } ExportManager& exp = ExportManager::instance(); if (format == "raw") { if (exp.exportRawData(data, name, this)) { StatusBarManager::instance().updateStatus("Export completed successfully", 3000); } } else if (format == "clipboard") { QString typeName = item->data(0, Qt::UserRole + 2).toString(); QMimeData* mimeData = new QMimeData(); // 1. Internal format - full metadata for paste inside app QByteArray internalData; QDataStream stream(&internalData, QIODevice::WriteOnly); stream << name << typeName << vars << data; mimeData->setData("application/x-xplor-item", internalData); // 2. External format - raw file bytes if (!data.isEmpty()) { mimeData->setData("application/octet-stream", data); } // 3. Text representation QStringList lines; lines.append(QString("Name: %1").arg(name)); lines.append(QString("Type: %1").arg(typeName)); const QVariantMap visible = mTypeRegistry.filterVisibleFields(vars, typeName); for (auto it = visible.begin(); it != visible.end(); ++it) { lines.append(QString("%1: %2").arg(it.key(), it.value().toString())); } mimeData->setText(lines.join("\n")); // 4. Native format if content is image/audio auto contentType = exp.detectContentType(vars); if (contentType == ExportManager::Unknown && !data.isEmpty()) { contentType = exp.detectContentType(data, name); } if (contentType == ExportManager::Image && !data.isEmpty()) { QImage img; if (img.loadFromData(data)) { mimeData->setImageData(img); } } QApplication::clipboard()->setMimeData(mimeData); StatusBarManager::instance().updateStatus(QString("Copied '%1' to clipboard").arg(name), 3000); } else if (exp.supportedImageFormats().contains(format)) { QImage img; img.loadFromData(data); if (img.isNull()) { // Try ImagePreviewWidget's loaders for game formats auto* tempWidget = new ImagePreviewWidget(); tempWidget->loadFromData(data, name); // Get the pixmap and convert to image QPixmap pix = tempWidget->grab(); img = pix.toImage(); delete tempWidget; } if (!img.isNull()) { if (exp.exportImage(img, format, name, this)) { StatusBarManager::instance().updateStatus("Image exported successfully", 3000); } } else { StatusBarManager::instance().updateStatus("Failed to decode image data", 3000); } } else if (exp.supportedAudioFormats().contains(format)) { if (exp.exportAudio(data, format, name, this)) { StatusBarManager::instance().updateStatus("Audio exported successfully", 3000); } } else if (format == "txt") { if (exp.exportText(data, name, this)) { StatusBarManager::instance().updateStatus("Text exported successfully", 3000); } } }); // Handle quick export requests connect(mTreeWidget, &XTreeWidget::quickExportRequested, this, [this](QTreeWidgetItem* item) { if (!item) return; QVariantMap vars = item->data(0, Qt::UserRole + 3).toMap(); QString name = item->text(0); QByteArray data; if (DslKeys::contains(vars, DslKey::Preview)) { QVariant preview = DslKeys::get(vars, DslKey::Preview); if (preview.typeId() == QMetaType::QVariantMap) { data = preview.toMap().value("data").toByteArray(); } else { data = preview.toByteArray(); } } else if (vars.contains("data")) { data = vars.value("data").toByteArray(); } if (data.isEmpty()) { StatusBarManager::instance().updateStatus("No data available to export", 3000); return; } ExportManager& exp = ExportManager::instance(); auto contentType = exp.detectContentType(vars); if (contentType == ExportManager::Unknown) { contentType = exp.detectContentType(data, name); } if (exp.quickExport(data, name, contentType, this)) { StatusBarManager::instance().updateStatus("Export completed successfully", 3000); } }); // Handle export with dialog requests connect(mTreeWidget, &XTreeWidget::exportDialogRequested, this, [this](QTreeWidgetItem* item) { if (!item) return; QVariantMap vars = item->data(0, Qt::UserRole + 3).toMap(); QString name = item->text(0); QByteArray data; if (DslKeys::contains(vars, DslKey::Preview)) { QVariant preview = DslKeys::get(vars, DslKey::Preview); if (preview.typeId() == QMetaType::QVariantMap) { data = preview.toMap().value("data").toByteArray(); } else { data = preview.toByteArray(); } } else if (vars.contains("data")) { data = vars.value("data").toByteArray(); } if (data.isEmpty()) { StatusBarManager::instance().updateStatus("No data available to export", 3000); return; } ExportManager& exp = ExportManager::instance(); auto contentType = exp.detectContentType(vars); if (contentType == ExportManager::Unknown) { contentType = exp.detectContentType(data, name); } if (exp.exportWithDialog(data, name, contentType, this)) { StatusBarManager::instance().updateStatus("Export completed successfully", 3000); } }); // Handle batch export requests connect(mTreeWidget, &XTreeWidget::batchExportRequested, this, [this](QTreeWidgetItem* parentItem) { if (!parentItem) return; QList items; ExportManager& exp = ExportManager::instance(); std::function collectItems = [&](QTreeWidgetItem* item, const QString& basePath) { QString kind = item->data(0, Qt::UserRole + 1).toString(); if (kind == "INSTANCE") { QVariantMap vars = item->data(0, Qt::UserRole + 3).toMap(); QString name = item->text(0); QByteArray data; if (DslKeys::contains(vars, DslKey::Preview)) { QVariant preview = DslKeys::get(vars, DslKey::Preview); if (preview.typeId() == QMetaType::QVariantMap) { data = preview.toMap().value("data").toByteArray(); } else { data = preview.toByteArray(); } } else if (vars.contains("data")) { data = vars.value("data").toByteArray(); } if (!data.isEmpty()) { BatchExportItem batchItem; batchItem.name = name; batchItem.path = basePath.isEmpty() ? name : basePath + "/" + name; batchItem.data = data; batchItem.contentType = static_cast(exp.detectContentType(vars)); if (batchItem.contentType == ExportManager::Unknown) { batchItem.contentType = static_cast(exp.detectContentType(data, name)); } batchItem.selected = true; items.append(batchItem); } } for (int i = 0; i < item->childCount(); ++i) { QString childPath = basePath; if (kind == "CATEGORY" || kind == "SUBCATEGORY" || kind == "EXTENSION_GROUP") { childPath = basePath.isEmpty() ? item->text(0) : basePath + "/" + item->text(0); } collectItems(item->child(i), childPath); } }; for (int i = 0; i < parentItem->childCount(); ++i) { collectItems(parentItem->child(i), ""); } if (items.isEmpty()) { StatusBarManager::instance().updateStatus("No exportable items found", 3000); return; } if (exp.batchExport(items, this)) { StatusBarManager::instance().updateStatus("Batch export completed successfully", 3000); } }); // Handle batch export by type requests connect(mTreeWidget, &XTreeWidget::batchExportByTypeRequested, this, [this](QTreeWidgetItem* parentItem, int contentType) { if (!parentItem) return; QList items; ExportManager& exp = ExportManager::instance(); std::function collectItems = [&](QTreeWidgetItem* item, const QString& basePath) { QString kind = item->data(0, Qt::UserRole + 1).toString(); if (kind == "INSTANCE") { QVariantMap vars = item->data(0, Qt::UserRole + 3).toMap(); QString name = item->text(0); auto itemType = exp.detectContentType(vars); if (itemType == ExportManager::Unknown) { QByteArray tempData; if (DslKeys::contains(vars, DslKey::Preview)) { QVariant preview = DslKeys::get(vars, DslKey::Preview); if (preview.typeId() == QMetaType::QVariantMap) { tempData = preview.toMap().value("data").toByteArray(); } else { tempData = preview.toByteArray(); } } else if (vars.contains("data")) { tempData = vars.value("data").toByteArray(); } itemType = exp.detectContentType(tempData, name); } if (static_cast(itemType) != contentType) return; QByteArray data; if (DslKeys::contains(vars, DslKey::Preview)) { QVariant preview = DslKeys::get(vars, DslKey::Preview); if (preview.typeId() == QMetaType::QVariantMap) { data = preview.toMap().value("data").toByteArray(); } else { data = preview.toByteArray(); } } else if (vars.contains("data")) { data = vars.value("data").toByteArray(); } if (!data.isEmpty()) { BatchExportItem batchItem; batchItem.name = name; batchItem.path = basePath.isEmpty() ? name : basePath + "/" + name; batchItem.data = data; batchItem.contentType = contentType; batchItem.selected = true; items.append(batchItem); } } for (int i = 0; i < item->childCount(); ++i) { QString childPath = basePath; if (kind == "CATEGORY" || kind == "SUBCATEGORY" || kind == "EXTENSION_GROUP") { childPath = basePath.isEmpty() ? item->text(0) : basePath + "/" + item->text(0); } collectItems(item->child(i), childPath); } }; for (int i = 0; i < parentItem->childCount(); ++i) { collectItems(parentItem->child(i), ""); } if (items.isEmpty()) { StatusBarManager::instance().updateStatus("No exportable items found", 3000); return; } if (exp.batchExport(items, this)) { StatusBarManager::instance().updateStatus("Batch export completed successfully", 3000); } }); connect(mTreeWidget, &XTreeWidget::ItemSelected, this, [this](const QString itemText, QTreeWidgetItem* treeItem) { Q_UNUSED(itemText); auto* item = static_cast(treeItem); if (!item) return; const QString kind = item->data(0, Qt::UserRole + 1).toString(); if (kind != "INSTANCE") { // Clicking categories/subcategories does nothing (or you can show a summary panel) return; } const QVariantMap vars = item->data(0, Qt::UserRole + 3).toMap(); // Use _type from parsed data (set by parse_here delegation) over tree item's stored type const QString storedType = item->data(0, Qt::UserRole + 2).toString(); const QString typeName = DslKeys::contains(vars, DslKey::Type) ? DslKeys::getString(vars, DslKey::Type) : storedType; // Prevent dup tabs const QString tabTitle = item->text(0); for (int i = 0; i < ui->tabWidget->count(); i++) { if (ui->tabWidget->tabText(i) == tabTitle) { ui->tabWidget->setCurrentIndex(i); return; } } // Check if this item has a preview (resource data) LogManager::instance().addEntry(QString("[VIEWER] Checking item '%1', has _preview: %2, has data: %3") .arg(tabTitle) .arg(DslKeys::contains(vars, DslKey::Preview)) .arg(vars.contains("data"))); if (DslKeys::contains(vars, DslKey::Preview)) { const QVariantMap preview = DslKeys::get(vars, DslKey::Preview).toMap(); const QString filename = preview.value("filename").toString(); const QByteArray data = preview.value("data").toByteArray(); const QString lowerFilename = filename.toLower(); LogManager::instance().addEntry(QString("[VIEWER] Preview filename='%1', data size=%2") .arg(filename) .arg(data.size())); if (!data.isEmpty()) { // Get file extension QFileInfo fileInfo(filename); QString extension = fileInfo.suffix(); // Check if script specified a viewer type via set_viewer() QString viewerType; if (DslKeys::contains(vars, DslKey::Viewer)) { viewerType = DslKeys::getString(vars, DslKey::Viewer).toLower(); LogManager::instance().addEntry(QString("[VIEWER] Using script-defined viewer: '%1'") .arg(viewerType)); } else if (preview.contains("viewer")) { viewerType = preview.value("viewer").toString().toLower(); LogManager::instance().addEntry(QString("[VIEWER] Using preview-defined viewer: '%1'") .arg(viewerType)); } else { // Fall back to settings-based viewer type viewerType = Settings::instance().viewerForExtension(extension); LogManager::instance().addEntry(QString("[VIEWER] Extension='%1', viewerType='%2'") .arg(extension) .arg(viewerType)); } // Collect visible metadata based on UI schema QVariantMap metadata = mTypeRegistry.filterVisibleFields(vars, typeName); if (viewerType == "audio") { // Audio preview widget auto* audioWidget = new AudioPreviewWidget(ui->tabWidget); audioWidget->setProperty("PARENT_NAME", tabTitle); audioWidget->setProperty("TAB_DATA", data); audioWidget->setProperty("TAB_VARS", vars); audioWidget->loadFromData(data, filename); audioWidget->setMetadata(metadata); ui->tabWidget->addTab(audioWidget, tabTitle); ui->tabWidget->setCurrentWidget(audioWidget); return; } else if (viewerType == "image") { // Image preview widget auto* imageWidget = new ImagePreviewWidget(ui->tabWidget); imageWidget->setProperty("PARENT_NAME", tabTitle); imageWidget->setProperty("TAB_DATA", data); imageWidget->setProperty("TAB_VARS", vars); imageWidget->setFilename(filename); // Determine format from filename QString format; if (lowerFilename.endsWith(".tga")) format = "TGA"; else if (lowerFilename.endsWith(".dds")) format = "DDS"; else if (lowerFilename.endsWith(".png")) format = "PNG"; else if (lowerFilename.endsWith(".jpg") || lowerFilename.endsWith(".jpeg")) format = "JPG"; imageWidget->loadFromData(data, format); imageWidget->setMetadata(metadata); ui->tabWidget->addTab(imageWidget, tabTitle); ui->tabWidget->setCurrentWidget(imageWidget); return; } else if (viewerType == "text") { // Text viewer widget auto* textWidget = new TextViewerWidget(ui->tabWidget); textWidget->setProperty("PARENT_NAME", tabTitle); textWidget->setProperty("TAB_DATA", data); textWidget->setProperty("TAB_VARS", vars); textWidget->setData(data, filename); textWidget->setMetadata(metadata); ui->tabWidget->addTab(textWidget, tabTitle); ui->tabWidget->setCurrentWidget(textWidget); return; } else if (viewerType == "list") { // List preview widget - parse as string table auto* listWidget = new ListPreviewWidget(ui->tabWidget); listWidget->setProperty("PARENT_NAME", tabTitle); listWidget->setProperty("TAB_DATA", data); listWidget->setProperty("TAB_VARS", vars); // Parse data as null-terminated strings QVariantList items; int start = 0; for (int i = 0; i < data.size(); ++i) { if (data[i] == '\0' && i > start) { QString str = QString::fromUtf8(data.mid(start, i - start)); if (!str.isEmpty()) { QVariantMap item; item["string"] = str; item["offset"] = start; items.append(item); } start = i + 1; } } listWidget->setListData(items, filename); listWidget->setMetadata(metadata); ui->tabWidget->addTab(listWidget, tabTitle); ui->tabWidget->setCurrentWidget(listWidget); return; } else { // Hex viewer for unknown file types (default) auto* hexWidget = new HexViewerWidget(ui->tabWidget); hexWidget->setProperty("PARENT_NAME", tabTitle); hexWidget->setProperty("TAB_DATA", data); hexWidget->setProperty("TAB_VARS", vars); hexWidget->setData(data, filename); hexWidget->setMetadata(metadata); ui->tabWidget->addTab(hexWidget, tabTitle); ui->tabWidget->setCurrentWidget(hexWidget); return; } } } // Check if this is a chunk with binary data if (vars.contains("data") && vars.value("data").typeId() == QMetaType::QByteArray) { QByteArray chunkData = vars.value("data").toByteArray(); if (!chunkData.isEmpty()) { // Get extension from tab title (often includes filename) QFileInfo fileInfo(tabTitle); QString extension = fileInfo.suffix(); // Check if script specified a viewer type via set_viewer() QString viewerType; if (DslKeys::contains(vars, DslKey::Viewer)) { viewerType = DslKeys::getString(vars, DslKey::Viewer).toLower(); LogManager::instance().addEntry(QString("[VIEWER] Using script-defined viewer: '%1'") .arg(viewerType)); } else { viewerType = Settings::instance().viewerForExtension(extension); } // Collect visible metadata based on UI schema QVariantMap metadata = mTypeRegistry.filterVisibleFields(vars, typeName); metadata.remove("data"); // Exclude raw binary data from display if (viewerType == "text") { auto* textWidget = new TextViewerWidget(ui->tabWidget); textWidget->setProperty("PARENT_NAME", tabTitle); textWidget->setProperty("TAB_DATA", chunkData); textWidget->setProperty("TAB_VARS", vars); textWidget->setData(chunkData, tabTitle); textWidget->setMetadata(metadata); ui->tabWidget->addTab(textWidget, tabTitle); ui->tabWidget->setCurrentWidget(textWidget); return; } else if (viewerType == "image") { auto* imageWidget = new ImagePreviewWidget(ui->tabWidget); imageWidget->setProperty("PARENT_NAME", tabTitle); imageWidget->setProperty("TAB_DATA", chunkData); imageWidget->setProperty("TAB_VARS", vars); imageWidget->setFilename(tabTitle); imageWidget->loadFromData(chunkData); imageWidget->setMetadata(metadata); ui->tabWidget->addTab(imageWidget, tabTitle); ui->tabWidget->setCurrentWidget(imageWidget); return; } else if (viewerType == "audio") { auto* audioWidget = new AudioPreviewWidget(ui->tabWidget); audioWidget->setProperty("PARENT_NAME", tabTitle); audioWidget->setProperty("TAB_DATA", chunkData); audioWidget->setProperty("TAB_VARS", vars); audioWidget->loadFromData(chunkData, tabTitle); audioWidget->setMetadata(metadata); ui->tabWidget->addTab(audioWidget, tabTitle); ui->tabWidget->setCurrentWidget(audioWidget); return; } else if (viewerType == "list") { // List preview widget - parse as string table auto* listWidget = new ListPreviewWidget(ui->tabWidget); listWidget->setProperty("PARENT_NAME", tabTitle); listWidget->setProperty("TAB_DATA", chunkData); listWidget->setProperty("TAB_VARS", vars); // Parse chunkData as null-terminated strings QVariantList items; int start = 0; for (int i = 0; i < chunkData.size(); ++i) { if (chunkData[i] == '\0' && i > start) { QString str = QString::fromUtf8(chunkData.mid(start, i - start)); if (!str.isEmpty()) { QVariantMap item; item["string"] = str; item["offset"] = start; items.append(item); } start = i + 1; } } listWidget->setListData(items, tabTitle); listWidget->setMetadata(metadata); ui->tabWidget->addTab(listWidget, tabTitle); ui->tabWidget->setCurrentWidget(listWidget); return; } else { // Try image detection by content for unknown extensions if (looksLikeImage(chunkData)) { auto* imageWidget = new ImagePreviewWidget(ui->tabWidget); imageWidget->setProperty("PARENT_NAME", tabTitle); imageWidget->setProperty("TAB_DATA", chunkData); imageWidget->setProperty("TAB_VARS", vars); imageWidget->setFilename(tabTitle); imageWidget->loadFromData(chunkData); imageWidget->setMetadata(metadata); ui->tabWidget->addTab(imageWidget, tabTitle); ui->tabWidget->setCurrentWidget(imageWidget); return; } // Default to hex viewer auto* hexWidget = new HexViewerWidget(ui->tabWidget); hexWidget->setProperty("PARENT_NAME", tabTitle); hexWidget->setProperty("TAB_DATA", chunkData); hexWidget->setProperty("TAB_VARS", vars); hexWidget->setData(chunkData, tabTitle); hexWidget->setMetadata(metadata); ui->tabWidget->addTab(hexWidget, tabTitle); ui->tabWidget->setCurrentWidget(hexWidget); return; } } } // Check if a specific viewer type was requested via set_viewer() // This allows xscript to override the default form+hex view if (DslKeys::contains(vars, DslKey::Viewer)) { QString viewerType = DslKeys::getString(vars, DslKey::Viewer).toLower(); // Get data from wherever available QByteArray viewData; QString filename = tabTitle; if (DslKeys::contains(vars, DslKey::Preview)) { QVariantMap preview = DslKeys::get(vars, DslKey::Preview).toMap(); viewData = preview.value("data").toByteArray(); if (preview.contains("filename")) filename = preview.value("filename").toString(); } else if (vars.contains("data")) { viewData = vars.value("data").toByteArray(); } else if (vars.contains("_original_bytes")) { viewData = vars.value("_original_bytes").toByteArray(); } if (viewerType == "text" && !viewData.isEmpty()) { auto* textWidget = new TextViewerWidget(ui->tabWidget); textWidget->setProperty("PARENT_NAME", tabTitle); textWidget->setProperty("treeItem", QVariant::fromValue(item)); textWidget->setData(viewData, filename); ui->tabWidget->addTab(textWidget, tabTitle); ui->tabWidget->setCurrentWidget(textWidget); return; } // For hex viewer type with data, create simple hex view if (viewerType == "hex" && !viewData.isEmpty()) { auto* hexWidget = new HexViewerWidget(ui->tabWidget); hexWidget->setProperty("PARENT_NAME", tabTitle); hexWidget->setProperty("treeItem", QVariant::fromValue(item)); hexWidget->setData(viewData, filename); ui->tabWidget->addTab(hexWidget, tabTitle); ui->tabWidget->setCurrentWidget(hexWidget); return; } } const TypeDef& td = mTypeRegistry.module().types[typeName]; const auto schema = buildUiSchemaForType(td, &mTypeRegistry.module()); // Create container widget with tabbed layout (Form + Hex views) auto* containerWidget = new QWidget(ui->tabWidget); auto* containerLayout = new QVBoxLayout(containerWidget); containerLayout->setContentsMargins(0, 0, 0, 0); auto* innerTabWidget = new QTabWidget(containerWidget); // Form tab auto* formEditor = new ScriptTypeEditorWidget(typeName, schema, innerTabWidget); formEditor->setValues(vars); innerTabWidget->addTab(formEditor, "Form"); // Hex tab - shows recompiled binary auto* hexView = new HexView(innerTabWidget); hexView->setTheme(Settings::instance().theme()); innerTabWidget->addTab(hexView, "Hex"); // Store original bytes if available from vars (for diff highlighting later) QByteArray originalBytes; if (vars.contains("_original_bytes")) { originalBytes = vars.value("_original_bytes").toByteArray(); } formEditor->setOriginalBytes(originalBytes); // Initialize hex view with original data if (!originalBytes.isEmpty()) { hexView->setData(originalBytes); } else if (vars.contains("data")) { // Fall back to data field if available hexView->setData(vars.value("data").toByteArray()); } else if (DslKeys::contains(vars, DslKey::Preview)) { // Try preview data as last resort QVariant preview = DslKeys::get(vars, DslKey::Preview); if (preview.typeId() == QMetaType::QVariantMap) { QVariantMap pm = preview.toMap(); if (pm.contains("data")) { hexView->setData(pm.value("data").toByteArray()); } } } // Connect recompilation on edit connect(formEditor, &ScriptTypeEditorWidget::requestRecompile, containerWidget, [formEditor, hexView, this]() { int journalId = formEditor->journalId(); if (journalId < 0) { // No journal - show original bytes or empty hexView->setData(formEditor->originalBytes()); return; } Recompiler recompiler; if (!recompiler.canRecompile(journalId)) { // Not exportable - show warning in status bar StatusBarManager::instance().updateStatus( "Cannot recompile: " + recompiler.whyNotRecompilable(journalId), 3000); hexView->setData(formEditor->originalBytes()); return; } QStringList errors; QByteArray recompiled = recompiler.recompileWithErrors(journalId, formEditor->values(), errors); if (!errors.isEmpty()) { StatusBarManager::instance().updateStatus("Recompile warnings: " + errors.join("; "), 3000); } hexView->setData(recompiled); }); // Connect theme changes connect(&Settings::instance(), &Settings::themeChanged, hexView, &HexView::setTheme); containerLayout->addWidget(innerTabWidget); containerWidget->setProperty("PARENT_NAME", tabTitle); containerWidget->setProperty("formEditor", QVariant::fromValue(formEditor)); containerWidget->setProperty("hexView", QVariant::fromValue(hexView)); containerWidget->setProperty("treeItem", QVariant::fromValue(item)); // Connect dirty state tracking connect(formEditor, &ScriptTypeEditorWidget::modifiedChanged, containerWidget, [containerWidget, this](bool modified) { if (modified) { DirtyStateManager::instance().markDirty(containerWidget); } else { DirtyStateManager::instance().markClean(containerWidget); } }); // Connect value changes for undo/redo support connect(formEditor, &ScriptTypeEditorWidget::valueChanged, this, [this, formEditor](const QString& fieldName, const QVariant& oldValue, const QVariant& newValue) { int journalId = formEditor->journalId(); if (journalId >= 0) { pushFieldEdit(journalId, fieldName, oldValue, newValue); } }); ui->tabWidget->addTab(containerWidget, tabTitle); ui->tabWidget->setCurrentWidget(containerWidget); }); connect(mTreeWidget, &XTreeWidget::ItemClosed, this, [this](const QString itemText) { for (int i = 0; i < ui->tabWidget->count(); i++) { const QString parentName = ui->tabWidget->widget(i)->property("PARENT_NAME").toString(); if (parentName == itemText) { ui->tabWidget->removeTab(i); break; } } }); ui->toolBar->setWindowTitle("Tool Bar"); QDockWidget *treeDockWidget = new QDockWidget(this); treeDockWidget->setWidget(mTreeWidget); treeDockWidget->setWindowTitle("Tree Browser"); addDockWidget(Qt::LeftDockWidgetArea, treeDockWidget); QDockWidget *logDockWidget = new QDockWidget(this); logDockWidget->setWidget(mLogWidget); logDockWidget->setWindowTitle("Logs"); addDockWidget(Qt::BottomDockWidgetArea, logDockWidget); // ========== Create Actions ========== // File menu actions actionNew = new QAction(QIcon::fromTheme("document-new"), "New", this); actionOpen = new QAction(QIcon::fromTheme("document-open"), "Open", this); actionOpenFolder = new QAction(QIcon::fromTheme("folder-open"), "Open Folder", this); actionSave = new QAction(QIcon::fromTheme("document-save"), "Save", this); actionSaveAs = new QAction(QIcon::fromTheme("document-save-as"), "Save As", this); // Edit menu actions actionUndo = new QAction(QIcon::fromTheme("edit-undo"), "Undo", this); actionRedo = new QAction(QIcon::fromTheme("edit-redo"), "Redo", this); actionCut = new QAction(QIcon::fromTheme("edit-cut"), "Cut", this); actionCopy = new QAction(QIcon::fromTheme("edit-copy"), "Copy", this); actionPaste = new QAction(QIcon::fromTheme("edit-paste"), "Paste", this); actionRename = new QAction(QIcon::fromTheme("edit-rename"), "Rename", this); actionDelete = new QAction(QIcon::fromTheme("edit-delete"), "Delete", this); actionFind = new QAction(QIcon::fromTheme("edit-find"), "Find", this); actionClearUndoHistory = new QAction(QIcon::fromTheme("edit-clear"), "Clear Undo History", this); actionPreferences = new QAction(QIcon::fromTheme("preferences-system"), "Preferences...", this); // Tools menu actions actionRunTests = new QAction(QIcon::fromTheme("system-run"), "Run Tests", this); actionViewDefinitions = new QAction(QIcon::fromTheme("document-properties"), "View Definitions...", this); // Help menu actions actionAbout = new QAction(QIcon::fromTheme("help-about"), "About", this); actionCheckForUpdates = new QAction(QIcon::fromTheme("system-software-update"), "Check for Updates", this); actionOpenMaintenanceTool = new QAction(QIcon::fromTheme("system-software-update"), "Open Maintenance Tool...", this); actionReportIssue = new QAction(QIcon::fromTheme("tools-report-bug"), "Report Issue", this); // Toolbar actions actionReparse = new QAction(QIcon::fromTheme("view-refresh"), "Reparse", this); actionReparse->setToolTip("Reload definitions and reparse open files"); // Disable unimplemented actions actionNew->setEnabled(false); actionOpenFolder->setEnabled(false); // Save/SaveAs enabled based on current tab actionSave->setEnabled(false); actionSaveAs->setEnabled(false); actionUndo->setEnabled(false); actionRedo->setEnabled(false); actionCut->setEnabled(false); actionCopy->setEnabled(false); actionPaste->setEnabled(false); actionRename->setEnabled(false); actionDelete->setEnabled(false); actionFind->setEnabled(false); actionClearUndoHistory->setEnabled(false); actionRunTests->setEnabled(false); // ========== Add Actions to Menus ========== ui->MenuDef->addAction(actionNew); ui->MenuDef->addSeparator(); ui->MenuDef->addAction(actionOpen); ui->MenuDef->addAction(actionOpenFolder); ui->MenuDef->addSeparator(); ui->MenuDef->addAction(actionSave); ui->MenuDef->addAction(actionSaveAs); ui->menuEdit->addAction(actionUndo); ui->menuEdit->addAction(actionRedo); ui->menuEdit->addSeparator(); ui->menuEdit->addAction(actionCut); ui->menuEdit->addAction(actionCopy); ui->menuEdit->addAction(actionPaste); ui->menuEdit->addAction(actionRename); ui->menuEdit->addAction(actionDelete); ui->menuEdit->addSeparator(); ui->menuEdit->addAction(actionFind); ui->menuEdit->addSeparator(); ui->menuEdit->addAction(actionClearUndoHistory); ui->menuEdit->addSeparator(); ui->menuEdit->addAction(actionPreferences); ui->menuTools->addAction(actionRunTests); ui->menuTools->addAction(actionViewDefinitions); ui->menuHelp->addAction(actionAbout); ui->menuHelp->addSeparator(); ui->menuHelp->addAction(actionCheckForUpdates); ui->menuHelp->addAction(actionOpenMaintenanceTool); ui->menuHelp->addSeparator(); ui->menuHelp->addAction(actionReportIssue); // ========== Add Actions to Toolbar ========== ui->toolBar->addAction(actionNew); ui->toolBar->addAction(actionOpen); ui->toolBar->addAction(actionOpenFolder); ui->toolBar->addAction(actionSave); ui->toolBar->addSeparator(); ui->toolBar->addAction(actionUndo); ui->toolBar->addAction(actionRedo); ui->toolBar->addSeparator(); ui->toolBar->addAction(actionCut); ui->toolBar->addAction(actionCopy); ui->toolBar->addAction(actionPaste); ui->toolBar->addSeparator(); ui->toolBar->addAction(actionFind); ui->toolBar->addSeparator(); ui->toolBar->addAction(actionReparse); // ========== Set Keyboard Shortcuts ========== actionNew->setShortcut(QKeySequence::New); actionOpen->setShortcut(QKeySequence::Open); actionSave->setShortcut(QKeySequence::Save); actionSaveAs->setShortcut(QKeySequence::SaveAs); actionUndo->setShortcut(QKeySequence::Undo); actionRedo->setShortcut(QKeySequence::Redo); actionCut->setShortcut(QKeySequence::Cut); actionCopy->setShortcut(QKeySequence::Copy); actionPaste->setShortcut(QKeySequence::Paste); actionDelete->setShortcut(QKeySequence::Delete); actionFind->setShortcut(QKeySequence::Find); actionReparse->setShortcut(QKeySequence(Qt::Key_F5)); // ========== Connect Action Signals ========== // File menu - New connect(actionNew, &QAction::triggered, this, [this]() { if (!mOpenedFilePaths.isEmpty()) { QMessageBox::StandardButton reply = QMessageBox::question( this, "New Session", "This will close all open files. Continue?", QMessageBox::Yes | QMessageBox::No); if (reply != QMessageBox::Yes) return; } Reset(); mOpenedFilePaths.clear(); statusBar()->showMessage("New session started", 3000); }); connect(actionOpen, &QAction::triggered, this, [this]() { // Build filter string from loaded definitions QMap exts = mTypeRegistry.supportedExtensions(); QStringList filterParts; QStringList allExts; for (auto it = exts.begin(); it != exts.end(); ++it) { const QString& ext = it.key(); const QString& displayName = it.value(); filterParts.append(QString("%1 (*.%2)").arg(displayName, ext)); allExts.append(QString("*.%1").arg(ext)); } // Sort filters alphabetically by display name filterParts.sort(Qt::CaseInsensitive); // Add "All Supported Files" at the beginning QString allFilter = QString("All Supported Files (%1)").arg(allExts.join(" ")); filterParts.prepend(allFilter); // Add "All Files" at the end filterParts.append("All Files (*)"); QString filter = filterParts.join(";;"); QStringList filePaths = QFileDialog::getOpenFileNames( this, "Open File", QString(), filter ); for (const QString& path : filePaths) { const QString fileName = QFileInfo(path).fileName(); QFile inputFile(path); if (!inputFile.open(QIODevice::ReadOnly)) { LogManager::instance().addError(QString("Failed to open: %1").arg(path)); continue; } LogManager::instance().addEntry(QString("[FILE] Analyzing: %1").arg(fileName)); const QString rootType = mTypeRegistry.chooseType(&inputFile, path); LogManager::instance().addEntry(QString("[FILE] Matched type '%1' for file: %2").arg(rootType).arg(fileName)); if (rootType.isEmpty()) { LogManager::instance().addError(QString("No matching definition for: %1").arg(path)); continue; } inputFile.seek(0); LogManager::instance().addLine(); LogManager::instance().addEntry(QString("========== PARSING %1 ==========").arg(fileName)); LogManager::instance().addLine(); const qint64 fileSize = inputFile.size(); QProgressDialog progress(QString("Parsing %1...").arg(fileName), "Cancel", 0, static_cast(fileSize), this); progress.setWindowTitle("XPlor - Parsing"); progress.setWindowModality(Qt::WindowModal); progress.setMinimumDuration(500); QVariantMap rootVars; bool cancelled = false; try { rootVars = mTypeRegistry.parse(rootType, &inputFile, path, [this, &progress, &cancelled](qint64 pos, qint64 size) { // Only update progress if position is moving forward (avoid jumps from seek) if (pos > progress.value()) { progress.setValue(static_cast(pos)); } QApplication::processEvents(); if (progress.wasCanceled()) { cancelled = true; throw std::runtime_error("Parsing cancelled by user"); } }, [this, &progress, &cancelled](const QString& status) { progress.setLabelText(status); QApplication::processEvents(); if (progress.wasCanceled()) { cancelled = true; throw std::runtime_error("Parsing cancelled by user"); } }); } catch (const std::exception& e) { if (cancelled) { LogManager::instance().addEntry(QString("[FILE] Parsing cancelled: %1").arg(fileName)); continue; } LogManager::instance().addLine(); LogManager::instance().addError(QString("========== PARSE ERROR in %1 ==========").arg(fileName)); LogManager::instance().addError(QString("Error: %1").arg(e.what())); LogManager::instance().addError(QString("File position: 0x%1").arg(inputFile.pos(), 0, 16)); LogManager::instance().addLine(); QMessageBox::critical(this, "Parse Error", QString("Failed to parse %1:\n\n%2").arg(fileName).arg(e.what())); continue; } LogManager::instance().addLine(); LogManager::instance().addEntry(QString("========== FINISHED %1 ==========").arg(fileName)); LogManager::instance().addLine(); if (!mOpenedFilePaths.contains(path)) { mOpenedFilePaths.append(path); } // Update progress for tree building phase progress.setLabelText(QString("Building tree for %1...").arg(fileName)); progress.setRange(0, 0); // Indeterminate mode QApplication::processEvents(); XTreeWidgetItem* cat = mTreeBuilder.ensureTypeCategoryRoot(rootType, DslKeys::getString(rootVars, DslKey::Display)); const QString itemDisplayName = DslKeys::contains(rootVars, DslKey::Name) ? DslKeys::getString(rootVars, DslKey::Name) : fileName; auto* rootInst = mTreeBuilder.addInstanceNode(cat, itemDisplayName, rootType, rootVars); mTreeBuilder.routeNestedObjects(rootInst, rootVars); progress.setLabelText(QString("Organizing %1...").arg(fileName)); QApplication::processEvents(); mTreeBuilder.organizeChildrenByExtension(rootInst); mTreeBuilder.updateNodeCounts(cat); cat->setExpanded(false); mTreeWidget->setCurrentItem(rootInst); } }); connect(actionOpenFolder, &QAction::triggered, this, [this]() { QString dirPath = QFileDialog::getExistingDirectory( this, "Open Folder", QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation)); if (dirPath.isEmpty()) return; // Get supported extensions QMap exts = mTypeRegistry.supportedExtensions(); QStringList extPatterns; for (const QString& ext : exts.keys()) { extPatterns.append("*." + ext); } // Find all matching files QDirIterator it(dirPath, extPatterns, QDir::Files, QDirIterator::Subdirectories); QStringList filesToOpen; while (it.hasNext()) { filesToOpen.append(it.next()); } if (filesToOpen.isEmpty()) { QMessageBox::information(this, "Open Folder", QString("No supported files found in:\n%1").arg(dirPath)); return; } // Confirm if many files if (filesToOpen.size() > 10) { QMessageBox::StandardButton reply = QMessageBox::question( this, "Open Folder", QString("Found %1 files. Open all?").arg(filesToOpen.size()), QMessageBox::Yes | QMessageBox::No); if (reply != QMessageBox::Yes) return; } // Open each file (reuse Open logic) for (const QString& path : filesToOpen) { const QString fileName = QFileInfo(path).fileName(); QFile inputFile(path); if (!inputFile.open(QIODevice::ReadOnly)) continue; const QString rootType = mTypeRegistry.chooseType(&inputFile, path); if (rootType.isEmpty()) continue; inputFile.seek(0); QVariantMap rootVars; try { rootVars = mTypeRegistry.parse(rootType, &inputFile, path, nullptr); } catch (...) { continue; } if (!mOpenedFilePaths.contains(path)) { mOpenedFilePaths.append(path); } XTreeWidgetItem* cat = mTreeBuilder.ensureTypeCategoryRoot(rootType, DslKeys::getString(rootVars, DslKey::Display)); const QString itemDisplayName = DslKeys::contains(rootVars, DslKey::Name) ? DslKeys::getString(rootVars, DslKey::Name) : fileName; auto* rootInst = mTreeBuilder.addInstanceNode(cat, itemDisplayName, rootType, rootVars); mTreeBuilder.routeNestedObjects(rootInst, rootVars); mTreeBuilder.organizeChildrenByExtension(rootInst); mTreeBuilder.updateNodeCounts(cat); cat->setExpanded(false); } statusBar()->showMessage(QString("Opened %1 files from folder").arg(filesToOpen.size()), 3000); }); connect(actionSave, &QAction::triggered, this, [this]() { QWidget* currentTab = ui->tabWidget->currentWidget(); if (currentTab) { saveTab(currentTab); } }); connect(actionSaveAs, &QAction::triggered, this, [this]() { QWidget* currentTab = ui->tabWidget->currentWidget(); if (currentTab) { saveTabAs(currentTab); } }); // Edit menu connections - wire to undo stack connect(actionUndo, &QAction::triggered, mUndoStack, &QUndoStack::undo); connect(actionRedo, &QAction::triggered, mUndoStack, &QUndoStack::redo); // Update action enabled state based on stack state connect(mUndoStack, &QUndoStack::canUndoChanged, actionUndo, &QAction::setEnabled); connect(mUndoStack, &QUndoStack::canRedoChanged, actionRedo, &QAction::setEnabled); // Update action text with undo/redo description connect(mUndoStack, &QUndoStack::undoTextChanged, this, [this](const QString& text) { actionUndo->setText(text.isEmpty() ? "Undo" : QString("Undo %1").arg(text)); }); connect(mUndoStack, &QUndoStack::redoTextChanged, this, [this](const QString& text) { actionRedo->setText(text.isEmpty() ? "Redo" : QString("Redo %1").arg(text)); }); // Initialize action state actionUndo->setEnabled(mUndoStack->canUndo()); actionRedo->setEnabled(mUndoStack->canRedo()); connect(actionCut, &QAction::triggered, this, [this]() { statusBar()->showMessage("Cut not available - read-only mode", 2000); }); connect(actionCopy, &QAction::triggered, this, [this]() { // Copy selected tree item to clipboard with multiple formats auto* item = static_cast(mTreeWidget->currentItem()); if (!item) { statusBar()->showMessage("Nothing selected to copy", 2000); return; } QString name = item->text(0); const QString kind = item->data(0, Qt::UserRole + 1).toString(); // Only allow copying INSTANCE items (not groups/categories) if (kind != "INSTANCE") { statusBar()->showMessage("Can only copy file items", 2000); return; } const QVariantMap vars = item->data(0, Qt::UserRole + 3).toMap(); const QString typeName = item->data(0, Qt::UserRole + 2).toString(); // Extract file data from the item QByteArray data; if (DslKeys::contains(vars, DslKey::Preview)) { QVariant preview = DslKeys::get(vars, DslKey::Preview); if (preview.typeId() == QMetaType::QVariantMap) { data = preview.toMap().value("data").toByteArray(); } else { data = preview.toByteArray(); } } else if (vars.contains("data")) { data = vars.value("data").toByteArray(); } QMimeData* mimeData = new QMimeData(); // 1. Internal format - full metadata for paste inside app QByteArray internalData; QDataStream stream(&internalData, QIODevice::WriteOnly); stream << name << typeName << vars << data; mimeData->setData("application/x-xplor-item", internalData); // 2. External format - raw file bytes if (!data.isEmpty()) { mimeData->setData("application/octet-stream", data); } // 3. Text representation - for pasting into text editors QStringList lines; lines.append(QString("Name: %1").arg(name)); lines.append(QString("Type: %1").arg(typeName)); const QVariantMap visible = mTypeRegistry.filterVisibleFields(vars, typeName); for (auto it = visible.begin(); it != visible.end(); ++it) { lines.append(QString("%1: %2").arg(it.key(), it.value().toString())); } mimeData->setText(lines.join("\n")); // 4. Native image format if content is an image ExportManager& exp = ExportManager::instance(); ExportManager::ContentType contentType = exp.detectContentType(vars); if (contentType == ExportManager::Unknown && !data.isEmpty()) { contentType = exp.detectContentType(data, name); } if (contentType == ExportManager::Image && !data.isEmpty()) { QImage img; if (img.loadFromData(data)) { mimeData->setImageData(img); } } QApplication::clipboard()->setMimeData(mimeData); statusBar()->showMessage(QString("Copied '%1' to clipboard").arg(name), 2000); }); connect(actionPaste, &QAction::triggered, this, [this]() { statusBar()->showMessage("Paste not available - read-only mode", 2000); }); connect(actionRename, &QAction::triggered, this, [this]() { statusBar()->showMessage("Rename not available - read-only mode", 2000); }); connect(actionDelete, &QAction::triggered, this, [this]() { statusBar()->showMessage("Delete not available - read-only mode", 2000); }); connect(actionFind, &QAction::triggered, this, [this]() { bool ok; QString searchText = QInputDialog::getText(this, "Find", "Search for:", QLineEdit::Normal, QString(), &ok); if (!ok || searchText.isEmpty()) return; // Search through tree items QList matches = mTreeWidget->findItems( searchText, Qt::MatchContains | Qt::MatchRecursive, 0); if (matches.isEmpty()) { QMessageBox::information(this, "Find", QString("No matches found for '%1'").arg(searchText)); return; } // Select first match and expand parents QTreeWidgetItem* first = matches.first(); mTreeWidget->setCurrentItem(first); mTreeWidget->scrollToItem(first); // Expand all parents QTreeWidgetItem* parent = first->parent(); while (parent) { parent->setExpanded(false); parent = parent->parent(); } statusBar()->showMessage(QString("Found %1 match(es)").arg(matches.size()), 3000); }); connect(actionClearUndoHistory, &QAction::triggered, this, [this]() { statusBar()->showMessage("Undo history cleared (no history in read-only mode)", 2000); }); connect(actionPreferences, &QAction::triggered, this, [this]() { PreferenceEditor *prefEditor = new PreferenceEditor(this); prefEditor->exec(); prefEditor->close(); delete prefEditor; }); // Tools menu connections connect(actionRunTests, &QAction::triggered, this, [this]() { // Run validation on all loaded definitions QStringList errors = mTypeRegistry.validateTypeReferences(); if (errors.isEmpty()) { QMessageBox::information(this, "Run Tests", QString("All tests passed!\n\n" "Loaded types: %1\n" "Supported extensions: %2") .arg(mTypeRegistry.typeNames().size()) .arg(mTypeRegistry.supportedExtensions().size())); } else { QString errorList = errors.join("\n"); QMessageBox::warning(this, "Run Tests", QString("Found %1 error(s):\n\n%2").arg(errors.size()).arg(errorList)); } }); connect(actionViewDefinitions, &QAction::triggered, this, [this]() { DefinitionViewer viewer(mDefinitionResults, this); viewer.exec(); }); // Help menu connections connect(actionAbout, &QAction::triggered, this, [this]() { AboutDialog *aboutDialog = new AboutDialog(this); aboutDialog->exec(); delete aboutDialog; }); connect(actionCheckForUpdates, &QAction::triggered, this, [this]() { // Open releases page in browser QDesktopServices::openUrl(QUrl("https://code.redline.llc/njohnson/XPlor/releases")); statusBar()->showMessage("Opening releases page in browser...", 3000); }); connect(actionOpenMaintenanceTool, &QAction::triggered, this, [this]() { QString appDir = QCoreApplication::applicationDirPath(); QString maintenanceTool = appDir + "/XPlorMaintenanceTool.exe"; if (QFile::exists(maintenanceTool)) { QProcess::startDetached(maintenanceTool, QStringList()); statusBar()->showMessage("Opening Maintenance Tool...", 3000); } else { QMessageBox::warning(this, "Maintenance Tool", "Maintenance Tool not found.\n\n" "The tool is only available when XPlor was installed using the installer.\n" "Expected location: " + maintenanceTool); } }); connect(actionReportIssue, &QAction::triggered, this, [this]() { ReportIssueDialog dialog(this); dialog.exec(); }); // Reparse action connection connect(actionReparse, &QAction::triggered, this, [this]() { if (mOpenedFilePaths.isEmpty()) { QMessageBox::information(this, "Reparse", "No files to reparse. Drag and drop files first."); return; } LogManager::instance().addLine(); LogManager::instance().addEntry("[REPARSE] Reloading definitions and reparsing files..."); // Store file paths before clearing QStringList filesToReparse = mOpenedFilePaths; // Clear everything Reset(); mOpenedFilePaths.clear(); // Clear and reload type registry mTypeRegistry = TypeRegistry(); LoadDefinitions(); LogManager::instance().addEntry(QString("[REPARSE] Definitions reloaded, reparsing %1 file(s)...").arg(filesToReparse.size())); // Reparse each file for (const QString& path : filesToReparse) { QFile inputFile(path); if (!inputFile.open(QIODevice::ReadOnly)) { LogManager::instance().addError(QString("[REPARSE] Failed to open: %1").arg(path)); continue; } const QString fileName = QFileInfo(path).fileName(); LogManager::instance().addEntry(QString("[FILE] Analyzing: %1").arg(fileName)); const QString rootType = mTypeRegistry.chooseType(&inputFile, path); LogManager::instance().addEntry(QString("[FILE] Matched type '%1' for file: %2").arg(rootType).arg(fileName)); if (rootType.isEmpty()) { LogManager::instance().addError(QString("No matching definition for: %1").arg(path)); continue; } inputFile.seek(0); LogManager::instance().addLine(); LogManager::instance().addEntry(QString("========== PARSING %1 ==========").arg(fileName)); LogManager::instance().addLine(); const qint64 fileSize = inputFile.size(); QProgressDialog progress(QString("Parsing %1...").arg(fileName), "Cancel", 0, static_cast(fileSize), this); progress.setWindowTitle("XPlor - Reparsing"); progress.setWindowModality(Qt::WindowModal); progress.setMinimumDuration(500); QVariantMap rootVars; bool cancelled = false; try { rootVars = mTypeRegistry.parse(rootType, &inputFile, path, [this, &progress, &cancelled](qint64 pos, qint64 size) { if (pos > progress.value()) { progress.setValue(static_cast(pos)); } QApplication::processEvents(); if (progress.wasCanceled()) { cancelled = true; throw std::runtime_error("Parsing cancelled by user"); } }, [this, &progress, &cancelled](const QString& status) { progress.setLabelText(status); QApplication::processEvents(); if (progress.wasCanceled()) { cancelled = true; throw std::runtime_error("Parsing cancelled by user"); } }); } catch (const std::exception& e) { if (cancelled) { LogManager::instance().addEntry(QString("[FILE] Parsing cancelled: %1").arg(fileName)); continue; } LogManager::instance().addLine(); LogManager::instance().addError(QString("========== PARSE ERROR in %1 ==========").arg(fileName)); LogManager::instance().addError(QString("Error: %1").arg(e.what())); LogManager::instance().addError(QString("File position: 0x%1").arg(inputFile.pos(), 0, 16)); LogManager::instance().addLine(); continue; } LogManager::instance().addLine(); LogManager::instance().addEntry(QString("========== FINISHED %1 ==========").arg(fileName)); LogManager::instance().addLine(); // Track this file again mOpenedFilePaths.append(path); // Update progress for tree building phase progress.setLabelText(QString("Building tree for %1...").arg(fileName)); progress.setRange(0, 0); // Indeterminate mode QApplication::processEvents(); // Rebuild tree XTreeWidgetItem* cat = mTreeBuilder.ensureTypeCategoryRoot(rootType, DslKeys::getString(rootVars, DslKey::Display)); const QString itemDisplayName = DslKeys::contains(rootVars, DslKey::Name) ? DslKeys::getString(rootVars, DslKey::Name) : fileName; auto* rootInst = mTreeBuilder.addInstanceNode(cat, itemDisplayName, rootType, rootVars); mTreeBuilder.routeNestedObjects(rootInst, rootVars); progress.setLabelText(QString("Organizing %1...").arg(fileName)); QApplication::processEvents(); mTreeBuilder.organizeChildrenByExtension(rootInst); mTreeBuilder.updateNodeCounts(cat); cat->setExpanded(false); } LogManager::instance().addLine(); LogManager::instance().addEntry("[REPARSE] Reparse complete!"); statusBar()->showMessage("Reparse complete!", 3000); }); Reset(); // LoadDefinitions is now called from main.cpp during splash screen //LoadTreeCategories(); // Check for QuickBMS after window is shown QTimer::singleShot(500, this, [this]() { QString quickBmsPath = Settings::instance().quickBmsPath(); if (quickBmsPath.isEmpty()) { // QuickBMS not found - prompt user QMessageBox::StandardButton reply = QMessageBox::question( this, "QuickBMS Not Found", "QuickBMS is required for decompressing certain Xbox 360 formats.\n\n" "Would you like to locate quickbms.exe now?\n\n" "(You can also configure this later in Edit > Preferences > Tools)", QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes ); if (reply == QMessageBox::Yes) { QString file = QFileDialog::getOpenFileName( this, "Locate QuickBMS Executable", QDir::homePath(), "QuickBMS (quickbms.exe);;All Files (*.*)" ); if (!file.isEmpty()) { Settings::instance().setQuickBmsPath(file); Compression::setQuickBmsPath(file); statusBar()->showMessage("QuickBMS configured: " + file, 5000); } } } }); } MainWindow::~MainWindow() { delete ui; } void MainWindow::setTypeRegistry(TypeRegistry&& registry, const QVector& results) { mTypeRegistry = std::move(registry); mDefinitionResults = results; // Validate all type references after loading QStringList refErrors = mTypeRegistry.validateTypeReferences(); for (const QString& err : refErrors) { LogManager::instance().addError(QString("[DEF] %1").arg(err)); } if (!refErrors.isEmpty()) { LogManager::instance().addError(QString("[DEF] Found %1 invalid type reference(s)").arg(refErrors.size())); } } void MainWindow::LoadDefinitions() { // Used for reparse - reloads all definitions mDefinitionResults.clear(); mTypeRegistry = TypeRegistry(); const QString definitionsDir = QCoreApplication::applicationDirPath() + "/definitions/"; QDirIterator it(definitionsDir, {"*.xscript"}, QDir::Files, QDirIterator::Subdirectories); while (it.hasNext()) { const QString path = it.next(); const QString fileName = QFileInfo(path).fileName(); QFile f(path); if (!f.open(QIODevice::ReadOnly)) { LogManager::instance().addError(QString("[DEF] Failed to open definition file: %1").arg(fileName)); mDefinitionResults.append({path, fileName, false, "Failed to open file"}); continue; } try { mTypeRegistry.ingestScript(QString::fromUtf8(f.readAll()), path); LogManager::instance().addEntry(QString("[DEF] Loaded definition: %1").arg(fileName)); mDefinitionResults.append({path, fileName, true, QString()}); } catch (const std::exception& e) { LogManager::instance().addError(QString("[DEF] ERROR loading %1: %2").arg(fileName).arg(e.what())); mDefinitionResults.append({path, fileName, false, QString::fromUtf8(e.what())}); } } // Validate all type references after loading QStringList refErrors = mTypeRegistry.validateTypeReferences(); for (const QString& err : refErrors) { LogManager::instance().addError(QString("[DEF] %1").arg(err)); } if (!refErrors.isEmpty()) { LogManager::instance().addError(QString("[DEF] Found %1 invalid type reference(s)").arg(refErrors.size())); } } void MainWindow::LoadTreeCategories() { const Module& mod = mTypeRegistry.module(); for (const QString& typeName : mod.types.keys()) { const TypeDef& td = mod.types[typeName]; if (!td.isRoot) continue; auto* cat = new XTreeWidgetItem(mTreeWidget); cat->setText(0, typeName); cat->setData(0, Qt::UserRole, typeName); mTreeWidget->addTopLevelItem(cat); } } void MainWindow::Reset() { // Clear the tree widget mTreeWidget->clear(); // Clear category roots in tree builder mTreeBuilder.reset(); // Clear tabs ui->tabWidget->clear(); } void MainWindow::HandleLogEntry(const QString &entry) { QString logContents = mLogWidget->toPlainText() + "\n" + entry; if (mLogWidget->toPlainText().isEmpty()) { logContents = entry; } mLogWidget->setPlainText(logContents); mLogWidget->moveCursor(QTextCursor::End); } void MainWindow::HandleStatusUpdate(const QString &message, int timeout) { statusBar()->showMessage(message, timeout); mProgressBar->setVisible(false); // Hide progress bar if just a message } void MainWindow::HandleProgressUpdate(const QString &message, int progress, int max) { mProgressBar->setMaximum(max); mProgressBar->setValue(progress); mProgressBar->setVisible(true); QString progressText = QString("%1 (%2/%3)").arg(message).arg(progress).arg(max); statusBar()->showMessage(progressText); } void MainWindow::dragEnterEvent(QDragEnterEvent *event) { const QMimeData *mimeData = event->mimeData(); if (mimeData->hasUrls()) { event->acceptProposedAction(); } } void MainWindow::dragMoveEvent(QDragMoveEvent *event) { Q_UNUSED(event); } void MainWindow::dragLeaveEvent(QDragLeaveEvent *event) { Q_UNUSED(event); } void MainWindow::dropEvent(QDropEvent *event) { const QMimeData *mimeData = event->mimeData(); if (!mimeData->hasUrls()) { ui->statusBar->showMessage("Can't display dropped data!"); return; } for (const QUrl& url : mimeData->urls()) { const QString path = url.toLocalFile(); const QString fileName = QFileInfo(path).fileName(); QFile inputFile(path); if (!inputFile.open(QIODevice::ReadOnly)) continue; LogManager::instance().addEntry(QString("[FILE] Analyzing: %1").arg(fileName)); // Type matching via XScript definitions const QString rootType = mTypeRegistry.chooseType(&inputFile, path); LogManager::instance().addEntry(QString("[FILE] Matched type '%1' for file: %2").arg(rootType).arg(fileName)); if (rootType.isEmpty()) { LogManager::instance().addError(QString("No matching definition for: %1").arg(path)); continue; } inputFile.seek(0); LogManager::instance().addLine(); LogManager::instance().addEntry(QString("========== PARSING %1 ==========").arg(fileName)); LogManager::instance().addLine(); const qint64 fileSize = inputFile.size(); QProgressDialog progress(QString("Parsing %1...").arg(fileName), "Cancel", 0, static_cast(fileSize), this); progress.setWindowTitle("XPlor - Parsing"); progress.setWindowModality(Qt::WindowModal); progress.setMinimumDuration(500); QVariantMap rootVars; bool cancelled = false; try { rootVars = mTypeRegistry.parse(rootType, &inputFile, path, [this, &progress, &cancelled](qint64 pos, qint64 size) { if (pos > progress.value()) { progress.setValue(static_cast(pos)); } QApplication::processEvents(); if (progress.wasCanceled()) { cancelled = true; throw std::runtime_error("Parsing cancelled by user"); } }, [this, &progress, &cancelled](const QString& status) { progress.setLabelText(status); QApplication::processEvents(); if (progress.wasCanceled()) { cancelled = true; throw std::runtime_error("Parsing cancelled by user"); } }); } catch (const std::exception& e) { if (cancelled) { LogManager::instance().addEntry(QString("[FILE] Parsing cancelled: %1").arg(fileName)); continue; } LogManager::instance().addLine(); LogManager::instance().addError(QString("========== PARSE ERROR in %1 ==========").arg(fileName)); LogManager::instance().addError(QString("Error: %1").arg(e.what())); LogManager::instance().addError(QString("File position: 0x%1").arg(inputFile.pos(), 0, 16)); LogManager::instance().addLine(); QMessageBox::critical(this, "Parse Error", QString("Failed to parse %1:\n\n%2").arg(fileName).arg(e.what())); continue; } LogManager::instance().addLine(); LogManager::instance().addEntry(QString("========== FINISHED %1 ==========").arg(fileName)); LogManager::instance().addLine(); // Track this file for reparsing if (!mOpenedFilePaths.contains(path)) { mOpenedFilePaths.append(path); } // Update progress for tree building phase progress.setLabelText(QString("Building tree for %1...").arg(fileName)); progress.setRange(0, 0); // Indeterminate mode QApplication::processEvents(); // Ensure top-level category exists (it should, but safe) XTreeWidgetItem* cat = mTreeBuilder.ensureTypeCategoryRoot(rootType, DslKeys::getString(rootVars, DslKey::Display)); // Add instance under category (FastFiles -> test.ff) const QString itemDisplayName = DslKeys::contains(rootVars, DslKey::Name) ? DslKeys::getString(rootVars, DslKey::Name) : fileName; auto* rootInst = mTreeBuilder.addInstanceNode(cat, itemDisplayName, rootType, rootVars); // Route nested objects as subcategories under the instance (test.ff -> ZoneFiles -> ...) mTreeBuilder.routeNestedObjects(rootInst, rootVars); progress.setLabelText(QString("Organizing %1...").arg(fileName)); QApplication::processEvents(); mTreeBuilder.organizeChildrenByExtension(rootInst); mTreeBuilder.updateNodeCounts(cat); cat->setExpanded(false); mTreeWidget->setCurrentItem(rootInst); } } void MainWindow::applyTheme(const Theme &theme) { // Update ribbon color mRibbon->setStyleSheet(QString("background-color: %1;").arg(theme.accentColor)); // Apply global stylesheet with theme colors setStyleSheet(QString(R"( QMainWindow, QWidget { background-color: %1; } QMenuBar { background-color: %2; color: %3; border: none; } QMenuBar::item { background: transparent; padding: 4px 10px; } QMenuBar::item:selected { background-color: %4; color: white; } QMenu { background-color: %2; color: %3; border: 1px solid %4; } QMenu::item:selected { background-color: %5; color: white; } QToolBar { background-color: %2; border: none; border-bottom: 1px solid %4; spacing: 3px; padding: 2px; } QToolButton { background-color: transparent; border: none; padding: 4px; border-radius: 3px; } QToolButton:hover { background-color: %4; } QToolButton:pressed { background-color: %6; } QStatusBar { background-color: %2; color: %3; } QTabWidget::pane { border: 1px solid %4; background-color: %1; } QTabBar::tab { background-color: %2; color: %3; padding: 6px 12px; border: 1px solid %4; border-bottom: none; margin-right: 2px; } QTabBar::tab:selected { background-color: %1; border-top: 2px solid %5; } QTabBar::tab:hover:!selected { background-color: %4; } QTreeWidget, QTreeView { background-color: %1; color: %3; border: 1px solid %4; selection-background-color: %5; } QTreeWidget::item:selected, QTreeView::item:selected { background-color: %5; color: white; } QTreeWidget::item:hover, QTreeView::item:hover { background-color: %2; } QScrollBar:vertical { background-color: %4; width: 8px; border-radius: 4px; margin: 2px; } QScrollBar::handle:vertical { background-color: %5; min-height: 20px; border-radius: 4px; } QScrollBar::handle:vertical:hover { background-color: %7; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical, QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; height: 0; border: none; } QScrollBar:horizontal { background-color: %4; height: 8px; border-radius: 4px; margin: 2px; } QScrollBar::handle:horizontal { background-color: %5; min-width: 20px; border-radius: 4px; } QScrollBar::handle:horizontal:hover { background-color: %7; } QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal, QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: none; width: 0; border: none; } QProgressBar { background-color: %4; border: none; border-radius: 3px; text-align: center; color: white; } QProgressBar::chunk { background-color: %5; border-radius: 3px; } QDockWidget { titlebar-close-icon: url(close.png); titlebar-normal-icon: url(undock.png); } QDockWidget::title { background-color: %2; padding: 4px; border: none; } QPlainTextEdit, QTextEdit { background-color: %1; color: %3; border: 1px solid %4; selection-background-color: %5; } QSplitter::handle { background-color: %4; } QSplitter::handle:hover { background-color: %5; } QHeaderView::section { background-color: %2; color: %3; padding: 4px; border: 1px solid %4; } QLabel { background-color: transparent; color: %3; } QPushButton { background-color: %2; color: %3; border: 1px solid %4; padding: 4px 12px; border-radius: 3px; } QPushButton:hover { background-color: %4; } QPushButton:pressed { background-color: %6; } QPushButton:disabled { background-color: %4; color: %8; } QSlider::groove:horizontal { background-color: %4; height: 6px; border-radius: 3px; } QSlider::handle:horizontal { background-color: %5; width: 12px; margin: -3px 0; border-radius: 6px; } QSlider::handle:horizontal:hover { background-color: %7; } QSlider::sub-page:horizontal { background-color: %5; border-radius: 3px; } QFrame { background-color: transparent; } )") .arg(theme.backgroundColor) // %1 .arg(theme.panelColor) // %2 .arg(theme.textColor) // %3 .arg(theme.borderColor) // %4 .arg(theme.accentColor) // %5 .arg(theme.accentColorDark) // %6 .arg(theme.accentColorLight) // %7 .arg(theme.textColorMuted) // %8 ); // Update app icon with new accent color QIcon themedIcon = generateThemedIcon(QColor(theme.accentColor)); if (!themedIcon.isNull()) { qApp->setWindowIcon(themedIcon); } } bool MainWindow::openFile(const QString& path) { const QString fileName = QFileInfo(path).fileName(); QFile inputFile(path); if (!inputFile.open(QIODevice::ReadOnly)) { LogManager::instance().addError(QString("Failed to open: %1").arg(path)); return false; } LogManager::instance().addEntry(QString("[FILE] Analyzing: %1").arg(fileName)); const QString rootType = mTypeRegistry.chooseType(&inputFile, path); LogManager::instance().addEntry(QString("[FILE] Matched type '%1' for file: %2").arg(rootType).arg(fileName)); if (rootType.isEmpty()) { LogManager::instance().addError(QString("No matching definition for: %1").arg(path)); return false; } inputFile.seek(0); LogManager::instance().addLine(); LogManager::instance().addEntry(QString("========== PARSING %1 ==========").arg(fileName)); LogManager::instance().addLine(); const qint64 fileSize = inputFile.size(); QProgressDialog progress(QString("Parsing %1...").arg(fileName), "Cancel", 0, static_cast(fileSize), this); progress.setWindowTitle("XPlor - Parsing"); progress.setWindowModality(Qt::WindowModal); progress.setMinimumDuration(500); QVariantMap rootVars; bool cancelled = false; try { rootVars = mTypeRegistry.parse(rootType, &inputFile, path, [this, &progress, &cancelled](qint64 pos, qint64 size) { if (pos > progress.value()) { progress.setValue(static_cast(pos)); } QApplication::processEvents(); if (progress.wasCanceled()) { cancelled = true; throw std::runtime_error("Parsing cancelled by user"); } }, [this, &progress, &cancelled](const QString& status) { progress.setLabelText(status); QApplication::processEvents(); if (progress.wasCanceled()) { cancelled = true; throw std::runtime_error("Parsing cancelled by user"); } }); } catch (const std::exception& e) { if (cancelled) { LogManager::instance().addEntry(QString("[FILE] Parsing cancelled: %1").arg(fileName)); return false; } LogManager::instance().addLine(); LogManager::instance().addError(QString("========== PARSE ERROR in %1 ==========").arg(fileName)); LogManager::instance().addError(QString("Error: %1").arg(e.what())); LogManager::instance().addError(QString("File position: 0x%1").arg(inputFile.pos(), 0, 16)); LogManager::instance().addLine(); QMessageBox::critical(this, "Parse Error", QString("Failed to parse %1:\n\n%2").arg(fileName).arg(e.what())); return false; } LogManager::instance().addLine(); LogManager::instance().addEntry(QString("========== FINISHED %1 ==========").arg(fileName)); LogManager::instance().addLine(); if (!mOpenedFilePaths.contains(path)) { mOpenedFilePaths.append(path); } // Update progress for tree building phase progress.setLabelText(QString("Building tree for %1...").arg(fileName)); progress.setRange(0, 0); // Indeterminate mode QApplication::processEvents(); XTreeWidgetItem* cat = mTreeBuilder.ensureTypeCategoryRoot(rootType, DslKeys::getString(rootVars, DslKey::Display)); const QString itemDisplayName = DslKeys::contains(rootVars, DslKey::Name) ? DslKeys::getString(rootVars, DslKey::Name) : fileName; auto* rootInst = mTreeBuilder.addInstanceNode(cat, itemDisplayName, rootType, rootVars); mTreeBuilder.routeNestedObjects(rootInst, rootVars); progress.setLabelText(QString("Organizing %1...").arg(fileName)); QApplication::processEvents(); mTreeBuilder.organizeChildrenByExtension(rootInst); mTreeBuilder.updateNodeCounts(cat); cat->setExpanded(false); mTreeWidget->setCurrentItem(rootInst); statusBar()->showMessage(QString("Opened: %1").arg(fileName), 3000); return true; } bool MainWindow::saveTab(QWidget* tab) { if (!tab) return false; // Get the form editor from the container QWidget* formEditorWidget = tab->property("formEditor").value(); auto* formEditor = qobject_cast(formEditorWidget); if (!formEditor) { QMessageBox::warning(this, "Save Error", "Cannot save: not a form editor tab."); return false; } // Check if we have a file path QString filePath = DirtyStateManager::instance().filePath(tab); if (filePath.isEmpty()) { // No path - need to do Save As return saveTabAs(tab); } // Check if exportable int journalId = formEditor->journalId(); if (journalId < 0) { QMessageBox::warning(this, "Save Error", "Cannot save: no journal data available.\n\n" "This file was not parsed with write-back tracking enabled."); return false; } Recompiler recompiler; if (!recompiler.canRecompile(journalId)) { QMessageBox::warning(this, "Save Error", QString("Cannot save: %1\n\n" "Files that use random access reads or unsupported compression cannot be saved.") .arg(recompiler.whyNotRecompilable(journalId))); return false; } // Recompile QStringList errors; QByteArray recompiled = recompiler.recompileWithErrors(journalId, formEditor->values(), errors); if (recompiled.isEmpty() && !errors.isEmpty()) { QMessageBox::critical(this, "Save Error", QString("Failed to recompile:\n\n%1").arg(errors.join("\n"))); return false; } // Write to file QFile file(filePath); if (!file.open(QIODevice::WriteOnly)) { QMessageBox::critical(this, "Save Error", QString("Cannot write to file:\n%1\n\n%2").arg(filePath).arg(file.errorString())); return false; } qint64 written = file.write(recompiled); file.close(); if (written != recompiled.size()) { QMessageBox::critical(this, "Save Error", QString("Failed to write all data to file.\nExpected %1 bytes, wrote %2 bytes.") .arg(recompiled.size()).arg(written)); return false; } // Mark as clean formEditor->setModified(false); DirtyStateManager::instance().markClean(tab); if (!errors.isEmpty()) { StatusBarManager::instance().updateStatus(QString("Saved with warnings: %1").arg(errors.join("; ")), 5000); } else { statusBar()->showMessage(QString("Saved: %1").arg(filePath), 3000); } return true; } bool MainWindow::saveTabAs(QWidget* tab) { if (!tab) return false; // Get the form editor from the container QWidget* formEditorWidget = tab->property("formEditor").value(); auto* formEditor = qobject_cast(formEditorWidget); if (!formEditor) { QMessageBox::warning(this, "Save Error", "Cannot save: not a form editor tab."); return false; } // Check if exportable int journalId = formEditor->journalId(); if (journalId >= 0) { Recompiler recompiler; if (!recompiler.canRecompile(journalId)) { QMessageBox::warning(this, "Save Error", QString("Cannot save: %1\n\n" "Files that use random access reads or unsupported compression cannot be saved.") .arg(recompiler.whyNotRecompilable(journalId))); return false; } } // Get current file path or use parent name as default QString currentPath = DirtyStateManager::instance().filePath(tab); if (currentPath.isEmpty()) { currentPath = tab->property("PARENT_NAME").toString(); } // Show save dialog QString filePath = QFileDialog::getSaveFileName(this, "Save As", currentPath, "All Files (*)"); if (filePath.isEmpty()) { return false; // User cancelled } // Store the path and do regular save DirtyStateManager::instance().setFilePath(tab, filePath); return saveTab(tab); } void MainWindow::pushFieldEdit(int journalId, const QString& fieldName, const QVariant& oldValue, const QVariant& newValue) { // Create callback to apply value changes auto applyCallback = [this, journalId](const QString& field, const QVariant& value) { // Find the tab widget that owns this journal for (int i = 0; i < ui->tabWidget->count(); ++i) { QWidget* tab = ui->tabWidget->widget(i); ScriptTypeEditorWidget* editor = tab->findChild(); if (editor && editor->journalId() == journalId) { // Apply the value without triggering a new undo command editor->blockSignals(true); editor->setFieldValue(field, value); editor->blockSignals(false); break; } } }; // Create and push the command auto* cmd = new FieldEditCommand(journalId, fieldName, oldValue, newValue, applyCallback); mUndoStack->push(cmd); }