diff --git a/CMakeLists.txt b/CMakeLists.txt index 5044d66..8c73720 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -109,6 +109,8 @@ add_executable(Reclass src/scannerpanel.h src/scannerpanel.cpp src/mainwindow.h + src/startpage.h + src/dock_tab_buttons.h src/optionsdialog.h src/optionsdialog.cpp src/titlebar.h diff --git a/src/main.cpp b/src/main.cpp index 9e51d11..c05971d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -574,6 +574,7 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { overlay->raise(); overlay->show(); + // Central placeholder — will be replaced by start page after construction m_centralPlaceholder = new QWidget(this); m_centralPlaceholder->setFixedSize(0, 0); m_centralPlaceholder->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored); @@ -612,6 +613,9 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, &MainWindow::applyTheme); + // Apply theme once at startup (the signal only fires on change, not initial load) + applyTheme(ThemeManager::instance().current()); + // Load plugins m_pluginManager.LoadPlugins(); @@ -2292,15 +2296,20 @@ void MainWindow::applyTheme(const Theme& theme) { if (m_titleBar) m_titleBar->applyTheme(theme); + // Start page + if (m_startPage) + m_startPage->applyTheme(theme); + // Update border overlay color updateBorderColor(isActiveWindow() ? theme.borderFocused : theme.border); - // Style doc dock tab bars and remove dock borders + // Style doc dock tab bars and remove dock borders. + // QWidget default colors are required because having ANY stylesheet on QMainWindow + // switches children from palette-based to CSS-based rendering. setStyleSheet(QStringLiteral( "QMainWindow::separator { width: 1px; height: 1px; background: transparent; }" "QDockWidget { border: none; }" - "QDockWidget > QWidget { border: none; }") - .arg(theme.border.name())); + "QDockWidget > QWidget { border: none; }")); for (auto* tabBar : findChildren()) { // Only style tab bars owned directly by this QMainWindow (dock tabs), @@ -3165,10 +3174,7 @@ QDockWidget* MainWindow::project_open(const QString& path) { if (filePath.isEmpty()) { filePath = QFileDialog::getOpenFileName(this, "Open Definition", {}, - "All Supported (*.rcx *.json *.reclass *.MemeCls *.xml)" - ";;Reclass (*.rcx)" - ";;JSON (*.json)" - ";;ReClass XML (*.reclass *.MemeCls *.xml)" + "Reclass (*.rcx)" ";;All (*)"); if (filePath.isEmpty()) return nullptr; } @@ -3179,8 +3185,7 @@ QDockWidget* MainWindow::project_open(const QString& path) { QFile probe(filePath); if (probe.open(QIODevice::ReadOnly)) { QByteArray head = probe.read(64); - isXml = head.trimmed().startsWith("update(); } +void MainWindow::showStartPage() { + if (m_startPage) return; + + m_startPage = new StartPageWidget(this); + m_startPage->applyTheme(ThemeManager::instance().current()); + + // Size the popup to ~85% of the main window, min 1060 wide for two-column layout + QSize sz(qBound(1060, int(width() * 0.85), width() - 40), + qBound(560, int(height() * 0.8), height() - 40)); + m_startPage->setFixedSize(sz); + + // Wire start page signals — each closes the dialog then performs action + connect(m_startPage, &StartPageWidget::openProject, this, [this]() { + dismissStartPage(); + openFile(); + if (m_tabs.isEmpty()) showStartPage(); + }); + connect(m_startPage, &StartPageWidget::newClass, this, [this]() { + dismissStartPage(); + newClass(); + }); + connect(m_startPage, &StartPageWidget::importSource, this, [this]() { + dismissStartPage(); + importFromSource(); + if (m_tabs.isEmpty()) showStartPage(); + }); + connect(m_startPage, &StartPageWidget::importXml, this, [this]() { + dismissStartPage(); + importReclassXml(); + if (m_tabs.isEmpty()) showStartPage(); + }); + connect(m_startPage, &StartPageWidget::importPdb, this, [this]() { + dismissStartPage(); + importPdb(); + if (m_tabs.isEmpty()) showStartPage(); + }); + connect(m_startPage, &StartPageWidget::continueClicked, this, [this]() { + dismissStartPage(); + selfTest(); + }); + connect(m_startPage, &StartPageWidget::fileSelected, this, [this](const QString& path) { + dismissStartPage(); + project_open(path); + }); + connect(m_startPage, &QDialog::rejected, this, [this]() { + dismissStartPage(); + }); + + // Center over main window and show as application-modal + m_startPage->move(geometry().center() - m_startPage->rect().center()); + m_startPage->open(); +} + +void MainWindow::dismissStartPage() { + if (!m_startPage) return; + m_startPage->close(); + m_startPage->deleteLater(); + m_startPage = nullptr; +} + } // namespace rcx // ── Entry point ── @@ -3986,8 +4051,8 @@ int main(int argc, char* argv[]) { window.show(); - // Auto-open demo project from saved .rcx file - QMetaObject::invokeMethod(&window, "selfTest"); + // Show VS2022-style start page instead of jumping straight to demo + QMetaObject::invokeMethod(&window, "showStartPage", Qt::QueuedConnection); return app.exec(); } diff --git a/src/mainwindow.h b/src/mainwindow.h index bccf5cb..82fab5f 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -3,6 +3,7 @@ #include "titlebar.h" #include "pluginmanager.h" #include "scannerpanel.h" +#include "startpage.h" #include #include #include @@ -169,6 +170,11 @@ private: DockGripWidget* m_scanDockGrip = nullptr; void createScannerDock(); + // Start page + StartPageWidget* m_startPage = nullptr; + Q_INVOKABLE void showStartPage(); + void dismissStartPage(); + protected: void changeEvent(QEvent* event) override; void resizeEvent(QResizeEvent* event) override; diff --git a/src/resources.qrc b/src/resources.qrc index e080e74..54d91f5 100644 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -64,5 +64,6 @@ vsicons/pinned.svg vsicons/close-all.svg vsicons/split-vertical.svg + vsicons/book.svg diff --git a/src/startpage.h b/src/startpage.h new file mode 100644 index 0000000..ed86517 --- /dev/null +++ b/src/startpage.h @@ -0,0 +1,360 @@ +#pragma once +#include "themes/thememanager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rcx { + +// Single-widget start page: everything painted in paintEvent. +// Zero CSS, zero Fusion conflicts, zero child-widget styling issues. + +class StartPageWidget : public QDialog { + Q_OBJECT +public: + explicit StartPageWidget(QWidget* parent = nullptr) : QDialog(parent) { + setWindowFlags(Qt::FramelessWindowHint | Qt::Dialog); + setMouseTracking(true); + setAttribute(Qt::WA_OpaquePaintEvent); + + m_search = new QLineEdit(this); + m_search->setPlaceholderText("Search recent..."); + m_search->setFixedHeight(30); + m_search->setMaximumWidth(330); + m_search->addAction(QIcon(":/vsicons/search.svg"), QLineEdit::TrailingPosition); + connect(m_search, &QLineEdit::textChanged, this, [this]{ buildGroups(); update(); }); + + loadEntries(); + buildGroups(); + applyTheme(ThemeManager::instance().current()); + } + + void applyTheme(const Theme& t) { + m_t = t; + m_search->setStyleSheet( + "QLineEdit { background: " + t.background.name() + "; color: " + t.text.name() + + "; border: 1px solid " + t.border.name() + + "; padding: 2px 8px; font-size: 13px; }" + "QLineEdit:focus { border: 1px solid " + t.borderFocused.name() + "; }"); + update(); + } + +signals: + void openProject(); + void newClass(); + void importSource(); + void importXml(); + void importPdb(); + void continueClicked(); + void fileSelected(const QString& path); + +protected: + void paintEvent(QPaintEvent*) override { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + const int LX = 48, TM = 36, RM = 48, GAP = 40, RW = 340; + const int rpX = width() - RW - RM; + const int lW = qMax(100, rpX - GAP - LX); + + p.fillRect(rect(), m_t.background); + + // ── Title ── + int y = TM; + QFont titleF = font(); titleF.setPixelSize(30); titleF.setWeight(QFont::Light); + p.setFont(titleF); p.setPen(m_t.text); + QFontMetrics titleFm(titleF); + p.drawText(LX, y + titleFm.ascent(), "Reclass"); + y += titleFm.height() + 24; + + // ── Headings (left + right at same y) ── + QFont headF = font(); headF.setPixelSize(20); headF.setWeight(QFont::DemiBold); + p.setFont(headF); QFontMetrics headFm(headF); + p.drawText(LX, y + headFm.ascent(), "Open recent"); + int ry = y; + p.drawText(rpX, ry + headFm.ascent(), "Get started"); + ry += headFm.height() + 14; + y += headFm.height() + 14; + + // ── Search bar (only child widget) ── + m_search->setGeometry(LX, y, qMin(330, lW), 30); + y += 46; + m_listTop = y; + + // ── Right panel ── + drawCards(p, rpX, ry, RW); + + // ── File list ── + drawFileList(p, LX, lW); + + // ── Border ── + p.setPen(QPen(m_t.border, 1)); + p.setBrush(Qt::NoBrush); + p.drawRect(rect().adjusted(0, 0, -1, -1)); + } + + void mouseMoveEvent(QMouseEvent* e) override { + auto [z, i] = hitTest(e->pos()); + if (z != m_hz || i != m_hi) { + m_hz = z; m_hi = i; + setCursor(z != HZ_None ? Qt::PointingHandCursor : Qt::ArrowCursor); + update(); + } + } + + void mousePressEvent(QMouseEvent* e) override { + if (e->button() != Qt::LeftButton) return; + auto [z, i] = hitTest(e->pos()); + if (z == HZ_Entry) emit fileSelected(m_filtered[i].path); + if (z == HZ_Group) { m_groups[i].expanded = !m_groups[i].expanded; update(); } + if (z == HZ_Card && i == 0) emit openProject(); + if (z == HZ_Card && i == 1) emit newClass(); + if (z == HZ_Card && i == 2) emit importSource(); + if (z == HZ_Card && i == 3) emit importXml(); + if (z == HZ_Card && i == 4) emit importPdb(); + if (z == HZ_Continue) emit continueClicked(); + } + + void wheelEvent(QWheelEvent* e) override { + m_scrollY = qBound(0, m_scrollY - e->angleDelta().y() / 2, m_maxScroll); + update(); + } + + void resizeEvent(QResizeEvent* e) override { QWidget::resizeEvent(e); update(); } + void leaveEvent(QEvent*) override { m_hz = HZ_None; m_hi = -1; setCursor(Qt::ArrowCursor); update(); } + void keyPressEvent(QKeyEvent* e) override { if (e->key() == Qt::Key_Escape) reject(); } + +private: + enum HZ { HZ_None, HZ_Entry, HZ_Group, HZ_Card, HZ_Continue }; + struct Hit { HZ zone; int idx; }; + + struct Entry { + QString path, fileName, dirPath; + QDateTime lastModified; + bool isExample; + }; + struct Group { + QString name; + bool expanded = true; + QVector entries; + }; + + Theme m_t; + QLineEdit* m_search; + QVector m_all, m_filtered; + QVector m_groups; + int m_scrollY = 0, m_maxScroll = 0, m_listTop = 0, m_contentH = 0; + + HZ m_hz = HZ_None; + int m_hi = -1; + + // Hit rects populated during paint + QVector> m_grpRects, m_entRects; + QRectF m_cardR[5], m_contR; + + void drawIcon(QPainter& p, const QString& path, int x, int y, int sz) { + QIcon(path).paint(&p, x, y, sz, sz); + } + + // ── Data loading ── + + void loadEntries() { + m_all.clear(); + QSettings s("Reclass", "Reclass"); + for (const auto& path : s.value("recentFiles").toStringList()) { + QFileInfo fi(path); + if (!fi.exists()) continue; + m_all.append({fi.absoluteFilePath(), fi.fileName(), fi.absolutePath(), + fi.lastModified(), false}); + } +#ifdef __APPLE__ + QDir exDir(QDir::cleanPath(QCoreApplication::applicationDirPath() + "/../Resources/examples")); +#else + QDir exDir(QCoreApplication::applicationDirPath() + "/examples"); +#endif + for (const auto& fn : exDir.entryList({"*.rcx"}, QDir::Files, QDir::Name)) + m_all.append({exDir.absoluteFilePath(fn), fn, exDir.absolutePath(), + QFileInfo(exDir.filePath(fn)).lastModified(), true}); + } + + void buildGroups() { + QString f = m_search->text().trimmed().toLower(); + m_filtered.clear(); + for (const auto& e : m_all) + if (f.isEmpty() || e.fileName.toLower().contains(f) || e.dirPath.toLower().contains(f)) + m_filtered.append(e); + + QDate today = QDate::currentDate(); + QVector bk[6]; + for (int i = 0; i < m_filtered.size(); i++) { + auto& e = m_filtered[i]; + if (e.isExample) { bk[5].append(i); continue; } + int d = e.lastModified.date().daysTo(today); + if (d == 0) bk[0].append(i); + else if (d == 1) bk[1].append(i); + else if (d < 7) bk[2].append(i); + else if (e.lastModified.date().month() == today.month() + && e.lastModified.date().year() == today.year()) bk[3].append(i); + else bk[4].append(i); + } + static const char* names[] = {"Today","Yesterday","This week","This month","Older","Examples"}; + m_groups.clear(); + for (int i = 0; i < 6; i++) + if (!bk[i].isEmpty()) m_groups.append({names[i], true, bk[i]}); + m_scrollY = 0; + } + + // ── Drawing ── + + void drawCards(QPainter& p, int x, int y, int w) { + struct C { const char* icon; const char* title; const char* desc; }; + static const C cards[] = { + {":/vsicons/folder-opened.svg", "Open a project", "Open an existing .rcx project"}, + {":/vsicons/symbol-class.svg", "New Class", "Start a new binary class definition"}, + {":/vsicons/file-binary.svg", "Import from Source", "Import C/C++ header or source file"}, + {":/vsicons/code.svg", "Import ReClass XML", "Import from ReClass .xml format"}, + {":/vsicons/debug.svg", "Import PDB", "Import types from a .pdb symbol file"} + }; + + const int N = 5, CH = 84, R = 6, panelH = N * CH; + + // Rounded panel background + QPainterPath clip; + clip.addRoundedRect(QRectF(x, y, w, panelH), R, R); + p.save(); + p.setClipPath(clip); + p.fillRect(x, y, w, panelH, m_t.background); + + for (int i = 0; i < N; i++) { + int cy = y + i * CH; + QRectF cr(x, cy, w, CH); + m_cardR[i] = cr; + bool hov = (m_hz == HZ_Card && m_hi == i); + + if (hov) { + p.fillRect(cr, m_t.hover); + p.fillRect(QRectF(x, cy, 3, CH), m_t.indHoverSpan); + } + + // Icon (32px, centered vertically) + int iconSz = 32; + drawIcon(p, cards[i].icon, x + 24, cy + (CH - iconSz) / 2, iconSz); + + // Title + description block, centered vertically + int tx = x + 24 + iconSz + 16; + QFont tf = font(); tf.setPixelSize(15); + QFont df = font(); df.setPixelSize(12); + QFontMetrics tfm(tf), dfm(df); + int blockH = tfm.height() + 5 + dfm.height(); + int by = cy + (CH - blockH) / 2; + + p.setFont(tf); p.setPen(m_t.text); + p.drawText(tx, by + tfm.ascent(), cards[i].title); + p.setFont(df); p.setPen(m_t.textDim); + p.drawText(tx, by + tfm.height() + 5 + dfm.ascent(), cards[i].desc); + } + + p.restore(); + + // "Continue ->" + int cy = y + N * CH + 16; + QFont lf = font(); lf.setPixelSize(13); + if (m_hz == HZ_Continue) lf.setUnderline(true); + p.setFont(lf); p.setPen(m_t.indHoverSpan); + QFontMetrics lfm(lf); + QString ct = QStringLiteral("Continue \u2192"); + int cw = lfm.horizontalAdvance(ct); + m_contR = QRectF(x + w - cw, cy, cw, lfm.height()); + p.drawText(int(m_contR.x()), cy + lfm.ascent(), ct); + } + + void drawFileList(QPainter& p, int x, int w) { + int listH = height() - 24 - m_listTop; + p.save(); + p.setClipRect(x, m_listTop, w, listH); + + int fy = m_listTop - m_scrollY; + m_grpRects.clear(); + m_entRects.clear(); + + for (int gi = 0; gi < m_groups.size(); gi++) { + auto& g = m_groups[gi]; + if (gi > 0) fy += 15; + + // Group header + m_grpRects.append({gi, QRectF(x, fy, w, 28)}); + p.setPen(Qt::NoPen); p.setBrush(m_t.text); + int triX = x + 8, triY = fy + 11; + QPolygonF tri; + if (g.expanded) tri << QPointF(triX,triY) << QPointF(triX+6,triY) << QPointF(triX+3,triY+6); + else tri << QPointF(triX,triY) << QPointF(triX+6,triY+3) << QPointF(triX,triY+6); + p.drawPolygon(tri); + + QFont gf = font(); gf.setPixelSize(13); + p.setFont(gf); p.setPen(m_t.text); + p.drawText(triX + 14, fy + 14 + QFontMetrics(gf).ascent() / 2 - 1, g.name); + fy += 28; + + if (!g.expanded) continue; + + for (int ei : g.entries) { + auto& e = m_filtered[ei]; + QRectF er(x, fy, w, 52); + m_entRects.append({ei, er}); + if (m_hz == HZ_Entry && m_hi == ei) p.fillRect(er, m_t.hover); + + drawIcon(p, e.isExample ? ":/vsicons/book.svg" : ":/vsicons/symbol-structure.svg", + x + 24, fy + 17, 18); + + int tx = x + 52, avail = w - 64; + QFont nf = font(); nf.setPixelSize(14); + p.setFont(nf); p.setPen(m_t.text); + QFontMetrics nm(nf); + int ny = fy + 8; + p.drawText(tx, ny + nm.ascent(), + nm.elidedText(e.fileName, Qt::ElideMiddle, avail * 0.65)); + + if (!e.isExample) { + p.setPen(m_t.textDim); + QString dt = e.lastModified.toString("M/d/yyyy h:mm AP"); + p.drawText(x + w - 12 - nm.horizontalAdvance(dt), ny + nm.ascent(), dt); + } + + QFont pf = font(); pf.setPixelSize(12); + p.setFont(pf); p.setPen(m_t.textDim); + QFontMetrics pm(pf); + p.drawText(tx, ny + nm.height() + 4 + pm.ascent(), + pm.elidedText(e.dirPath, Qt::ElideMiddle, avail)); + fy += 52; + } + } + + m_contentH = fy + m_scrollY - m_listTop; + m_maxScroll = qMax(0, m_contentH - listH); + p.restore(); + } + + // ── Hit testing ── + + Hit hitTest(QPoint pos) const { + for (int i = 0; i < 5; i++) + if (m_cardR[i].contains(pos)) return {HZ_Card, i}; + if (m_contR.contains(pos)) return {HZ_Continue, 0}; + if (pos.y() >= m_listTop && pos.y() < height() - 24) { + for (const auto& [gi, r] : m_grpRects) + if (r.contains(pos)) return {HZ_Group, gi}; + for (const auto& [ei, r] : m_entRects) + if (r.contains(pos)) return {HZ_Entry, ei}; + } + return {HZ_None, -1}; + } +}; + +} // namespace rcx diff --git a/src/workspace_model.h b/src/workspace_model.h index 17a2684..4518a14 100644 --- a/src/workspace_model.h +++ b/src/workspace_model.h @@ -63,24 +63,10 @@ inline void buildProjectExplorer(QStandardItemModel* model, return QString::fromLatin1(kindToString(m.kind)); }; - // Sort structs by visible children count descending (most fields first) - auto countVisible = [&](const Entry& e) { - int n = 0; - for (int idx : e.tree->childrenOf(e.node->id)) - if (!isHexPad(e.tree->nodes[idx].kind)) ++n; - return n; - }; - auto cmpChildren = [&](const Entry& a, const Entry& b) { - int ca = countVisible(a); - int cb = countVisible(b); - if (ca != cb) return ca > cb; - return nameOf(a.node).compare(nameOf(b.node), Qt::CaseInsensitive) < 0; - }; - std::sort(types.begin(), types.end(), cmpChildren); - auto cmpName = [&](const Entry& a, const Entry& b) { - return nameOf(a.node).compare(nameOf(b.node), Qt::CaseInsensitive) < 0; - }; - std::sort(enums.begin(), enums.end(), cmpName); + // TODO: re-enable sorting once startup perf is acceptable + // auto countVisible = [&](const Entry& e) { ... }; + // std::sort(types.begin(), types.end(), cmpChildren); + // std::sort(enums.begin(), enums.end(), cmpName); for (const auto& e : types) { QVector members = e.tree->childrenOf(e.node->id);