Enhance app UI with export integration and tab management

Major UI improvements across the application:

MainWindow:
- Integrate undo stack for field editing
- Dirty state tracking with tab title indicators (*)
- Save/SaveAs prompts on tab close with unsaved changes
- Export menu integration in tab context menu
- Tab management: close all, close left/right of current tab
- Connect export system to tree widget signals

XTreeWidget:
- Add context menus for tree items
- Quick export action for immediate saves
- Export dialog action for format options
- Raw data export for any item
- Batch export for containers

XTreeWidgetItem:
- Add modified state tracking with visual indicator
- Support for marking items as dirty

ImagePreviewWidget:
- Enhanced image display and navigation
- Improved zoom and pan controls

TreeBuilder:
- Better handling of nested data structures
- Improved child node generation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
njohnson 2026-01-12 20:55:41 -05:00
parent 37bde14174
commit 57c7ee7de5
10 changed files with 1516 additions and 69 deletions

View File

@ -261,10 +261,23 @@ bool ImagePreviewWidget::loadFromData(const QByteArray &data, const QString &for
}
// Check for DDS format (starts with "DDS ")
if (data.size() >= 4 && data.left(4) == "DDS ") {
if (data.size() >= 128 && data.left(4) == "DDS ") {
QImage image = loadDDS(data);
if (!image.isNull()) {
mImageSize = image.size();
mOriginalPixmap = QPixmap::fromImage(image);
mZoomFactor = 1.0;
mImageLabel->setPixmap(mOriginalPixmap);
mImageLabel->adjustSize();
mInfoLabel->setText(QString("%1 - %2x%3 (%4)")
.arg(mFilename).arg(image.width()).arg(image.height()).arg(mDetectedFormat));
updateMetadataDisplay();
return true;
}
// Fall through if DDS loading failed
mDetectedFormat = "DDS";
mCompression = "DXT";
mImageLabel->setText(QString("DDS Texture Format\n\nSize: %1 bytes\n\nDDS preview not yet implemented.")
mCompression = "Unknown";
mImageLabel->setText(QString("DDS Texture Format\n\nSize: %1 bytes\n\nUnsupported DDS format.")
.arg(data.size()));
mInfoLabel->setText(QString("%1 - DDS Texture (%2 bytes)").arg(mFilename).arg(data.size()));
updateMetadataDisplay();
@ -588,6 +601,154 @@ static void decodeDXT5BlockStatic(const uchar *src, QRgb *dest, int destPitch)
}
}
// DXT1 block decoder - read colors as little-endian (PC/DDS native)
static void decodeDXT1BlockStaticLE(const uchar *src, QRgb *dest, int destPitch)
{
// Read colors as little-endian (PC native format)
quint16 c0 = src[0] | (src[1] << 8);
quint16 c1 = src[2] | (src[3] << 8);
// Extract RGB565 components: bits 15-11=R, 10-5=G, 4-0=B
int r0 = ((c0 >> 11) & 0x1F) * 255 / 31;
int g0 = ((c0 >> 5) & 0x3F) * 255 / 63;
int b0 = (c0 & 0x1F) * 255 / 31;
int r1 = ((c1 >> 11) & 0x1F) * 255 / 31;
int g1 = ((c1 >> 5) & 0x3F) * 255 / 63;
int b1 = (c1 & 0x1F) * 255 / 31;
// Build color table
QRgb colors[4];
colors[0] = qRgb(r0, g0, b0);
colors[1] = qRgb(r1, g1, b1);
if (c0 > c1) {
colors[2] = qRgb((2 * r0 + r1) / 3, (2 * g0 + g1) / 3, (2 * b0 + b1) / 3);
colors[3] = qRgb((r0 + 2 * r1) / 3, (g0 + 2 * g1) / 3, (b0 + 2 * b1) / 3);
} else {
colors[2] = qRgb((r0 + r1) / 2, (g0 + g1) / 2, (b0 + b1) / 2);
colors[3] = qRgba(0, 0, 0, 0); // Transparent
}
// Read indices - standard order
for (int y = 0; y < 4; y++) {
uchar rowByte = src[4 + y];
for (int x = 0; x < 4; x++) {
int idx = (rowByte >> (x * 2)) & 0x03;
dest[y * destPitch + x] = colors[idx];
}
}
}
// DXT5 block decoder - read as little-endian (PC/DDS native)
static void decodeDXT5BlockStaticLE(const uchar *src, QRgb *dest, int destPitch)
{
// Alpha block (first 8 bytes) - standard order
uchar alpha0 = src[0];
uchar alpha1 = src[1];
// Build alpha table
uchar alphas[8];
alphas[0] = alpha0;
alphas[1] = alpha1;
if (alpha0 > alpha1) {
alphas[2] = (6 * alpha0 + 1 * alpha1) / 7;
alphas[3] = (5 * alpha0 + 2 * alpha1) / 7;
alphas[4] = (4 * alpha0 + 3 * alpha1) / 7;
alphas[5] = (3 * alpha0 + 4 * alpha1) / 7;
alphas[6] = (2 * alpha0 + 5 * alpha1) / 7;
alphas[7] = (1 * alpha0 + 6 * alpha1) / 7;
} else {
alphas[2] = (4 * alpha0 + 1 * alpha1) / 5;
alphas[3] = (3 * alpha0 + 2 * alpha1) / 5;
alphas[4] = (2 * alpha0 + 3 * alpha1) / 5;
alphas[5] = (1 * alpha0 + 4 * alpha1) / 5;
alphas[6] = 0;
alphas[7] = 255;
}
// Alpha indices (6 bytes, 48 bits = 16 pixels * 3 bits each)
quint64 alphaIndices = src[2] | ((quint64)src[3] << 8) | ((quint64)src[4] << 16) |
((quint64)src[5] << 24) | ((quint64)src[6] << 32) | ((quint64)src[7] << 40);
// Color block (bytes 8-15, same as DXT1) - little-endian
const uchar *colorSrc = src + 8;
quint16 c0 = colorSrc[0] | (colorSrc[1] << 8); // Little-endian (PC)
quint16 c1 = colorSrc[2] | (colorSrc[3] << 8); // Little-endian (PC)
// RGB565: bits 15-11=R, 10-5=G, 4-0=B
int r0 = ((c0 >> 11) & 0x1F) * 255 / 31;
int g0 = ((c0 >> 5) & 0x3F) * 255 / 63;
int b0 = (c0 & 0x1F) * 255 / 31;
int r1 = ((c1 >> 11) & 0x1F) * 255 / 31;
int g1 = ((c1 >> 5) & 0x3F) * 255 / 63;
int b1 = (c1 & 0x1F) * 255 / 31;
QRgb colors[4];
colors[0] = qRgb(r0, g0, b0);
colors[1] = qRgb(r1, g1, b1);
colors[2] = qRgb((2 * r0 + r1) / 3, (2 * g0 + g1) / 3, (2 * b0 + b1) / 3);
colors[3] = qRgb((r0 + 2 * r1) / 3, (g0 + 2 * g1) / 3, (b0 + 2 * b1) / 3);
// Decode 4x4 block
for (int y = 0; y < 4; y++) {
uchar colorRowByte = colorSrc[4 + y];
for (int x = 0; x < 4; x++) {
int pixelIdx = y * 4 + x;
int alphaIdx = (alphaIndices >> (pixelIdx * 3)) & 0x07;
int colorIdx = (colorRowByte >> (x * 2)) & 0x03;
QRgb color = colors[colorIdx];
dest[y * destPitch + x] = qRgba(qRed(color), qGreen(color), qBlue(color), alphas[alphaIdx]);
}
}
}
// Helper to decode DXT data into an image (little-endian/PC format)
static QImage decodeDXTToImageLE(const uchar *srcData, int width, int height, int blockSize, bool isDXT5, int textureDataSize)
{
int blocksWide = (width + 3) / 4;
int blocksHigh = (height + 3) / 4;
QImage image(width, height, QImage::Format_ARGB32);
if (image.isNull()) return QImage();
image.fill(Qt::black);
for (int by = 0; by < blocksHigh; by++) {
for (int bx = 0; bx < blocksWide; bx++) {
int blockIdx = by * blocksWide + bx;
int blockOffset = blockIdx * blockSize;
if (blockOffset + blockSize > textureDataSize) break;
const uchar *blockData = srcData + blockOffset;
// Decode into temporary 4x4 block
QRgb block[16];
if (isDXT5) {
decodeDXT5BlockStaticLE(blockData, block, 4);
} else {
decodeDXT1BlockStaticLE(blockData, block, 4);
}
// Copy block to image
int px = bx * 4;
int py = by * 4;
for (int y = 0; y < 4 && py + y < height; y++) {
QRgb *destLine = reinterpret_cast<QRgb*>(image.scanLine(py + y));
for (int x = 0; x < 4 && px + x < width; x++) {
destLine[px + x] = block[y * 4 + x];
}
}
}
}
return image;
}
// Swap endianness in 16-bit chunks (required for Xbox 360)
static void swapEndian16(uchar *data, int size)
{
@ -944,3 +1105,135 @@ QImage ImagePreviewWidget::loadRawDXT(const QByteArray &data, bool tryDXT5First)
return untiledResult.isNull() ? linearResult : untiledResult;
}
QImage ImagePreviewWidget::loadDDS(const QByteArray &data)
{
// DDS header structure:
// 0x00: "DDS " magic (4 bytes)
// 0x04: DDS_HEADER (124 bytes)
// 0x04: dwSize = 124
// 0x08: dwFlags
// 0x0C: dwHeight
// 0x10: dwWidth
// 0x14: dwPitchOrLinearSize
// 0x18: dwDepth
// 0x1C: dwMipMapCount
// 0x20-0x4B: dwReserved1[11] (44 bytes)
// 0x4C: DDS_PIXELFORMAT (32 bytes)
// 0x4C: dwSize = 32
// 0x50: dwFlags (0x4 = DDPF_FOURCC)
// 0x54: dwFourCC ("DXT1", "DXT3", "DXT5")
// 0x58-0x6B: RGB masks and bit count
// 0x6C-0x7B: Caps and reserved
// 0x80: Texture data starts
if (data.size() < 128) {
return QImage();
}
const uchar *d = reinterpret_cast<const uchar*>(data.constData());
// Verify magic
if (data.left(4) != "DDS ") {
return QImage();
}
// Read header fields (little-endian)
auto readU32 = [d](int offset) -> quint32 {
return d[offset] | (d[offset+1] << 8) | (d[offset+2] << 16) | (d[offset+3] << 24);
};
quint32 headerSize = readU32(0x04);
if (headerSize != 124) {
return QImage(); // Invalid header size
}
quint32 height = readU32(0x0C);
quint32 width = readU32(0x10);
// Read pixel format
quint32 pfFlags = readU32(0x50);
quint32 fourCC = readU32(0x54);
// Determine format from FourCC
int blockSize = 0;
bool isDXT5 = false;
QString formatName;
// FourCC codes (little-endian in file)
const quint32 DXT1 = 0x31545844; // "DXT1"
const quint32 DXT3 = 0x33545844; // "DXT3"
const quint32 DXT5 = 0x35545844; // "DXT5"
if ((pfFlags & 0x4) == 0) {
// Not FourCC format - check for uncompressed RGB
quint32 rgbBitCount = readU32(0x58);
if (rgbBitCount == 32) {
// A8R8G8B8 or similar uncompressed format
mDetectedFormat = "DDS (ARGB32)";
mCompression = "None";
mBitsPerPixel = 32;
int dataOffset = 128;
int dataSize = width * height * 4;
if (dataOffset + dataSize > data.size()) {
return QImage();
}
QImage image(width, height, QImage::Format_ARGB32);
const uchar *src = d + dataOffset;
for (quint32 y = 0; y < height; y++) {
QRgb *dest = reinterpret_cast<QRgb*>(image.scanLine(y));
for (quint32 x = 0; x < width; x++) {
dest[x] = qRgba(src[2], src[1], src[0], src[3]);
src += 4;
}
}
return image;
}
return QImage(); // Unsupported uncompressed format
}
if (fourCC == DXT1) {
blockSize = 8;
isDXT5 = false;
formatName = "DXT1";
} else if (fourCC == DXT3) {
blockSize = 16;
isDXT5 = true; // DXT3 uses same 16-byte block structure
formatName = "DXT3";
} else if (fourCC == DXT5) {
blockSize = 16;
isDXT5 = true;
formatName = "DXT5";
} else {
// Unsupported format
char fourCCStr[5] = {0};
memcpy(fourCCStr, &fourCC, 4);
mDetectedFormat = QString("DDS (%1)").arg(fourCCStr);
mCompression = "Unknown";
return QImage();
}
mDetectedFormat = QString("DDS (%1)").arg(formatName);
mCompression = formatName;
mBitsPerPixel = (blockSize == 8) ? 4 : 8;
// Calculate texture data size
int blocksWide = (width + 3) / 4;
int blocksHigh = (height + 3) / 4;
int textureDataSize = blocksWide * blocksHigh * blockSize;
int dataOffset = 128; // Header is 128 bytes
if (dataOffset + textureDataSize > data.size()) {
return QImage();
}
const uchar *srcData = d + dataOffset;
// DDS files are PC format (linear, little-endian)
// Use LE decoder - no Xbox 360 untiling needed
QImage image = decodeDXTToImageLE(srcData, width, height, blockSize, isDXT5, textureDataSize);
return image;
}

View File

@ -72,6 +72,9 @@ private:
// Load raw DXT1/DXT5 data with auto-detected dimensions
QImage loadRawDXT(const QByteArray &data, bool tryDXT5First = false);
// Load DDS (DirectDraw Surface) texture
QImage loadDDS(const QByteArray &data);
// Detected image info
QString mDetectedFormat;
int mBitsPerPixel;

View File

@ -23,6 +23,7 @@
#include <QJsonArray>
#include <QTextStream>
#include <QThread>
#include <QTimer>
#include <QImage>
#include <QIcon>
#include <iostream>
@ -342,7 +343,8 @@ int main(int argc, char *argv[])
return 1;
}
int result = runCli(args.first(), parser.value(gameOption), parser.value(platformOption), parser.isSet(jsonOption));
int result = runCli(args.first(), parser.value(gameOption), parser.value(platformOption),
parser.isSet(jsonOption));
#ifdef Q_OS_WIN
// If we allocated our own console window, wait for user input before closing
@ -451,5 +453,20 @@ int main(int argc, char *argv[])
// finish() will show the main window and keep splash on top if waitForInteraction is enabled
splash.finish(&w);
// Check for file argument to open (GUI mode)
QStringList args = QCoreApplication::arguments();
for (int i = 1; i < args.size(); ++i) {
const QString& arg = args[i];
// Skip options (start with -)
if (arg.startsWith("-")) continue;
// Try to open as file
if (QFileInfo::exists(arg)) {
QTimer::singleShot(100, &w, [&w, arg]() {
w.openFile(arg);
});
break; // Only open first file
}
}
return a.exec();
}

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@ class XTreeWidget;
class XTreeWidgetItem;
class QPlainTextEdit;
class QProgressBar;
class QUndoStack;
QT_BEGIN_NAMESPACE
namespace Ui {
@ -38,17 +39,28 @@ public:
void setTypeRegistry(TypeRegistry&& registry, const QVector<DefinitionLoadResult>& results);
void LoadTreeCategories();
void Reset();
bool openFile(const QString& filePath); // Open and parse a file
const QVector<DefinitionLoadResult>& definitionResults() const { return mDefinitionResults; }
const TypeRegistry& typeRegistry() const { return mTypeRegistry; }
TreeBuilder& treeBuilder() { return mTreeBuilder; }
// Undo/Redo support
QUndoStack* undoStack() const { return mUndoStack; }
void pushFieldEdit(int journalId, const QString& fieldName,
const QVariant& oldValue, const QVariant& newValue);
private slots:
void HandleLogEntry(const QString &entry);
void HandleStatusUpdate(const QString &message, int timeout);
void HandleProgressUpdate(const QString &message, int progress, int max);
void applyTheme(const Theme &theme);
public:
// Save functionality for write-back
bool saveTab(QWidget* tab);
bool saveTabAs(QWidget* tab);
protected:
void dragEnterEvent(QDragEnterEvent *event) override;
void dragMoveEvent(QDragMoveEvent *event) override;
@ -67,6 +79,9 @@ private:
QStringList mOpenedFilePaths; // Track opened files for reparsing
QVector<DefinitionLoadResult> mDefinitionResults;
// Undo/Redo support
QUndoStack *mUndoStack;
// Actions - File menu
QAction *actionNew;
QAction *actionOpen;

View File

@ -33,6 +33,9 @@ QString TreeBuilder::pluralizeType(const QString& typeName) const
QString groupLabel = (it != mod.types.end() && !it->display.isEmpty())
? it->display
: typeName;
// Don't double the 's' if already ends with 's'
if (groupLabel.endsWith('s') || groupLabel.endsWith('S'))
return groupLabel;
return groupLabel + "s";
}
@ -44,9 +47,14 @@ XTreeWidgetItem* TreeBuilder::ensureTypeCategoryRoot(const QString& typeName, co
return m_categoryRoots[categoryKey];
auto* root = new XTreeWidgetItem(m_tree);
const QString categoryLabel = displayOverride.isEmpty()
? pluralizeType(typeName)
: displayOverride + "s";
QString categoryLabel;
if (displayOverride.isEmpty()) {
categoryLabel = pluralizeType(typeName);
} else if (displayOverride.endsWith('s') || displayOverride.endsWith('S')) {
categoryLabel = displayOverride; // Don't double the 's'
} else {
categoryLabel = displayOverride + "s";
}
root->setText(0, categoryLabel);
root->setData(0, Qt::UserRole + 1, "CATEGORY");
root->setData(0, Qt::UserRole + 2, typeName);
@ -98,15 +106,23 @@ QString TreeBuilder::instanceDisplayFor(const QVariantMap& obj, const QString& f
const QString s = DslKeys::getString(obj, DslKey::Name);
if (!s.isEmpty()) return s;
}
// _display is secondary (set via set_display())
if (DslKeys::contains(obj, DslKey::Display)) {
const QString s = DslKeys::getString(obj, DslKey::Display);
if (!s.isEmpty()) return s;
}
// Index-based fallback for array items - ensures unique names
// This comes BEFORE type fallback to avoid all items showing same type name
if (!fallbackKey.isEmpty() && index.has_value()) {
return QString("%1[%2]").arg(fallbackKey).arg(*index);
}
// Type fallback (for non-array items)
if (!fallbackType.isEmpty()) return fallbackType;
if (!fallbackKey.isEmpty()) {
if (index.has_value()) return QString("%1[%2]").arg(fallbackKey).arg(*index);
return fallbackKey;
}
return QStringLiteral("<unnamed>");

View File

@ -1,8 +1,10 @@
#include "xtreewidget.h"
#include "exportmanager.h"
#include "qheaderview.h"
#include "qmenu.h"
#include "logmanager.h"
#include "xtreewidgetitem.h"
#include "../libs/dsl/dslkeys.h"
#include <QFileDialog>
@ -23,21 +25,183 @@ XTreeWidget::XTreeWidget(QWidget *parent)
connect(this, &XTreeWidget::customContextMenuRequested, this, &XTreeWidget::PrepareContextMenu);
}
int XTreeWidget::countExportableChildren(QTreeWidgetItem* parent) const {
int count = 0;
std::function<void(QTreeWidgetItem*)> countRecursive = [&](QTreeWidgetItem* item) {
QString kind = item->data(0, Qt::UserRole + 1).toString();
if (kind == "INSTANCE") {
count++;
}
for (int i = 0; i < item->childCount(); ++i) {
countRecursive(item->child(i));
}
};
for (int i = 0; i < parent->childCount(); ++i) {
countRecursive(parent->child(i));
}
return count;
}
int XTreeWidget::countExportableChildrenByType(QTreeWidgetItem* parent, int contentType) const {
int count = 0;
ExportManager& exp = ExportManager::instance();
std::function<void(QTreeWidgetItem*)> countRecursive = [&](QTreeWidgetItem* item) {
QString kind = item->data(0, Qt::UserRole + 1).toString();
if (kind == "INSTANCE") {
QVariantMap vars = item->data(0, Qt::UserRole + 3).toMap();
if (exp.detectContentType(vars) == contentType) {
count++;
}
}
for (int i = 0; i < item->childCount(); ++i) {
countRecursive(item->child(i));
}
};
for (int i = 0; i < parent->childCount(); ++i) {
countRecursive(parent->child(i));
}
return count;
}
void XTreeWidget::PrepareContextMenu(const QPoint &pos) {
auto activeItem = itemAt(pos);
if (!activeItem) { return; }
if (activeItem->text(0).isEmpty()) { return; }
QString activeText = activeItem->text(0);
QString kind = activeItem->data(0, Qt::UserRole + 1).toString();
QMenu *contextMenu = new QMenu(this);
if (kind == "INSTANCE") {
prepareInstanceContextMenu(contextMenu, activeItem);
} else if (kind == "CATEGORY" || kind == "SUBCATEGORY" || kind == "EXTENSION_GROUP") {
prepareContainerContextMenu(contextMenu, activeItem);
} else {
// No context menu for other item types
delete contextMenu;
return;
}
QPoint pt(pos);
contextMenu->exec(mapToGlobal(pt));
contextMenu->exec(mapToGlobal(pos));
delete contextMenu;
}
void XTreeWidget::prepareInstanceContextMenu(QMenu* menu, QTreeWidgetItem* item) {
QVariantMap vars = item->data(0, Qt::UserRole + 3).toMap();
ExportManager& exp = ExportManager::instance();
auto contentType = exp.detectContentType(vars);
// Quick Export - uses saved defaults
QAction* quickAction = menu->addAction("Quick Export");
quickAction->setShortcut(QKeySequence("Ctrl+Shift+E"));
connect(quickAction, &QAction::triggered, this, [this, item]() {
emit quickExportRequested(item);
});
// Export with dialog - full options
QAction* dialogAction = menu->addAction("Export...");
dialogAction->setShortcut(QKeySequence("Ctrl+E"));
connect(dialogAction, &QAction::triggered, this, [this, item]() {
emit exportDialogRequested(item);
});
menu->addSeparator();
// Raw export - always available
QAction* rawAction = menu->addAction("Export Raw Data...");
connect(rawAction, &QAction::triggered, this, [this, item]() {
emit exportRequested("raw", item);
});
menu->addSeparator();
// Format-specific exports based on content type
if (contentType == ExportManager::Image) {
QMenu* imgMenu = menu->addMenu("Export Image As");
for (const QString& fmt : exp.supportedImageFormats()) {
QAction* a = imgMenu->addAction(fmt.toUpper());
connect(a, &QAction::triggered, this, [this, item, fmt]() {
emit exportRequested(fmt, item);
});
}
}
else if (contentType == ExportManager::Audio) {
QMenu* audMenu = menu->addMenu("Export Audio As");
for (const QString& fmt : exp.supportedAudioFormats()) {
QAction* a = audMenu->addAction(fmt.toUpper());
// Disable non-WAV formats if FFmpeg not available
a->setEnabled(fmt == "wav" || exp.hasFFmpeg());
if (!a->isEnabled()) {
a->setText(a->text() + " (requires FFmpeg)");
}
connect(a, &QAction::triggered, this, [this, item, fmt]() {
emit exportRequested(fmt, item);
});
}
}
else if (contentType == ExportManager::Text) {
QAction* txtAction = menu->addAction("Export as Text...");
connect(txtAction, &QAction::triggered, this, [this, item]() {
emit exportRequested("txt", item);
});
}
menu->addSeparator();
// Clipboard operations
QAction* copyAction = menu->addAction("Copy to Clipboard");
connect(copyAction, &QAction::triggered, this, [this, item]() {
emit exportRequested("clipboard", item);
});
}
void XTreeWidget::prepareContainerContextMenu(QMenu* menu, QTreeWidgetItem* item) {
int totalCount = countExportableChildren(item);
if (totalCount == 0) {
QAction* emptyAction = menu->addAction("No exportable items");
emptyAction->setEnabled(false);
return;
}
// Export all children
QAction* exportAllAction = menu->addAction(QString("Export All Children... (%1 items)").arg(totalCount));
connect(exportAllAction, &QAction::triggered, this, [this, item]() {
emit batchExportRequested(item);
});
menu->addSeparator();
// Export by type
int imageCount = countExportableChildrenByType(item, ExportManager::Image);
int audioCount = countExportableChildrenByType(item, ExportManager::Audio);
int textCount = countExportableChildrenByType(item, ExportManager::Text);
if (imageCount > 0) {
QAction* imgAction = menu->addAction(QString("Export All Images... (%1)").arg(imageCount));
connect(imgAction, &QAction::triggered, this, [this, item]() {
emit batchExportByTypeRequested(item, ExportManager::Image);
});
}
if (audioCount > 0) {
QAction* audAction = menu->addAction(QString("Export All Audio... (%1)").arg(audioCount));
connect(audAction, &QAction::triggered, this, [this, item]() {
emit batchExportByTypeRequested(item, ExportManager::Audio);
});
}
if (textCount > 0) {
QAction* txtAction = menu->addAction(QString("Export All Text... (%1)").arg(textCount));
connect(txtAction, &QAction::triggered, this, [this, item]() {
emit batchExportByTypeRequested(item, ExportManager::Text);
});
}
}
void XTreeWidget::ItemSelectionChanged() {
if (selectedItems().isEmpty()) { return; }
@ -46,5 +210,5 @@ void XTreeWidget::ItemSelectionChanged() {
if (selectedItem->text(0).isEmpty()) { return; }
QString selectedText = selectedItem->text(0);
emit ItemSelected(selectedText);
emit ItemSelected(selectedText, selectedItem);
}

View File

@ -3,6 +3,8 @@
#include <QTreeWidget>
class QTreeWidgetItem;
class XTreeWidget : public QTreeWidget
{
Q_OBJECT
@ -10,16 +12,29 @@ class XTreeWidget : public QTreeWidget
public:
explicit XTreeWidget(QWidget *parent = nullptr);
// Helper methods for batch export
int countExportableChildren(QTreeWidgetItem* parent) const;
int countExportableChildrenByType(QTreeWidgetItem* parent, int contentType) const;
signals:
void ItemSelected(const QString itemText);
void ItemSelected(const QString itemText, QTreeWidgetItem* item);
void ItemClosed(const QString itemText);
void Cleared();
// Export signals
void exportRequested(const QString& format, QTreeWidgetItem* item);
void quickExportRequested(QTreeWidgetItem* item);
void exportDialogRequested(QTreeWidgetItem* item);
void batchExportRequested(QTreeWidgetItem* parentItem);
void batchExportByTypeRequested(QTreeWidgetItem* parentItem, int contentType);
protected:
void ItemSelectionChanged();
void PrepareContextMenu(const QPoint &pos);
private:
void prepareInstanceContextMenu(QMenu* menu, QTreeWidgetItem* item);
void prepareContainerContextMenu(QMenu* menu, QTreeWidgetItem* item);
};
#endif // XTREEWIDGET_H

View File

@ -52,3 +52,32 @@ void XTreeWidgetItem::SetIsGroup(bool aIsGroup)
{
isGroup = aIsGroup;
}
void XTreeWidgetItem::setModified(bool modified)
{
if (m_modified == modified) return;
if (modified && m_originalText.isEmpty()) {
// Store original text before adding indicator
m_originalText = text(0);
}
m_modified = modified;
if (modified) {
// Add asterisk indicator
if (!text(0).endsWith(" *")) {
setText(0, m_originalText + " *");
}
} else {
// Remove asterisk indicator
if (!m_originalText.isEmpty()) {
setText(0, m_originalText);
}
}
}
bool XTreeWidgetItem::isModified() const
{
return m_modified;
}

View File

@ -20,8 +20,14 @@ public:
bool GetIsGroup() const;
void SetIsGroup(bool aIsGroup);
// Modified state for edit indicator
void setModified(bool modified);
bool isModified() const;
private:
bool isGroup;
bool m_modified = false;
QString m_originalText;
};