Add new preview widgets and enhance existing viewers

- Add ListPreviewWidget for displaying parsed list data with table view
- Add TextViewerWidget for text file preview with syntax highlighting
- Add TreeBuilder class to organize parsed data into tree structure
- Enhance HexView with selection support, copy functionality, keyboard navigation
- Enhance ImagePreviewWidget with additional format support and metadata display
- Minor audio preview widget adjustments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
njohnson 2026-01-11 12:08:59 -05:00
parent c45d5cba86
commit 57ad7c4111
11 changed files with 1320 additions and 35 deletions

View File

@ -430,10 +430,8 @@ void AudioPreviewWidget::updateWaveformPosition()
void AudioPreviewWidget::setMetadata(const QVariantMap &metadata)
{
// Add custom metadata from parsed fields
// Add custom metadata from parsed fields (caller provides only visible fields)
for (auto it = metadata.begin(); it != metadata.end(); ++it) {
if (it.key().startsWith("_")) continue; // Skip internal fields
auto *item = new QTreeWidgetItem(mMetadataTree);
item->setText(0, it.key());

View File

@ -4,6 +4,10 @@
#include <QScrollBar>
#include <QPainter>
#include <QResizeEvent>
#include <QMouseEvent>
#include <QKeyEvent>
#include <QClipboard>
#include <QApplication>
// ============================================================================
// HexView - Virtualized hex viewer with direct painting
@ -33,10 +37,13 @@ HexView::HexView(QWidget *parent)
mControlColor = QColor("#707070");
mNonPrintableColor = QColor("#909090");
mBorderColor = QColor("#333333");
mSelectionColor = QColor("#264f78"); // Blue selection highlight
setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn);
setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
viewport()->setAutoFillBackground(false);
viewport()->setMouseTracking(true);
setFocusPolicy(Qt::StrongFocus);
}
void HexView::setData(const QByteArray &data)
@ -92,14 +99,17 @@ void HexView::updateScrollBars()
}
int totalLines = (mData.size() + mBytesPerLine - 1) / mBytesPerLine;
int visibleLines = viewport()->height() / mLineHeight;
// Account for header (1 line) + gap (4px ~= 0.25 lines)
int headerHeight = mLineHeight + 4;
int availableHeight = viewport()->height() - headerHeight;
int visibleLines = qMax(1, availableHeight / mLineHeight);
verticalScrollBar()->setRange(0, qMax(0, totalLines - visibleLines));
verticalScrollBar()->setPageStep(visibleLines);
verticalScrollBar()->setSingleStep(1);
// Horizontal: offset(8) + space + hex(3*n + gap) + separator + ascii(n) + margin
int contentWidth = 10 * mCharWidth + mBytesPerLine * 3 * mCharWidth + 2 * mCharWidth + mBytesPerLine * mCharWidth + 20;
// Horizontal: offset(10) + hex(3*n) + separator(2) + ascii(n)
int contentWidth = 10 * mCharWidth + mBytesPerLine * 3 * mCharWidth + 2 * mCharWidth + mBytesPerLine * mCharWidth;
int viewportWidth = viewport()->width();
horizontalScrollBar()->setRange(0, qMax(0, contentWidth - viewportWidth));
@ -113,22 +123,19 @@ void HexView::recalculateBytesPerLine()
int viewportWidth = viewport()->width();
if (viewportWidth < 100) return;
// Calculate available space
// Format: XXXXXXXX HH HH HH HH ... | AAAA...
// Offset = 10 chars (8 + 2 spaces)
// Each byte = 3 chars (2 hex + space)
// Gap after 8 bytes = 1 char
// Separator = 3 chars
// ASCII = 1 char per byte
// Calculate available space for hex bytes
// Format: XXXXXXXX HH HH HH HH HH HH ... | AAAA...
// Offset = 10 chars (8 hex + 2 spaces)
// Each byte = 3 chars hex + 1 char ascii
// Separator = 2 chars
int offsetWidth = 10 * mCharWidth;
int separatorWidth = 3 * mCharWidth;
int remaining = viewportWidth - offsetWidth - separatorWidth - 20;
int fixedWidth = 10 * mCharWidth + 2 * mCharWidth; // offset + separator
int remaining = viewportWidth - fixedWidth;
// Each byte needs: 3 chars hex + 1 char ascii = 4 chars
int bytesPerLine = remaining / (4 * mCharWidth);
// Round to multiple of 8
// Round down to multiple of 8 for clean display
bytesPerLine = qMax(8, (bytesPerLine / 8) * 8);
bytesPerLine = qMin(32, bytesPerLine);
@ -160,7 +167,7 @@ void HexView::paintEvent(QPaintEvent *event)
int visibleLines = viewport()->height() / mLineHeight + 2;
int totalLines = (mData.size() + mBytesPerLine - 1) / mBytesPerLine;
// Calculate column positions
// Calculate column positions (no extra gaps - uniform spacing)
int offsetX = xOffset;
int hexX = offsetX + 10 * mCharWidth;
int asciiX = hexX + mBytesPerLine * 3 * mCharWidth + 2 * mCharWidth;
@ -170,11 +177,10 @@ void HexView::paintEvent(QPaintEvent *event)
painter.setPen(mOffsetColor);
painter.drawText(offsetX, y - 4, "Offset");
// Draw hex column headers
// Draw hex column headers (uniform spacing)
painter.setPen(mTextColor);
for (int i = 0; i < mBytesPerLine; i++) {
int x = hexX + i * 3 * mCharWidth;
if (i == 8) x += mCharWidth; // Gap after 8 bytes
painter.drawText(x, y - 4, QString("%1").arg(i, 2, 16, QChar('0')).toUpper());
}
@ -188,6 +194,11 @@ void HexView::paintEvent(QPaintEvent *event)
y += 4; // Small gap after header
// Get selection range (normalized so start <= end)
int selStart = qMin(mSelectionStart, mSelectionEnd);
int selEnd = qMax(mSelectionStart, mSelectionEnd);
bool hasSelection = mSelectionStart >= 0 && mSelectionEnd >= 0;
// Draw data rows (only visible ones)
for (int line = 0; line < visibleLines && (firstLine + line) < totalLines; line++) {
int dataLine = firstLine + line;
@ -201,13 +212,20 @@ void HexView::paintEvent(QPaintEvent *event)
QString offsetStr = QString("%1").arg(offset, 8, 16, QChar('0')).toUpper();
painter.drawText(offsetX, y, offsetStr);
// Draw hex bytes
// Draw hex bytes (uniform spacing)
for (int i = 0; i < mBytesPerLine; i++) {
int x = hexX + i * 3 * mCharWidth;
if (i >= 8) x += mCharWidth; // Gap after 8 bytes
if (i < bytesInLine) {
quint8 byte = static_cast<quint8>(mData[offset + i]);
int byteIndex = offset + i;
quint8 byte = static_cast<quint8>(mData[byteIndex]);
// Draw selection background for hex
if (hasSelection && byteIndex >= selStart && byteIndex <= selEnd) {
QRect selRect(x - 1, y - mLineHeight + 4, 2 * mCharWidth + 2, mLineHeight);
painter.fillRect(selRect, mSelectionColor);
}
painter.setPen(getByteColor(byte));
QString byteStr = QString("%1").arg(byte, 2, 16, QChar('0')).toUpper();
painter.drawText(x, y, byteStr);
@ -219,14 +237,29 @@ void HexView::paintEvent(QPaintEvent *event)
painter.setPen(mBorderColor);
painter.drawLine(sepX, y - mLineHeight + 4, sepX, y + 2);
// Draw ASCII
// Draw ASCII/Decoded - show extended ASCII like HxD
for (int i = 0; i < mBytesPerLine; i++) {
int x = asciiX + i * mCharWidth;
if (i < bytesInLine) {
quint8 byte = static_cast<quint8>(mData[offset + i]);
char c = (byte >= 0x20 && byte < 0x7F) ? static_cast<char>(byte) : '.';
int byteIndex = offset + i;
quint8 byte = static_cast<quint8>(mData[byteIndex]);
// Draw selection background for decoded
if (hasSelection && byteIndex >= selStart && byteIndex <= selEnd) {
QRect selRect(x, y - mLineHeight + 4, mCharWidth, mLineHeight);
painter.fillRect(selRect, mSelectionColor);
}
painter.setPen(getAsciiColor(byte));
// Show actual character for printable ASCII and extended ASCII (Latin-1)
// Control chars (0x00-0x1F) and DEL (0x7F) show as '.'
QChar c;
if (byte < 0x20 || byte == 0x7F) {
c = '.';
} else {
c = QChar::fromLatin1(static_cast<char>(byte));
}
painter.drawText(x, y, QString(c));
}
}
@ -264,10 +297,183 @@ QColor HexView::getByteColor(quint8 byte) const
QColor HexView::getAsciiColor(quint8 byte) const
{
if (byte >= 0x20 && byte < 0x7F) {
return mPrintableColor;
if (byte < 0x20 || byte == 0x7F) {
return mNullColor; // Control characters shown as '.'
} else if (byte >= 0x20 && byte < 0x7F) {
return mPrintableColor; // Standard ASCII
} else {
return mNonPrintableColor; // Extended ASCII (0x80-0xFF)
}
return mNullColor;
}
void HexView::updateColumnPositions()
{
mOffsetX = -horizontalScrollBar()->value();
mHexX = mOffsetX + 10 * mCharWidth;
mAsciiX = mHexX + mBytesPerLine * 3 * mCharWidth + 2 * mCharWidth;
mHeaderHeight = mLineHeight + 4;
}
int HexView::byteIndexAtPos(const QPoint &pos, SelectionSource *source) const
{
// Check if in header area
if (pos.y() < mHeaderHeight) {
return -1;
}
// Calculate which line
int lineY = pos.y() - mHeaderHeight;
int line = lineY / mLineHeight + verticalScrollBar()->value();
int col = -1;
// Check if in hex area
int hexEndX = mHexX + mBytesPerLine * 3 * mCharWidth;
if (pos.x() >= mHexX && pos.x() < hexEndX) {
int relX = pos.x() - mHexX;
col = relX / (3 * mCharWidth);
if (source) *source = SelectionSource::Hex;
}
// Check if in decoded/ASCII area
else if (pos.x() >= mAsciiX) {
int relX = pos.x() - mAsciiX;
col = relX / mCharWidth;
if (source) *source = SelectionSource::Decoded;
}
if (col < 0 || col >= mBytesPerLine) {
return -1;
}
int byteIndex = line * mBytesPerLine + col;
if (byteIndex < 0 || byteIndex >= mData.size()) {
return -1;
}
return byteIndex;
}
void HexView::clearSelection()
{
mSelectionStart = -1;
mSelectionEnd = -1;
mSelectionSource = SelectionSource::None;
viewport()->update();
}
bool HexView::hasSelection() const
{
return mSelectionStart >= 0 && mSelectionEnd >= 0 && mSelectionStart != mSelectionEnd;
}
QString HexView::getSelectedHex() const
{
if (!hasSelection()) return QString();
int start = qMin(mSelectionStart, mSelectionEnd);
int end = qMax(mSelectionStart, mSelectionEnd);
QString result;
for (int i = start; i <= end && i < mData.size(); i++) {
result += QString("%1").arg(static_cast<quint8>(mData[i]), 2, 16, QChar('0')).toUpper();
}
return result;
}
QString HexView::getSelectedDecoded() const
{
if (!hasSelection()) return QString();
int start = qMin(mSelectionStart, mSelectionEnd);
int end = qMax(mSelectionStart, mSelectionEnd);
QString result;
for (int i = start; i <= end && i < mData.size(); i++) {
quint8 byte = static_cast<quint8>(mData[i]);
if (byte < 0x20 || byte == 0x7F) {
result += '.';
} else {
result += QChar::fromLatin1(static_cast<char>(byte));
}
}
return result;
}
void HexView::copyToClipboard()
{
if (!hasSelection()) return;
QString text;
if (mSelectionSource == SelectionSource::Hex) {
text = getSelectedHex();
} else {
text = getSelectedDecoded();
}
QApplication::clipboard()->setText(text);
}
void HexView::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton) {
updateColumnPositions();
SelectionSource source = SelectionSource::None;
int idx = byteIndexAtPos(event->pos(), &source);
if (idx >= 0) {
mSelectionStart = idx;
mSelectionEnd = idx;
mSelectionSource = source;
mSelecting = true;
setFocus();
viewport()->update();
} else {
clearSelection();
}
}
QAbstractScrollArea::mousePressEvent(event);
}
void HexView::mouseMoveEvent(QMouseEvent *event)
{
if (mSelecting && (event->buttons() & Qt::LeftButton)) {
updateColumnPositions();
int idx = byteIndexAtPos(event->pos());
if (idx >= 0) {
mSelectionEnd = idx;
viewport()->update();
}
}
QAbstractScrollArea::mouseMoveEvent(event);
}
void HexView::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton) {
mSelecting = false;
}
QAbstractScrollArea::mouseReleaseEvent(event);
}
void HexView::keyPressEvent(QKeyEvent *event)
{
if (event->matches(QKeySequence::Copy)) {
copyToClipboard();
event->accept();
return;
}
// Ctrl+A to select all
if (event->modifiers() == Qt::ControlModifier && event->key() == Qt::Key_A) {
if (!mData.isEmpty()) {
mSelectionStart = 0;
mSelectionEnd = mData.size() - 1;
mSelectionSource = SelectionSource::Hex;
viewport()->update();
}
event->accept();
return;
}
QAbstractScrollArea::keyPressEvent(event);
}
// ============================================================================
@ -383,9 +589,8 @@ void HexViewerWidget::setData(const QByteArray &data, const QString &filename)
void HexViewerWidget::setMetadata(const QVariantMap &metadata)
{
// Add metadata from parsed fields (caller provides only visible fields)
for (auto it = metadata.begin(); it != metadata.end(); ++it) {
if (it.key().startsWith("_")) continue;
auto *item = new QTreeWidgetItem(mMetadataTree);
item->setText(0, it.key());

View File

@ -13,12 +13,14 @@
#include "settings.h"
// Custom hex view widget with virtualized rendering
// Custom hex view widget with virtualized rendering and selection
class HexView : public QAbstractScrollArea
{
Q_OBJECT
public:
enum class SelectionSource { None, Hex, Decoded };
explicit HexView(QWidget *parent = nullptr);
void setData(const QByteArray &data);
@ -26,16 +28,29 @@ public:
void setBytesPerLine(int bytes);
int bytesPerLine() const { return mBytesPerLine; }
// Selection
void clearSelection();
bool hasSelection() const;
QString getSelectedHex() const; // Returns hex without spaces
QString getSelectedDecoded() const; // Returns decoded ASCII/Latin-1
void copyToClipboard();
protected:
void paintEvent(QPaintEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
void scrollContentsBy(int dx, int dy) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
private:
void updateScrollBars();
void recalculateBytesPerLine();
QColor getByteColor(quint8 byte) const;
QColor getAsciiColor(quint8 byte) const;
int byteIndexAtPos(const QPoint &pos, SelectionSource *source = nullptr) const;
void updateColumnPositions();
QByteArray mData;
QFont mMonoFont;
@ -43,6 +58,18 @@ private:
int mCharWidth = 0;
int mLineHeight = 0;
// Column positions (calculated once, updated on resize)
int mOffsetX = 0;
int mHexX = 0;
int mAsciiX = 0;
int mHeaderHeight = 0;
// Selection state
int mSelectionStart = -1;
int mSelectionEnd = -1;
SelectionSource mSelectionSource = SelectionSource::None;
bool mSelecting = false;
// Theme colors
QColor mBgColor;
QColor mTextColor;
@ -53,6 +80,7 @@ private:
QColor mControlColor;
QColor mNonPrintableColor;
QColor mBorderColor;
QColor mSelectionColor;
};
class HexViewerWidget : public QWidget

View File

@ -67,10 +67,8 @@ void ImagePreviewWidget::setFilename(const QString &filename)
void ImagePreviewWidget::setMetadata(const QVariantMap &metadata)
{
// Add parsed metadata fields to tree
// Add parsed metadata fields to tree (caller provides only visible fields)
for (auto it = metadata.begin(); it != metadata.end(); ++it) {
if (it.key().startsWith("_")) continue; // Skip internal fields
auto *item = new QTreeWidgetItem(mMetadataTree);
item->setText(0, it.key());
@ -273,6 +271,27 @@ bool ImagePreviewWidget::loadFromData(const QByteArray &data, const QString &for
return true;
}
// Check for RCB pixel block format (Avatar g4rc textures)
if (data.size() >= 0x28) {
const uchar *d = reinterpret_cast<const uchar*>(data.constData());
quint32 rcbDataSize = (d[0x10] << 24) | (d[0x11] << 16) | (d[0x12] << 8) | d[0x13];
uchar formatByte = d[0x09];
if (rcbDataSize > 0 && rcbDataSize + 0x24 <= (quint32)data.size() && formatByte == 0x52) {
QImage image = loadRCBPixel(data);
if (!image.isNull()) {
mImageSize = image.size();
mBitsPerPixel = image.depth();
mOriginalPixmap = QPixmap::fromImage(image);
mZoomFactor = 1.0;
mImageLabel->setPixmap(mOriginalPixmap);
mImageLabel->adjustSize();
mInfoLabel->setText(QString("%1 - %2x%3 (RCB DXT1)").arg(mFilename).arg(image.width()).arg(image.height()));
updateMetadataDisplay();
return true;
}
}
}
QImage image;
// Try standard Qt loading first
@ -782,3 +801,146 @@ QImage ImagePreviewWidget::loadXBTX2D(const QByteArray &data)
// Return the untiled version
return untiledResult;
}
// Helper to find dimensions from DXT1 data size
static bool findDXT1Dimensions(int dataSize, int &outWidth, int &outHeight)
{
int blockCount = dataSize / 8; // DXT1 = 8 bytes per 4x4 block
// Try common power-of-2 dimensions
static const int sizes[] = {2048, 1024, 512, 256, 128, 64, 32, 16};
for (int w : sizes) {
for (int h : sizes) {
if ((w / 4) * (h / 4) == blockCount) {
outWidth = w;
outHeight = h;
return true;
}
}
}
// Try non-square common sizes
static const int heights[] = {1736, 868, 768, 384, 192, 96, 48};
for (int w : sizes) {
for (int h : heights) {
if ((w / 4) * (h / 4) == blockCount) {
outWidth = w;
outHeight = h;
return true;
}
}
}
return false;
}
QImage ImagePreviewWidget::loadRCBPixel(const QByteArray &data)
{
// RCB pixel block header (0x24 bytes):
// 0x00: Unknown (4 bytes)
// 0x08: Format info (byte at +9 = 0x52 for DXT1)
// 0x10: Pixel data size (4 bytes, BE)
// 0x24+: Raw DXT1 data
if (data.size() < 0x28) return QImage();
const uchar *d = reinterpret_cast<const uchar*>(data.constData());
// Read data size (big-endian)
quint32 pixelDataSize = (d[0x10] << 24) | (d[0x11] << 16) | (d[0x12] << 8) | d[0x13];
// Verify it matches
if (pixelDataSize == 0 || (int)(pixelDataSize + 0x24) > data.size()) {
return QImage();
}
// Check format byte (0x52 = DXT1)
uchar formatByte = d[0x09];
bool isDXT1 = (formatByte == 0x52);
if (!isDXT1) {
return QImage();
}
// Find dimensions from data size
int width = 0, height = 0;
if (!findDXT1Dimensions(pixelDataSize, width, height)) {
return QImage();
}
mDetectedFormat = "RCB Pixel (DXT1)";
mCompression = "DXT1";
mBitsPerPixel = 4;
const uchar *pixelData = d + 0x24;
int blockSize = 8;
// Try untiled first (Xbox 360 textures are often tiled)
QByteArray untiled(pixelDataSize, 0);
untileXbox360(pixelData, reinterpret_cast<uchar*>(untiled.data()), width, height, blockSize);
QImage untiledResult = decodeDXTToImage(reinterpret_cast<const uchar*>(untiled.constData()),
width, height, blockSize, false, pixelDataSize);
QImage linearResult = decodeDXTToImage(pixelData, width, height, blockSize, false, pixelDataSize);
// Export for debugging
QDir().mkdir("exports");
QString debugName = mFilename.isEmpty() ? "rcb_debug" : mFilename;
int dot = debugName.lastIndexOf('.');
if (dot > 0) debugName = debugName.left(dot);
if (!untiledResult.isNull()) {
untiledResult.save("exports/" + debugName + "_UNTILED.png", "PNG");
}
if (!linearResult.isNull()) {
linearResult.save("exports/" + debugName + "_LINEAR.png", "PNG");
}
return untiledResult.isNull() ? linearResult : untiledResult;
}
QImage ImagePreviewWidget::loadRawDXT(const QByteArray &data, bool tryDXT5First)
{
int dataSize = data.size();
int width = 0, height = 0;
bool isDXT5 = false;
int blockSize = 8;
if (tryDXT5First) {
int blockCount = dataSize / 16;
for (int w = 2048; w >= 16; w /= 2) {
for (int h = 2048; h >= 16; h /= 2) {
if ((w / 4) * (h / 4) == blockCount) {
width = w;
height = h;
isDXT5 = true;
blockSize = 16;
break;
}
}
if (width > 0) break;
}
}
if (width == 0) {
if (!findDXT1Dimensions(dataSize, width, height)) {
return QImage();
}
isDXT5 = false;
blockSize = 8;
}
mDetectedFormat = isDXT5 ? "Raw DXT5" : "Raw DXT1";
mCompression = isDXT5 ? "DXT5" : "DXT1";
mBitsPerPixel = isDXT5 ? 8 : 4;
const uchar *srcData = reinterpret_cast<const uchar*>(data.constData());
QByteArray untiled(dataSize, 0);
untileXbox360(srcData, reinterpret_cast<uchar*>(untiled.data()), width, height, blockSize);
QImage untiledResult = decodeDXTToImage(reinterpret_cast<const uchar*>(untiled.constData()),
width, height, blockSize, isDXT5, dataSize);
QImage linearResult = decodeDXTToImage(srcData, width, height, blockSize, isDXT5, dataSize);
return untiledResult.isNull() ? linearResult : untiledResult;
}

View File

@ -66,6 +66,12 @@ private:
// Load Xbox 360 XBTX2D texture format
QImage loadXBTX2D(const QByteArray &data);
// Load RCB pixel block (Avatar g4rc texture format)
QImage loadRCBPixel(const QByteArray &data);
// Load raw DXT1/DXT5 data with auto-detected dimensions
QImage loadRawDXT(const QByteArray &data, bool tryDXT5First = false);
// Detected image info
QString mDetectedFormat;
int mBitsPerPixel;

271
app/listpreviewwidget.cpp Normal file
View File

@ -0,0 +1,271 @@
#include "listpreviewwidget.h"
#include <QFileInfo>
ListPreviewWidget::ListPreviewWidget(QWidget *parent)
: QWidget(parent)
{
setupUi();
// Connect to theme changes
connect(&Settings::instance(), &Settings::themeChanged,
this, &ListPreviewWidget::applyTheme);
applyTheme(Settings::instance().theme());
}
void ListPreviewWidget::setupUi()
{
auto *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setSpacing(0);
// Info label at top
mInfoLabel = new QLabel(this);
mInfoLabel->setContentsMargins(8, 4, 8, 4);
mainLayout->addWidget(mInfoLabel);
// Splitter for table and metadata
mSplitter = new QSplitter(Qt::Horizontal, this);
mainLayout->addWidget(mSplitter, 1);
// Table widget for list items
mTableWidget = new QTableWidget(this);
mTableWidget->setSelectionBehavior(QAbstractItemView::SelectRows);
mTableWidget->setSelectionMode(QAbstractItemView::SingleSelection);
mTableWidget->setEditTriggers(QAbstractItemView::NoEditTriggers);
mTableWidget->horizontalHeader()->setStretchLastSection(true);
mTableWidget->verticalHeader()->setVisible(false);
mTableWidget->setAlternatingRowColors(true);
mSplitter->addWidget(mTableWidget);
// Metadata tree on the right
mMetadataTree = new QTreeWidget(this);
mMetadataTree->setHeaderLabels({"Property", "Value"});
mMetadataTree->setColumnCount(2);
mMetadataTree->header()->setStretchLastSection(true);
mMetadataTree->setMinimumWidth(200);
mSplitter->addWidget(mMetadataTree);
// Set initial splitter sizes (70% table, 30% metadata)
mSplitter->setSizes({700, 300});
// Connect selection change
connect(mTableWidget, &QTableWidget::itemSelectionChanged,
this, &ListPreviewWidget::onItemSelectionChanged);
}
void ListPreviewWidget::setListData(const QVariantList &items, const QString &listName)
{
mItems = items;
mListName = listName;
// Update info label
mInfoLabel->setText(QString("%1 | %2 items")
.arg(listName.isEmpty() ? "List" : listName)
.arg(items.size()));
populateTable(items);
}
void ListPreviewWidget::populateTable(const QVariantList &items)
{
mTableWidget->clear();
mTableWidget->setRowCount(0);
if (items.isEmpty()) {
mTableWidget->setColumnCount(1);
mTableWidget->setHorizontalHeaderLabels({"(empty)"});
return;
}
// Collect all unique keys from all items to determine columns
QStringList columns;
for (const QVariant &item : items) {
if (item.typeId() == QMetaType::QVariantMap) {
const QVariantMap map = item.toMap();
for (auto it = map.constBegin(); it != map.constEnd(); ++it) {
const QString &key = it.key();
// Skip internal fields and binary data
if (key.startsWith('_') && key != "_name")
continue;
if (it.value().typeId() == QMetaType::QByteArray)
continue;
if (!columns.contains(key))
columns.append(key);
}
}
}
// If items are simple values (not maps), use a single column
if (columns.isEmpty()) {
columns.append("Value");
}
// Setup columns
mTableWidget->setColumnCount(columns.size());
mTableWidget->setHorizontalHeaderLabels(columns);
// Add rows
mTableWidget->setRowCount(items.size());
for (int row = 0; row < items.size(); ++row) {
const QVariant &item = items[row];
if (item.typeId() == QMetaType::QVariantMap) {
const QVariantMap map = item.toMap();
for (int col = 0; col < columns.size(); ++col) {
const QString &key = columns[col];
QVariant val = map.value(key);
QString displayText;
if (val.typeId() == QMetaType::QVariantMap) {
displayText = QString("{%1 fields}").arg(val.toMap().size());
} else if (val.typeId() == QMetaType::QVariantList) {
displayText = QString("[%1 items]").arg(val.toList().size());
} else if (val.typeId() == QMetaType::QByteArray) {
displayText = QString("<%1 bytes>").arg(val.toByteArray().size());
} else {
displayText = val.toString();
}
auto *tableItem = new QTableWidgetItem(displayText);
mTableWidget->setItem(row, col, tableItem);
}
} else {
// Simple value
auto *tableItem = new QTableWidgetItem(item.toString());
mTableWidget->setItem(row, 0, tableItem);
}
}
// Resize columns to content
mTableWidget->resizeColumnsToContents();
// Clear metadata tree initially
mMetadataTree->clear();
}
void ListPreviewWidget::setMetadata(const QVariantMap &metadata)
{
// This can be used to set overall list metadata
updateMetadataTree(metadata);
}
void ListPreviewWidget::clear()
{
mItems.clear();
mListName.clear();
mTableWidget->clear();
mTableWidget->setRowCount(0);
mTableWidget->setColumnCount(0);
mMetadataTree->clear();
mInfoLabel->setText("");
}
void ListPreviewWidget::onItemSelectionChanged()
{
int row = mTableWidget->currentRow();
if (row >= 0 && row < mItems.size()) {
const QVariant &item = mItems[row];
if (item.typeId() == QMetaType::QVariantMap) {
updateMetadataTree(item.toMap());
} else {
QVariantMap simple;
simple["value"] = item;
simple["index"] = row;
updateMetadataTree(simple);
}
}
}
void ListPreviewWidget::updateMetadataTree(const QVariantMap &item)
{
mMetadataTree->clear();
for (auto it = item.constBegin(); it != item.constEnd(); ++it) {
const QString &key = it.key();
const QVariant &val = it.value();
auto *treeItem = new QTreeWidgetItem(mMetadataTree);
treeItem->setText(0, key);
if (val.typeId() == QMetaType::QVariantMap) {
treeItem->setText(1, QString("{%1 fields}").arg(val.toMap().size()));
// Add nested items
const QVariantMap nested = val.toMap();
for (auto nit = nested.constBegin(); nit != nested.constEnd(); ++nit) {
auto *child = new QTreeWidgetItem(treeItem);
child->setText(0, nit.key());
if (nit.value().typeId() == QMetaType::QByteArray) {
child->setText(1, QString("<%1 bytes>").arg(nit.value().toByteArray().size()));
} else {
child->setText(1, nit.value().toString());
}
}
} else if (val.typeId() == QMetaType::QVariantList) {
treeItem->setText(1, QString("[%1 items]").arg(val.toList().size()));
} else if (val.typeId() == QMetaType::QByteArray) {
treeItem->setText(1, QString("<%1 bytes>").arg(val.toByteArray().size()));
} else {
treeItem->setText(1, val.toString());
}
}
mMetadataTree->expandAll();
mMetadataTree->resizeColumnToContents(0);
}
void ListPreviewWidget::applyTheme(const Theme &theme)
{
// Style info label
mInfoLabel->setStyleSheet(QString(
"QLabel {"
" background-color: %1;"
" color: %2;"
" border-bottom: 1px solid %3;"
"}"
).arg(theme.panelColor, theme.textColorMuted, theme.borderColor));
// Style table widget
mTableWidget->setStyleSheet(QString(
"QTableWidget {"
" background-color: %1;"
" color: %2;"
" border: 1px solid %3;"
" gridline-color: %3;"
"}"
"QTableWidget::item {"
" padding: 4px;"
"}"
"QTableWidget::item:selected {"
" background-color: %4;"
"}"
"QTableWidget::item:alternate {"
" background-color: %5;"
"}"
"QHeaderView::section {"
" background-color: %1;"
" color: %2;"
" border: 1px solid %3;"
" padding: 4px;"
" font-weight: bold;"
"}"
).arg(theme.panelColor, theme.textColor, theme.borderColor,
theme.accentColor, theme.backgroundColor));
// Style metadata tree
mMetadataTree->setStyleSheet(QString(
"QTreeWidget {"
" background-color: %1;"
" color: %2;"
" border: 1px solid %3;"
"}"
"QTreeWidget::item:selected {"
" background-color: %4;"
"}"
"QHeaderView::section {"
" background-color: %1;"
" color: %2;"
" border: 1px solid %3;"
" padding: 4px;"
"}"
).arg(theme.panelColor, theme.textColor, theme.borderColor, theme.accentColor));
}

48
app/listpreviewwidget.h Normal file
View File

@ -0,0 +1,48 @@
#ifndef LISTPREVIEWWIDGET_H
#define LISTPREVIEWWIDGET_H
#include <QWidget>
#include <QTableWidget>
#include <QTreeWidget>
#include <QLabel>
#include <QSplitter>
#include <QVBoxLayout>
#include <QHeaderView>
#include "settings.h"
class ListPreviewWidget : public QWidget
{
Q_OBJECT
public:
explicit ListPreviewWidget(QWidget *parent = nullptr);
~ListPreviewWidget() = default;
// Set list data from QVariantList (each item is a QVariantMap with fields)
void setListData(const QVariantList &items, const QString &listName = QString());
// Set metadata for the sidebar
void setMetadata(const QVariantMap &metadata);
// Clear the widget
void clear();
private slots:
void applyTheme(const Theme &theme);
void onItemSelectionChanged();
private:
void setupUi();
void populateTable(const QVariantList &items);
void updateMetadataTree(const QVariantMap &item);
QLabel *mInfoLabel;
QTableWidget *mTableWidget;
QTreeWidget *mMetadataTree;
QSplitter *mSplitter;
QVariantList mItems;
QString mListName;
};
#endif // LISTPREVIEWWIDGET_H

149
app/textviewerwidget.cpp Normal file
View File

@ -0,0 +1,149 @@
#include "textviewerwidget.h"
#include <QFileInfo>
#include <QHeaderView>
TextViewerWidget::TextViewerWidget(QWidget *parent)
: QWidget(parent)
{
auto *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setSpacing(0);
// Info label at top
mInfoLabel = new QLabel(this);
mInfoLabel->setContentsMargins(8, 4, 8, 4);
mainLayout->addWidget(mInfoLabel);
// Splitter for text view and metadata
mSplitter = new QSplitter(Qt::Horizontal, this);
mainLayout->addWidget(mSplitter, 1);
// Text editor (read-only)
mTextEdit = new QPlainTextEdit(this);
mTextEdit->setReadOnly(true);
mTextEdit->setLineWrapMode(QPlainTextEdit::NoWrap);
// Use monospace font
QFont monoFont("Consolas", 10);
monoFont.setStyleHint(QFont::Monospace);
mTextEdit->setFont(monoFont);
mSplitter->addWidget(mTextEdit);
// Metadata tree on the right
mMetadataTree = new QTreeWidget(this);
mMetadataTree->setHeaderLabels({"Property", "Value"});
mMetadataTree->setColumnCount(2);
mMetadataTree->header()->setStretchLastSection(true);
mMetadataTree->setMinimumWidth(200);
mSplitter->addWidget(mMetadataTree);
// Set initial splitter sizes (80% text, 20% metadata)
mSplitter->setSizes({800, 200});
// Connect to theme changes
connect(&Settings::instance(), &Settings::themeChanged,
this, &TextViewerWidget::applyTheme);
applyTheme(Settings::instance().theme());
}
void TextViewerWidget::setData(const QByteArray &data, const QString &filename)
{
mData = data;
mFilename = filename;
// Set text content
QString text = QString::fromUtf8(data);
mTextEdit->setPlainText(text);
// Update info label
QFileInfo fi(filename);
int lineCount = text.count('\n') + 1;
mInfoLabel->setText(QString("%1 | %2 bytes | %3 lines")
.arg(filename)
.arg(data.size())
.arg(lineCount));
// Setup syntax highlighting based on extension
setupSyntaxHighlighting(fi.suffix().toLower());
}
void TextViewerWidget::setMetadata(const QVariantMap &metadata)
{
mMetadataTree->clear();
for (auto it = metadata.constBegin(); it != metadata.constEnd(); ++it) {
const QString &key = it.key();
const QVariant &val = it.value();
// Skip internal fields and large binary data
if (key.startsWith('_') && key != "_name" && key != "_type")
continue;
if (val.typeId() == QMetaType::QByteArray)
continue;
auto *item = new QTreeWidgetItem(mMetadataTree);
item->setText(0, key);
if (val.typeId() == QMetaType::QVariantMap) {
item->setText(1, QString("{%1 fields}").arg(val.toMap().size()));
} else if (val.typeId() == QMetaType::QVariantList) {
item->setText(1, QString("[%1 items]").arg(val.toList().size()));
} else {
item->setText(1, val.toString());
}
}
mMetadataTree->resizeColumnToContents(0);
}
void TextViewerWidget::applyTheme(const Theme &theme)
{
mCurrentTheme = theme;
// Style the text editor
QString textStyle = QString(
"QPlainTextEdit {"
" background-color: %1;"
" color: %2;"
" border: 1px solid %3;"
" selection-background-color: %4;"
"}"
).arg(theme.panelColor, theme.textColor, theme.borderColor, theme.accentColor);
mTextEdit->setStyleSheet(textStyle);
// Style info label
mInfoLabel->setStyleSheet(QString(
"QLabel {"
" background-color: %1;"
" color: %2;"
" border-bottom: 1px solid %3;"
"}"
).arg(theme.panelColor, theme.textColorMuted, theme.borderColor));
// Style metadata tree
mMetadataTree->setStyleSheet(QString(
"QTreeWidget {"
" background-color: %1;"
" color: %2;"
" border: 1px solid %3;"
"}"
"QTreeWidget::item:selected {"
" background-color: %4;"
"}"
"QHeaderView::section {"
" background-color: %1;"
" color: %2;"
" border: 1px solid %3;"
" padding: 4px;"
"}"
).arg(theme.panelColor, theme.textColor, theme.borderColor, theme.accentColor));
}
void TextViewerWidget::setupSyntaxHighlighting(const QString &extension)
{
// Basic syntax highlighting could be added here in the future
// For now, just adjust display based on file type
Q_UNUSED(extension)
}

42
app/textviewerwidget.h Normal file
View File

@ -0,0 +1,42 @@
#ifndef TEXTVIEWERWIDGET_H
#define TEXTVIEWERWIDGET_H
#include <QWidget>
#include <QPlainTextEdit>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QSplitter>
#include <QTreeWidget>
#include "settings.h"
class TextViewerWidget : public QWidget
{
Q_OBJECT
public:
explicit TextViewerWidget(QWidget *parent = nullptr);
~TextViewerWidget() = default;
void setData(const QByteArray &data, const QString &filename);
void setMetadata(const QVariantMap &metadata);
private slots:
void applyTheme(const Theme &theme);
private:
void setupSyntaxHighlighting(const QString &extension);
QByteArray mData;
QString mFilename;
QSplitter *mSplitter;
QLabel *mInfoLabel;
QPlainTextEdit *mTextEdit;
QTreeWidget *mMetadataTree;
Theme mCurrentTheme;
};
#endif // TEXTVIEWERWIDGET_H

324
app/treebuilder.cpp Normal file
View File

@ -0,0 +1,324 @@
#include "treebuilder.h"
#include "xtreewidget.h"
#include "xtreewidgetitem.h"
#include "typeregistry.h"
#include "logmanager.h"
#include <QCollator>
#include <algorithm>
// Natural sort comparator for strings with numbers (e.g., "chunk1" < "chunk2" < "chunk10")
static bool naturalLessThan(const QString& a, const QString& b) {
static QCollator collator;
collator.setNumericMode(true);
collator.setCaseSensitivity(Qt::CaseInsensitive);
return collator.compare(a, b) < 0;
}
TreeBuilder::TreeBuilder(XTreeWidget* tree, const TypeRegistry& registry)
: m_tree(tree)
, m_registry(registry)
{
}
void TreeBuilder::reset()
{
m_categoryRoots.clear();
}
QString TreeBuilder::pluralizeType(const QString& typeName) const
{
const auto& mod = m_registry.module();
const auto it = mod.types.find(typeName);
QString groupLabel = (it != mod.types.end() && !it->display.isEmpty())
? it->display
: typeName;
return groupLabel + "s";
}
XTreeWidgetItem* TreeBuilder::ensureTypeCategoryRoot(const QString& typeName, const QString& displayOverride)
{
const QString categoryKey = displayOverride.isEmpty() ? typeName : displayOverride;
if (m_categoryRoots.contains(categoryKey))
return m_categoryRoots[categoryKey];
auto* root = new XTreeWidgetItem(m_tree);
const QString categoryLabel = displayOverride.isEmpty()
? pluralizeType(typeName)
: displayOverride + "s";
root->setText(0, categoryLabel);
root->setData(0, Qt::UserRole + 1, "CATEGORY");
root->setData(0, Qt::UserRole + 2, typeName);
m_tree->addTopLevelItem(root);
m_categoryRoots.insert(categoryKey, root);
return root;
}
XTreeWidgetItem* TreeBuilder::ensureSubcategory(XTreeWidgetItem* parent, const QString& childTypeName)
{
// Look for existing subcategory
for (int i = 0; i < parent->childCount(); i++) {
auto* c = static_cast<XTreeWidgetItem*>(parent->child(i));
if (c->data(0, Qt::UserRole + 1).toString() == "SUBCATEGORY" &&
c->data(0, Qt::UserRole + 2).toString() == childTypeName)
{
return c;
}
}
auto* sub = new XTreeWidgetItem(parent);
sub->setText(0, pluralizeType(childTypeName));
sub->setData(0, Qt::UserRole + 1, "SUBCATEGORY");
sub->setData(0, Qt::UserRole + 2, childTypeName);
parent->addChild(sub);
sub->setExpanded(false);
return sub;
}
XTreeWidgetItem* TreeBuilder::addInstanceNode(XTreeWidgetItem* parent, const QString& displayName,
const QString& typeName, const QVariantMap& vars)
{
auto* inst = new XTreeWidgetItem(parent);
inst->setText(0, displayName);
inst->setData(0, Qt::UserRole + 1, "INSTANCE");
inst->setData(0, Qt::UserRole + 2, typeName);
inst->setData(0, Qt::UserRole + 3, vars);
parent->addChild(inst);
inst->setExpanded(false);
return inst;
}
QString TreeBuilder::instanceDisplayFor(const QVariantMap& obj, const QString& fallbackType,
const QString& fallbackKey, std::optional<int> index)
{
// _name takes priority (explicit name set by script via set_name())
if (DslKeys::contains(obj, DslKey::Name)) {
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;
}
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>");
}
void TreeBuilder::addParsedFile(const QString& typeName, const QVariantMap& vars, const QString& fileName)
{
XTreeWidgetItem* cat = ensureTypeCategoryRoot(typeName, DslKeys::getString(vars, DslKey::Display));
const QString displayName = DslKeys::contains(vars, DslKey::Name)
? DslKeys::getString(vars, DslKey::Name)
: fileName;
XTreeWidgetItem* inst = addInstanceNode(cat, displayName, typeName, vars);
routeNestedObjects(inst, vars);
}
void TreeBuilder::routeNestedObjects(XTreeWidgetItem* parent, const QVariantMap& vars)
{
for (auto it = vars.begin(); it != vars.end(); ++it) {
const QString& key = it.key();
const QVariant& v = it.value();
// Child object (QVariantMap with _type)
if (v.typeId() == QMetaType::QVariantMap) {
const QVariantMap child = v.toMap();
// Skip hidden objects
if (DslKeys::get(child, DslKey::Hidden).toBool()) {
continue;
}
const QString childType = DslKeys::getString(child, DslKey::Type);
if (!childType.isEmpty()) {
auto* subcat = ensureSubcategory(parent, childType);
const QString childDisplay = instanceDisplayFor(child, childType, key);
auto* childInst = addInstanceNode(subcat, childDisplay, childType, child);
routeNestedObjects(childInst, child);
}
continue;
}
// Array of child objects
if (v.typeId() == QMetaType::QVariantList) {
// Check for skip marker
if (DslKeys::hasSkipTree(vars, key)) {
LogManager::instance().debug(QString("[TREE] Skipping array '%1' (has skip_tree marker)").arg(key));
continue;
}
const QVariantList list = v.toList();
LogManager::instance().debug(QString("[TREE] Processing array '%1' with %2 items")
.arg(key).arg(list.size()));
for (int i = 0; i < list.size(); i++) {
if (list[i].typeId() != QMetaType::QVariantMap) {
continue;
}
const QVariantMap child = list[i].toMap();
// Skip hidden objects
if (DslKeys::get(child, DslKey::Hidden).toBool()) {
continue;
}
const QString childType = DslKeys::getString(child, DslKey::Type);
if (childType.isEmpty()) {
continue;
}
auto* subcat = ensureSubcategory(parent, childType);
const QString childDisplay = instanceDisplayFor(child, childType, key, i);
auto* childInst = addInstanceNode(subcat, childDisplay, childType, child);
routeNestedObjects(childInst, child);
}
}
}
}
void TreeBuilder::organizeChildrenByExtension(XTreeWidgetItem* parent)
{
if (!parent) return;
// Recursively process children first (depth-first)
for (int i = 0; i < parent->childCount(); i++) {
organizeChildrenByExtension(static_cast<XTreeWidgetItem*>(parent->child(i)));
}
// Only organize SUBCATEGORY nodes
QString nodeType = parent->data(0, Qt::UserRole + 1).toString();
if (nodeType != "SUBCATEGORY") return;
// Group children by extension
QMap<QString, QList<XTreeWidgetItem*>> byExtension;
QList<XTreeWidgetItem*> noExtension;
QList<XTreeWidgetItem*> nonInstanceChildren;
// Take all children
QList<XTreeWidgetItem*> children;
while (parent->childCount() > 0) {
children.append(static_cast<XTreeWidgetItem*>(parent->takeChild(0)));
}
for (auto* child : children) {
QString childNodeType = child->data(0, Qt::UserRole + 1).toString();
// Keep non-instance children as-is
if (childNodeType == "SUBCATEGORY" || childNodeType == "EXTENSION_GROUP") {
nonInstanceChildren.append(child);
continue;
}
QVariantMap vars = child->data(0, Qt::UserRole + 3).toMap();
QString name = DslKeys::getString(vars, DslKey::Name);
// Skip names starting with dot (indexed chunk names)
if (name.startsWith('.')) {
noExtension.append(child);
continue;
}
int dotPos = name.lastIndexOf('.');
if (dotPos > 0) {
QString ext = name.mid(dotPos + 1).toLower();
byExtension[ext].append(child);
} else {
noExtension.append(child);
}
}
// Re-add non-instance children first
for (auto* child : nonInstanceChildren) {
parent->addChild(child);
}
// Only create groups if multiple extensions exist
int uniqueExtensions = byExtension.size() + (noExtension.isEmpty() ? 0 : 1);
if (uniqueExtensions <= 1) {
// Put all items back directly (sorted)
QList<XTreeWidgetItem*> allItems;
for (auto& list : byExtension) {
allItems.append(list);
}
allItems.append(noExtension);
std::sort(allItems.begin(), allItems.end(), [](XTreeWidgetItem* a, XTreeWidgetItem* b) {
return naturalLessThan(a->text(0), b->text(0));
});
for (auto* child : allItems) {
parent->addChild(child);
}
return;
}
// Create extension groups
QStringList sortedExts = byExtension.keys();
std::sort(sortedExts.begin(), sortedExts.end());
for (const QString& ext : sortedExts) {
auto* extGroup = new XTreeWidgetItem(parent);
extGroup->setText(0, QString(".%1").arg(ext));
extGroup->setData(0, Qt::UserRole + 1, "EXTENSION_GROUP");
extGroup->setData(0, Qt::UserRole + 2, ext);
QList<XTreeWidgetItem*>& items = byExtension[ext];
std::sort(items.begin(), items.end(), [](XTreeWidgetItem* a, XTreeWidgetItem* b) {
return naturalLessThan(a->text(0), b->text(0));
});
for (auto* item : items) {
extGroup->addChild(item);
}
extGroup->setExpanded(false);
}
// Add items with no extension at the end
if (!noExtension.isEmpty()) {
auto* otherGroup = new XTreeWidgetItem(parent);
otherGroup->setText(0, "(other)");
otherGroup->setData(0, Qt::UserRole + 1, "EXTENSION_GROUP");
otherGroup->setData(0, Qt::UserRole + 2, "");
std::sort(noExtension.begin(), noExtension.end(), [](XTreeWidgetItem* a, XTreeWidgetItem* b) {
return naturalLessThan(a->text(0), b->text(0));
});
for (auto* item : noExtension) {
otherGroup->addChild(item);
}
otherGroup->setExpanded(false);
}
}
void TreeBuilder::updateNodeCounts(XTreeWidgetItem* node)
{
if (!node) return;
// Recursively update children first
for (int i = 0; i < node->childCount(); i++) {
updateNodeCounts(static_cast<XTreeWidgetItem*>(node->child(i)));
}
// Update count for grouping nodes
QString nodeType = node->data(0, Qt::UserRole + 1).toString();
if (nodeType == "SUBCATEGORY" || nodeType == "CATEGORY" || nodeType == "EXTENSION_GROUP") {
int count = node->childCount();
if (count > 0) {
QString currentText = node->text(0);
int parenPos = currentText.lastIndexOf(" (");
if (parenPos > 0) {
currentText = currentText.left(parenPos);
}
node->setText(0, QString("%1 (%2)").arg(currentText).arg(count));
}
}
}

52
app/treebuilder.h Normal file
View File

@ -0,0 +1,52 @@
#ifndef TREEBUILDER_H
#define TREEBUILDER_H
#include "dslkeys.h"
#include <QVariantMap>
#include <QString>
#include <QHash>
#include <optional>
class XTreeWidget;
class XTreeWidgetItem;
class TypeRegistry;
class TreeBuilder
{
public:
TreeBuilder(XTreeWidget* tree, const TypeRegistry& registry);
// Build tree from parsed data
void addParsedFile(const QString& typeName, const QVariantMap& vars, const QString& fileName);
// Category management
XTreeWidgetItem* ensureTypeCategoryRoot(const QString& typeName, const QString& displayOverride = {});
XTreeWidgetItem* ensureSubcategory(XTreeWidgetItem* parent, const QString& childTypeName);
// Instance management
XTreeWidgetItem* addInstanceNode(XTreeWidgetItem* parent, const QString& displayName,
const QString& typeName, const QVariantMap& vars);
// Recursively route nested objects into tree
void routeNestedObjects(XTreeWidgetItem* parent, const QVariantMap& vars);
// Post-processing
void organizeChildrenByExtension(XTreeWidgetItem* parent);
void updateNodeCounts(XTreeWidgetItem* node);
// Utilities
QString pluralizeType(const QString& typeName) const;
static QString instanceDisplayFor(const QVariantMap& obj, const QString& fallbackType,
const QString& fallbackKey = {}, std::optional<int> index = std::nullopt);
// Clear all category roots
void reset();
private:
XTreeWidget* m_tree;
const TypeRegistry& m_registry;
QHash<QString, XTreeWidgetItem*> m_categoryRoots;
};
#endif // TREEBUILDER_H