#include "mainwindow.h" #include "providerregistry.h" #include "generator.h" #include "imports/import_reclass_xml.h" #include "imports/import_source.h" #include "imports/export_reclass_xml.h" #include "imports/import_pdb.h" #include "imports/import_pdb_dialog.h" #include "symbolstore.h" #include "symbol_downloader.h" #include "imports/pe_debug_info.h" #include "mcp/mcp_bridge.h" #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 #include #include #include "workspace_model.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "themes/thememanager.h" #include "themes/themeeditor.h" #include "optionsdialog.h" #ifdef _WIN32 #include #include #include #include #include #include static void setDarkTitleBar(QWidget* widget) { // Requires Windows 10 1809+ (build 17763) auto hwnd = reinterpret_cast(widget->winId()); BOOL dark = TRUE; // Attribute 20 = DWMWA_USE_IMMERSIVE_DARK_MODE (build 18985+), 19 for older DWORD attr = 20; if (FAILED(DwmSetWindowAttribute(hwnd, attr, &dark, sizeof(dark)))) { attr = 19; DwmSetWindowAttribute(hwnd, attr, &dark, sizeof(dark)); } } // Guard flag to prevent re-entrant crash inside the handler static volatile LONG s_inCrashHandler = 0; static LONG WINAPI crashHandler(EXCEPTION_POINTERS* ep) { // Prevent re-entrant crash: if we fault inside the handler, skip the // risky dbghelp work and just terminate with what we already printed. if (InterlockedCompareExchange(&s_inCrashHandler, 1, 0) != 0) { fprintf(stderr, "\n(re-entrant fault inside crash handler — aborting)\n"); fflush(stderr); return EXCEPTION_EXECUTE_HANDLER; } // Phase 1: always-safe output (no allocations, no complex APIs) fprintf(stderr, "\n=== UNHANDLED EXCEPTION ===\n"); fprintf(stderr, "Code : 0x%08lX\n", ep->ExceptionRecord->ExceptionCode); fprintf(stderr, "Addr : %p\n", ep->ExceptionRecord->ExceptionAddress); #ifdef _M_X64 fprintf(stderr, "RIP : 0x%016llx\n", (unsigned long long)ep->ContextRecord->Rip); fprintf(stderr, "RSP : 0x%016llx\n", (unsigned long long)ep->ContextRecord->Rsp); #else fprintf(stderr, "EIP : 0x%08lx\n", (unsigned long)ep->ContextRecord->Eip); #endif fflush(stderr); // Phase 1.5: write a full minidump next to the executable { // Build dump path: /reclass_crash_.dmp wchar_t exePath[MAX_PATH] = {}; GetModuleFileNameW(NULL, exePath, MAX_PATH); // Strip exe filename to get directory wchar_t* lastSlash = wcsrchr(exePath, L'\\'); if (lastSlash) *(lastSlash + 1) = L'\0'; SYSTEMTIME st; GetLocalTime(&st); wchar_t dumpPath[MAX_PATH]; _snwprintf_s(dumpPath, MAX_PATH, L"%sreclass_crash_%04d%02d%02d_%02d%02d%02d.dmp", exePath, st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); HANDLE hFile = CreateFileW(dumpPath, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile != INVALID_HANDLE_VALUE) { MINIDUMP_EXCEPTION_INFORMATION mei; mei.ThreadId = GetCurrentThreadId(); mei.ExceptionPointers = ep; mei.ClientPointers = FALSE; // MiniDumpWithFullMemory: captures entire process address space // so we can inspect all heap objects, Qt state, node trees, etc. BOOL ok = MiniDumpWriteDump( GetCurrentProcess(), GetCurrentProcessId(), hFile, static_cast(MiniDumpWithFullMemory | MiniDumpWithHandleData | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules), &mei, NULL, NULL); CloseHandle(hFile); if (ok) { fprintf(stderr, "Dump : %ls\n", dumpPath); } else { fprintf(stderr, "Dump : FAILED (error %lu)\n", GetLastError()); } } else { fprintf(stderr, "Dump : could not create file (error %lu)\n", GetLastError()); } fflush(stderr); } // Phase 2: attempt symbol resolution + stack walk // Copy context so StackWalk64 can mutate it safely CONTEXT ctxCopy = *ep->ContextRecord; HANDLE process = GetCurrentProcess(); HANDLE thread = GetCurrentThread(); SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME | SYMOPT_FAIL_CRITICAL_ERRORS); if (!SymInitialize(process, NULL, TRUE)) { fprintf(stderr, "\n(SymInitialize failed — no stack trace available)\n"); fprintf(stderr, "=== END CRASH ===\n"); fflush(stderr); return EXCEPTION_EXECUTE_HANDLER; } STACKFRAME64 frame = {}; DWORD machineType; #ifdef _M_X64 machineType = IMAGE_FILE_MACHINE_AMD64; frame.AddrPC.Offset = ctxCopy.Rip; frame.AddrFrame.Offset = ctxCopy.Rbp; frame.AddrStack.Offset = ctxCopy.Rsp; #else machineType = IMAGE_FILE_MACHINE_I386; frame.AddrPC.Offset = ctxCopy.Eip; frame.AddrFrame.Offset = ctxCopy.Ebp; frame.AddrStack.Offset = ctxCopy.Esp; #endif frame.AddrPC.Mode = AddrModeFlat; frame.AddrFrame.Mode = AddrModeFlat; frame.AddrStack.Mode = AddrModeFlat; fprintf(stderr, "\nStack trace:\n"); for (int i = 0; i < 64; i++) { if (!StackWalk64(machineType, process, thread, &frame, &ctxCopy, NULL, SymFunctionTableAccess64, SymGetModuleBase64, NULL)) break; if (frame.AddrPC.Offset == 0) break; char buf[sizeof(SYMBOL_INFO) + 256]; SYMBOL_INFO* sym = reinterpret_cast(buf); sym->SizeOfStruct = sizeof(SYMBOL_INFO); sym->MaxNameLen = 255; DWORD64 disp64 = 0; DWORD disp32 = 0; IMAGEHLP_LINE64 line = {}; line.SizeOfStruct = sizeof(line); bool hasSym = SymFromAddr(process, frame.AddrPC.Offset, &disp64, sym); bool hasLine = SymGetLineFromAddr64(process, frame.AddrPC.Offset, &disp32, &line); if (hasSym && hasLine) { fprintf(stderr, " [%2d] %s+0x%llx (%s:%lu)\n", i, sym->Name, (unsigned long long)disp64, line.FileName, line.LineNumber); } else if (hasSym) { fprintf(stderr, " [%2d] %s+0x%llx\n", i, sym->Name, (unsigned long long)disp64); } else { fprintf(stderr, " [%2d] 0x%llx\n", i, (unsigned long long)frame.AddrPC.Offset); } } SymCleanup(process); fprintf(stderr, "=== END CRASH ===\n"); fflush(stderr); return EXCEPTION_EXECUTE_HANDLER; } #endif class DarkApp : public QApplication { public: using QApplication::QApplication; bool notify(QObject* receiver, QEvent* event) override { if (event->type() == QEvent::WindowActivate && receiver->isWidgetType()) { auto* w = static_cast(receiver); if ((w->windowFlags() & Qt::Window) == Qt::Window && !w->property("DarkTitleBar").toBool()) { w->setProperty("DarkTitleBar", true); #ifdef _WIN32 setDarkTitleBar(w); #endif } } return QApplication::notify(receiver, event); } }; class MenuBarStyle : public QProxyStyle { public: using QProxyStyle::QProxyStyle; void polish(QWidget* w) override { #ifdef _WIN32 if (qobject_cast(w)) { w->setWindowFlag(Qt::FramelessWindowHint, true); // Layered window — gives full pixel control; DWM won't clip edges. w->setAttribute(Qt::WA_TranslucentBackground); } #endif QProxyStyle::polish(w); } using QProxyStyle::polish; QSize sizeFromContents(ContentsType type, const QStyleOption* opt, const QSize& sz, const QWidget* w) const override { QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w); if (type == CT_MenuBarItem) s.setHeight(s.height() + qRound(s.height() * 0.5)); if (type == CT_MenuItem) s = QSize(s.width() + 24, s.height() + 4); if (type == CT_ItemViewItem) s.setHeight(s.height() + 4); // Dock tab bar: fixed height, reasonable padding if (type == CT_TabBarTab) { if (auto* tabBar = qobject_cast(w)) { if (tabBar->parent() && qobject_cast(tabBar->parent())) { s.setHeight(31); } } } return s; } int pixelMetric(PixelMetric metric, const QStyleOption* opt, const QWidget* w) const override { // 1px border drawn in PE_FrameMenu if (metric == PM_MenuPanelWidth) return 1; // Inset menu items from border so hover rect doesn't touch edges if (metric == PM_MenuHMargin) return 3; if (metric == PM_MenuVMargin) return 3; // Thin draggable separator between dock widgets / central widget if (metric == PM_DockWidgetSeparatorExtent) return 1; return QProxyStyle::pixelMetric(metric, opt, w); } void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt, QPainter* p, const QWidget* w) const override { // Opaque fill + 1px border at the true widget edge. // WA_TranslucentBackground (set in polish) makes this a layered window, // so DWM doesn't clip any edges. if (elem == PE_FrameMenu) { QRect r = opt->rect; p->fillRect(r, opt->palette.color(QPalette::Window)); p->setPen(opt->palette.color(QPalette::Dark)); int x2 = r.right(), y2 = r.bottom(); p->drawLine(r.left(), r.top(), x2, r.top()); // top p->drawLine(r.left(), y2, x2, y2); // bottom p->drawLine(r.left(), r.top(), r.left(), y2); // left p->drawLine(x2, r.top(), x2, y2); // right return; } // Kill the status bar item frame and panel border if (elem == PE_FrameStatusBarItem || elem == PE_PanelStatusBar) return; // Kill Fusion's frame outline on QScintilla (window.darker(140) = ~#171717) if (elem == PE_Frame && w && w->inherits("QsciScintilla")) return; // Transparent menu bar background (no CSS needed) if (elem == PE_PanelMenuBar) return; // Item-view row background — patch Highlight so the row bg matches CE_ItemViewItem if (elem == PE_PanelItemViewRow) { if (auto* vi = qstyleoption_cast(opt)) { QStyleOptionViewItem patched = *vi; patched.palette.setColor(QPalette::Highlight, vi->palette.color(QPalette::Mid)); QProxyStyle::drawPrimitive(elem, &patched, p, w); return; } } QProxyStyle::drawPrimitive(elem, opt, p, w); } void drawControl(ControlElement element, const QStyleOption* opt, QPainter* p, const QWidget* w) const override { // Suppress Fusion's CE_MenuBarEmptyArea — it fills with palette.window() // bypassing PE_PanelMenuBar. TitleBarWidget paints the background. if (element == CE_MenuBarEmptyArea) return; // Menu bar items — fully owned painting (Fusion fills full rect, hiding border) if (element == CE_MenuBarItem) { if (auto* mi = qstyleoption_cast(opt)) { QRect area = mi->rect.adjusted(0, 0, 0, -1); // leave 1px for border bool selected = mi->state & State_Selected; bool sunken = mi->state & State_Sunken; // Only fill background for hover/pressed — non-hovered stays // transparent so the parent's border line shows through. if (sunken) p->fillRect(area, mi->palette.color(QPalette::Highlight).darker(130)); else if (selected) p->fillRect(area, mi->palette.color(QPalette::Highlight)); QColor fg = (selected || sunken) ? mi->palette.color(QPalette::Link) : mi->palette.color(QPalette::ButtonText); p->setPen(fg); p->drawText(area, Qt::AlignCenter | Qt::TextShowMnemonic, mi->text); return; // never delegate to Fusion } } // Popup menu items if (element == CE_MenuItem) { if (auto* mi = qstyleoption_cast(opt)) { // Subtle separator — single line using surface color if (mi->menuItemType == QStyleOptionMenuItem::Separator) { int y = mi->rect.center().y(); p->setPen(mi->palette.color(QPalette::AlternateBase)); p->drawLine(mi->rect.left() + 4, y, mi->rect.right() - 4, y); return; } // Hover highlight — flat fill (no Fusion border) then delegate // for text/icon/arrow with Selected cleared if ((mi->state & State_Selected)) { p->fillRect(mi->rect, mi->palette.color(QPalette::Highlight)); QStyleOptionMenuItem patched = *mi; patched.state &= ~State_Selected; patched.palette.setColor(QPalette::Text, mi->palette.color(QPalette::Link)); // theme.indHoverSpan QProxyStyle::drawControl(element, &patched, p, w); return; } } } // Item views — visible hover + themed selection (Fusion's hover is invisible on dark bg) if (element == CE_ItemViewItem) { if (auto* vi = qstyleoption_cast(opt)) { bool hovered = vi->state & State_MouseOver; bool selected = vi->state & State_Selected; if (hovered && !selected) p->fillRect(vi->rect, vi->palette.color(QPalette::Mid)); QStyleOptionViewItem patched = *vi; patched.palette.setColor(QPalette::Highlight, vi->palette.color(QPalette::Mid)); // theme.hover patched.palette.setColor(QPalette::HighlightedText, vi->palette.color(QPalette::Text)); QProxyStyle::drawControl(element, &patched, p, w); return; } } // Dock tab bar shape — background, accent line, hover, borders // (No stylesheet on dock tab bars — we handle it all here) if (element == CE_TabBarTabShape) { if (auto* tab = qstyleoption_cast(opt)) { auto* tabBar = qobject_cast(w); if (tabBar && tabBar->parent() && qobject_cast(tabBar->parent())) { bool selected = tab->state & State_Selected; bool hovered = tab->state & State_MouseOver; // Background QColor bg = tab->palette.color(QPalette::Window); // theme.background if (hovered && !selected) bg = tab->palette.color(QPalette::Mid); // theme.hover p->fillRect(tab->rect, bg); // Selected accent line on top (2px) if (selected) { p->fillRect(QRect(tab->rect.left(), tab->rect.top(), tab->rect.width(), 2), tab->palette.color(QPalette::Link)); // theme.indHoverSpan } // Bottom border (1px separator between tabs and content) p->setPen(tab->palette.color(QPalette::Dark)); // theme.border p->drawLine(tab->rect.bottomLeft(), tab->rect.bottomRight()); return; } } } // Dock tab bar label — middle-elide long names and use editor font if (element == CE_TabBarTabLabel) { if (auto* tab = qstyleoption_cast(opt)) { // Only apply to dock tab bars (parent is QMainWindow) auto* tabBar = qobject_cast(w); if (tabBar && tabBar->parent() && qobject_cast(tabBar->parent())) { // Find tab index for this rect int tabIdx = -1; for (int i = 0; i < tabBar->count(); ++i) { if (tabBar->tabRect(i).contains(tab->rect.center())) { tabIdx = i; break; } } // Leave space for pin+close buttons on right int btnWidth = 0; if (tabIdx >= 0) { auto* btn = tabBar->tabButton(tabIdx, QTabBar::RightSide); if (btn) btnWidth = btn->sizeHint().width() + 4; } QRect textRect = tab->rect.adjusted(8, 0, -(8 + btnWidth), 0); // Use editor font from settings QSettings s("Reclass", "Reclass"); QFont f(s.value("font", "JetBrains Mono").toString(), 10); f.setFixedPitch(true); p->setFont(f); QFontMetrics fm(f); // Get original (un-elided) text from the tab bar QString text = (tabIdx >= 0) ? tabBar->tabText(tabIdx) : tab->text; int maxW = textRect.width(); // Middle-elide only when text needs more than 2x the available width. // Short names like "Project" or "Modules" should never be elided // in a reasonably-sized dock panel. int textW = fm.horizontalAdvance(text); if (textW > maxW && textW > maxW * 2) { int ellipsisW = fm.horizontalAdvance(QStringLiteral("\u2026")); int avail = maxW - ellipsisW; if (avail > 0) { int half = avail / 2; QString left, right; for (int i = 0; i < text.size(); ++i) { if (fm.horizontalAdvance(text.left(i + 1)) > half) { left = text.left(i); break; } } if (left.isEmpty()) left = text.left(1); for (int i = text.size() - 1; i >= 0; --i) { if (fm.horizontalAdvance(text.mid(i)) > half) { right = text.mid(i + 1); break; } } if (right.isEmpty()) right = text.right(1); text = left + QStringLiteral("\u2026") + right; } else { text = QStringLiteral("\u2026"); } } bool selected = tab->state & State_Selected; QColor fg = selected ? tab->palette.color(QPalette::Text) : tab->palette.color(QPalette::WindowText); p->setPen(fg); p->drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft, text); return; } } } QProxyStyle::drawControl(element, opt, p, w); } }; #include "dock_tab_buttons.h" static void applyGlobalTheme(const rcx::Theme& theme) { QPalette pal; pal.setColor(QPalette::Window, theme.background); pal.setColor(QPalette::WindowText, theme.text); pal.setColor(QPalette::Base, theme.background); pal.setColor(QPalette::AlternateBase, theme.surface); pal.setColor(QPalette::Text, theme.text); pal.setColor(QPalette::Button, theme.button); pal.setColor(QPalette::ButtonText, theme.text); pal.setColor(QPalette::Highlight, theme.selected); pal.setColor(QPalette::HighlightedText, theme.text); pal.setColor(QPalette::ToolTipBase, theme.backgroundAlt); pal.setColor(QPalette::ToolTipText, theme.text); pal.setColor(QPalette::Mid, theme.hover); pal.setColor(QPalette::Dark, theme.border); pal.setColor(QPalette::Light, theme.textFaint); pal.setColor(QPalette::Link, theme.indHoverSpan); // Disabled group: Fusion reads these for disabled menu items, buttons, etc. pal.setColor(QPalette::Disabled, QPalette::WindowText, theme.textMuted); pal.setColor(QPalette::Disabled, QPalette::Text, theme.textMuted); pal.setColor(QPalette::Disabled, QPalette::ButtonText, theme.textMuted); pal.setColor(QPalette::Disabled, QPalette::HighlightedText, theme.textMuted); pal.setColor(QPalette::Disabled, QPalette::Light, theme.background); qApp->setPalette(pal); // Global scrollbar styling — track matches control bg, handle is solid qApp->setStyleSheet(QStringLiteral( "QScrollBar:vertical { background: palette(window); width: 8px; margin: 0; border: none; }" "QScrollBar::handle:vertical { background: %1; min-height: 20px; border: none; }" "QScrollBar::handle:vertical:hover { background: %2; }" "QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }" "QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; }" "QScrollBar:horizontal { background: palette(window); height: 8px; margin: 0; border: none; }" "QScrollBar::handle:horizontal { background: %1; min-width: 20px; border: none; }" "QScrollBar::handle:horizontal:hover { background: %2; }" "QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }" "QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: none; }") .arg(theme.textFaint.name(), theme.textDim.name())); } class BorderOverlay : public QWidget { public: QColor color; explicit BorderOverlay(QWidget* parent) : QWidget(parent) { setAttribute(Qt::WA_TransparentForMouseEvents); setAttribute(Qt::WA_NoSystemBackground); setFocusPolicy(Qt::NoFocus); } void paintEvent(QPaintEvent*) override { QPainter p(this); p.setPen(color); p.drawRect(0, 0, width() - 1, height() - 1); } }; namespace rcx { #ifdef __APPLE__ void applyMacTitleBarTheme(QWidget* window, const Theme& theme); #endif // MainWindow class declaration is in mainwindow.h MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { setWindowTitle("Reclass"); resize(1080, 720); #ifndef __APPLE__ // Frameless window with system menu (Alt+Space) and min/max/close support. setWindowFlags(Qt::FramelessWindowHint | Qt::WindowSystemMenuHint | Qt::WindowMinMaxButtonsHint); // Custom title bar (replaces native menu bar area in QMainWindow) m_titleBar = new TitleBarWidget(this); m_titleBar->applyTheme(ThemeManager::instance().current()); setMenuWidget(m_titleBar); m_menuBar = m_titleBar->menuBar(); #ifdef __linux__ m_menuBar->setNativeMenuBar(false); #endif #else setWindowTitle(QStringLiteral("Reclass")); setUnifiedTitleAndToolBarOnMac(true); m_menuBar = menuBar(); m_menuBar->setNativeMenuBar(true); applyMacTitleBarTheme(this, ThemeManager::instance().current()); #endif #ifdef _WIN32 // 1px top margin preserves DWM drop shadow on the frameless window { auto hwnd = reinterpret_cast(winId()); MARGINS margins = {0, 0, 1, 0}; DwmExtendFrameIntoClientArea(hwnd, &margins); } #endif // Border overlay — draws a 1px colored border on top of everything auto* overlay = new BorderOverlay(this); m_borderOverlay = overlay; overlay->color = ThemeManager::instance().current().borderFocused; overlay->setGeometry(rect()); 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); setCentralWidget(m_centralPlaceholder); setDockNestingEnabled(true); // Give left/right docks full height (corners belong to left/right, not top/bottom) setCorner(Qt::BottomLeftCorner, Qt::LeftDockWidgetArea); setCorner(Qt::TopLeftCorner, Qt::LeftDockWidgetArea); setCorner(Qt::BottomRightCorner, Qt::RightDockWidgetArea); setCorner(Qt::TopRightCorner, Qt::RightDockWidgetArea); setTabPosition(Qt::TopDockWidgetArea, QTabWidget::North); createWorkspaceDock(); createScannerDock(); createSymbolsDock(); createMenus(); if (m_titleBar) m_titleBar->finalizeMenuBar(); createStatusBar(); // Eliminate gap between central widget and status bar if (auto* ml = layout()) { ml->setSpacing(0); ml->setContentsMargins(0, 0, 0, 0); } // Separator line between central widget and status bar is killed in MenuBarStyle::drawControl // Restore menu bar title case setting (after menus are created) { QSettings s("Reclass", "Reclass"); m_menuBarTitleCase = s.value("menuBarTitleCase", false).toBool(); applyMenuBarTitleCase(m_menuBarTitleCase); if (m_titleBar && s.value("showIcon", false).toBool()) m_titleBar->setShowIcon(true); } // MenuBarStyle is set as app style in main() — covers both QMenuBar and QMenu 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(); // Start MCP bridge m_mcp = new McpBridge(this, this); if (QSettings("Reclass", "Reclass").value("autoStartMcp", true).toBool()) m_mcp->start(); // Active doc tracking is handled per dock in createTab() via visibilityChanged // Ensure border overlay is on top after initial layout settles QTimer::singleShot(0, this, [this]() { if (m_borderOverlay) { m_borderOverlay->setGeometry(rect()); m_borderOverlay->raise(); } }); // Track which split pane has focus (for menu-driven view switching) connect(qApp, &QApplication::focusChanged, this, [this](QWidget*, QWidget* now) { if (!now) return; auto* tab = activeTab(); if (!tab) return; for (int i = 0; i < tab->panes.size(); ++i) { if (tab->panes[i].tabWidget && tab->panes[i].tabWidget->isAncestorOf(now)) { tab->activePaneIdx = i; syncViewButtons(tab->panes[i].viewMode); return; } } }); } QIcon MainWindow::makeIcon(const QString& svgPath) { // Render SVG to pixmap explicitly — avoids dependency on qsvgicon plugin // which may not be deployed on Linux. QSvgRenderer renderer(svgPath); if (!renderer.isValid()) return QIcon(svgPath); QPixmap pm(32, 32); pm.fill(Qt::transparent); QPainter p(&pm); renderer.render(&p); return QIcon(pm); } template < typename...Args > inline QAction* Qt5Qt6AddAction(QMenu* menu, const QString &text, const QKeySequence &shortcut, const QIcon &icon, Args&&...args) { QAction *result = menu->addAction(icon, text); if (!shortcut.isEmpty()) result->setShortcut(shortcut); QObject::connect(result, &QAction::triggered, std::forward(args)...); return result; } void MainWindow::applyMenuBarTitleCase(bool titleCase) { m_menuBarTitleCase = titleCase; if (m_titleBar) { m_titleBar->setMenuBarTitleCase(titleCase); return; } if (!m_menuBar) return; for (QAction* action : m_menuBar->actions()) { QString text = action->text(); QString clean = text; clean.remove('&'); if (titleCase) { action->setText("&" + clean.toUpper()); } else { QString result; bool capitalizeNext = true; for (int i = 0; i < clean.length(); ++i) { QChar ch = clean[i]; if (ch.isLetter()) { result += capitalizeNext ? ch.toUpper() : ch.toLower(); capitalizeNext = false; } else { result += ch; if (ch.isSpace()) capitalizeNext = true; } } action->setText("&" + result); } } } void MainWindow::createMenus() { // File auto* file = m_menuBar->addMenu("&File"); Qt5Qt6AddAction(file, "New &Class", QKeySequence::New, QIcon(), this, &MainWindow::newClass); Qt5Qt6AddAction(file, "New &Struct", QKeySequence(Qt::CTRL | Qt::Key_T), QIcon(), this, &MainWindow::newStruct); Qt5Qt6AddAction(file, "New &Enum", QKeySequence(Qt::CTRL | Qt::Key_E), QIcon(), this, &MainWindow::newEnum); Qt5Qt6AddAction(file, "&Open...", QKeySequence::Open, makeIcon(":/vsicons/folder-opened.svg"), this, &MainWindow::openFile); m_recentFilesMenu = file->addMenu("Recent &Files"); updateRecentFilesMenu(); file->addSeparator(); Qt5Qt6AddAction(file, "&Save", QKeySequence::Save, makeIcon(":/vsicons/save.svg"), this, &MainWindow::saveFile); Qt5Qt6AddAction(file, "Save &As...", QKeySequence::SaveAs, makeIcon(":/vsicons/save-as.svg"), this, &MainWindow::saveFileAs); file->addSeparator(); auto* importMenu = file->addMenu("&Import"); Qt5Qt6AddAction(importMenu, "From &Source...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importFromSource); Qt5Qt6AddAction(importMenu, "ReClass &XML...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importReclassXml); Qt5Qt6AddAction(importMenu, "&PDB...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importPdb); auto* exportMenu = file->addMenu("E&xport"); Qt5Qt6AddAction(exportMenu, "&C++ Header...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportCpp); Qt5Qt6AddAction(exportMenu, "&Rust Structs...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportRust); Qt5Qt6AddAction(exportMenu, "#&define Offsets...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportDefines); Qt5Qt6AddAction(exportMenu, "C&# Structs...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportCSharp); Qt5Qt6AddAction(exportMenu, "&Python ctypes...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportPython); Qt5Qt6AddAction(exportMenu, "ReClass &XML...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportReclassXmlAction); // Examples submenu — scan once at init { #ifdef __APPLE__ QDir exDir(QDir::cleanPath(QCoreApplication::applicationDirPath() + "/../Resources/examples")); #else QDir exDir(QCoreApplication::applicationDirPath() + "/examples"); #endif QStringList rcxFiles = exDir.entryList({"*.rcx"}, QDir::Files, QDir::Name); if (!rcxFiles.isEmpty()) { auto* examples = file->addMenu("E&xamples"); for (const QString& fn : rcxFiles) { QString fullPath = exDir.absoluteFilePath(fn); examples->addAction(fn, this, [this, fullPath]() { project_open(fullPath); }); } } } file->addSeparator(); Qt5Qt6AddAction(file, "&Close Project", QKeySequence(Qt::CTRL | Qt::Key_W), QIcon(), this, &MainWindow::closeFile); file->addSeparator(); #ifdef _WIN32 { // "Relaunch as Administrator" — hidden when already elevated bool elevated = false; HANDLE token = nullptr; if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) { TOKEN_ELEVATION elev{}; DWORD sz = sizeof(elev); if (GetTokenInformation(token, TokenElevation, &elev, sizeof(elev), &sz)) elevated = (elev.TokenIsElevated != 0); CloseHandle(token); } if (!elevated) { Qt5Qt6AddAction(file, "Relaunch as &Administrator", QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_A), makeIcon(":/vsicons/shield.svg"), this, [this]() { wchar_t exePath[MAX_PATH]; GetModuleFileNameW(nullptr, exePath, MAX_PATH); SHELLEXECUTEINFOW sei{}; sei.cbSize = sizeof(sei); sei.lpVerb = L"runas"; sei.lpFile = exePath; sei.nShow = SW_SHOWNORMAL; if (ShellExecuteExW(&sei)) QCoreApplication::quit(); // If UAC was cancelled, do nothing }); file->addSeparator(); } } #endif m_sourceMenu = file->addMenu("&Data Source"); connect(m_sourceMenu, &QMenu::aboutToShow, this, &MainWindow::populateSourceMenu); connect(m_sourceMenu, &QMenu::triggered, this, [this](QAction* act) { auto* c = activeController(); if (!c) return; QString data = act->data().toString(); if (data.isEmpty()) return; // plugin actions handle themselves via lambda if (data == QStringLiteral("#clear")) c->clearSources(); else if (data.startsWith(QStringLiteral("#saved:"))) c->switchSource(data.mid(7).toInt()); else c->selectSource(data); }); file->addSeparator(); Qt5Qt6AddAction(file, "E&xit", QKeySequence(Qt::Key_Close), makeIcon(":/vsicons/close.svg"), this, &QMainWindow::close); // Edit auto* edit = m_menuBar->addMenu("&Edit"); Qt5Qt6AddAction(edit, "&Undo", QKeySequence::Undo, makeIcon(":/vsicons/arrow-left.svg"), this, &MainWindow::undo); Qt5Qt6AddAction(edit, "&Redo", QKeySequence::Redo, makeIcon(":/vsicons/arrow-right.svg"), this, &MainWindow::redo); // View auto* view = m_menuBar->addMenu("&View"); Qt5Qt6AddAction(view, "&Reset Windows", QKeySequence::UnknownKey, QIcon(), this, [this](bool) { // Re-tabify all doc docks into a single group (collapses splits) if (m_docDocks.isEmpty()) return; auto* first = m_docDocks.first(); for (int i = 1; i < m_docDocks.size(); ++i) { tabifyDockWidget(first, m_docDocks[i]); m_docDocks[i]->show(); } // Merge all sentinels back; keep only the first, delete extras for (int i = 0; i < m_sentinelDocks.size(); ++i) { if (i == 0) tabifyDockWidget(first, m_sentinelDocks[i]); else delete m_sentinelDocks[i]; } if (m_sentinelDocks.size() > 1) m_sentinelDocks.resize(1); if (m_activeDocDock) m_activeDocDock->raise(); QTimer::singleShot(0, this, [this]() { setupDockTabBars(); }); }); view->addSeparator(); auto* fontMenu = view->addMenu(makeIcon(":/vsicons/text-size.svg"), "&Font"); auto* fontGroup = new QActionGroup(this); fontGroup->setExclusive(true); auto* actConsolas = fontMenu->addAction("Consolas"); actConsolas->setCheckable(true); actConsolas->setActionGroup(fontGroup); auto* actJetBrains = fontMenu->addAction("JetBrains Mono"); actJetBrains->setCheckable(true); actJetBrains->setActionGroup(fontGroup); // Load saved preference QSettings settings("Reclass", "Reclass"); QString savedFont = settings.value("font", "JetBrains Mono").toString(); if (savedFont == "JetBrains Mono") actJetBrains->setChecked(true); else actConsolas->setChecked(true); connect(actConsolas, &QAction::triggered, this, [this]() { setEditorFont("Consolas"); }); connect(actJetBrains, &QAction::triggered, this, [this]() { setEditorFont("JetBrains Mono"); }); // Theme submenu auto* themeMenu = view->addMenu("&Theme"); auto* themeGroup = new QActionGroup(this); themeGroup->setExclusive(true); auto& tm = ThemeManager::instance(); auto allThemes = tm.themes(); for (int i = 0; i < allThemes.size(); i++) { auto* act = themeMenu->addAction(allThemes[i].name); act->setCheckable(true); act->setActionGroup(themeGroup); if (i == tm.currentIndex()) act->setChecked(true); connect(act, &QAction::triggered, this, [i]() { ThemeManager::instance().setCurrent(i); }); } themeMenu->addSeparator(); Qt5Qt6AddAction(themeMenu, "Edit Theme...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::editTheme); view->addSeparator(); auto* actCompact = view->addAction("Compact &Columns"); actCompact->setCheckable(true); actCompact->setChecked(settings.value("compactColumns", true).toBool()); connect(actCompact, &QAction::triggered, this, [this](bool checked) { QSettings("Reclass", "Reclass").setValue("compactColumns", checked); for (auto& tab : m_tabs) tab.ctrl->setCompactColumns(checked); }); auto* actTreeLines = view->addAction("&Tree Lines"); actTreeLines->setCheckable(true); actTreeLines->setChecked(settings.value("treeLines", true).toBool()); connect(actTreeLines, &QAction::triggered, this, [this](bool checked) { QSettings("Reclass", "Reclass").setValue("treeLines", checked); for (auto& tab : m_tabs) tab.ctrl->setTreeLines(checked); }); m_actRelOfs = view->addAction("R&elative Offsets"); m_actRelOfs->setCheckable(true); m_actRelOfs->setChecked(settings.value("relativeOffsets", true).toBool()); connect(m_actRelOfs, &QAction::triggered, this, [this](bool checked) { QSettings("Reclass", "Reclass").setValue("relativeOffsets", checked); for (auto& tab : m_tabs) for (auto& pane : tab.panes) pane.editor->setRelativeOffsets(checked); }); auto* actTypeHints = view->addAction("Type &Hints"); actTypeHints->setCheckable(true); actTypeHints->setChecked(settings.value("typeHints", false).toBool()); connect(actTypeHints, &QAction::triggered, this, [this](bool checked) { QSettings("Reclass", "Reclass").setValue("typeHints", checked); for (auto& tab : m_tabs) tab.ctrl->setTypeHints(checked); }); view->addSeparator(); view->addAction(m_workspaceDock->toggleViewAction()); { auto* scanAct = m_scannerDock->toggleViewAction(); scanAct->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_S)); view->addAction(scanAct); } { auto* symAct = m_symbolsDock->toggleViewAction(); symAct->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Y)); view->addAction(symAct); } // Tools auto* tools = m_menuBar->addMenu("&Tools"); Qt5Qt6AddAction(tools, "&Type Aliases...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::showTypeAliasesDialog); tools->addSeparator(); const auto mcpName = QSettings("Reclass", "Reclass").value("autoStartMcp", true).toBool() ? "Stop &MCP Server" : "Start &MCP Server"; m_mcpAction = Qt5Qt6AddAction(tools, mcpName, QKeySequence::UnknownKey, QIcon(), this, &MainWindow::toggleMcp); tools->addSeparator(); Qt5Qt6AddAction(tools, "&Options...", QKeySequence::UnknownKey, makeIcon(":/vsicons/settings-gear.svg"), this, static_cast(&MainWindow::showOptionsDialog)); // Plugins auto* plugins = m_menuBar->addMenu("&Plugins"); Qt5Qt6AddAction(plugins, "&Manage Plugins...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::showPluginsDialog); // Help auto* help = m_menuBar->addMenu("&Help"); Qt5Qt6AddAction(help, "&About Reclass", QKeySequence::UnknownKey, makeIcon(":/vsicons/question.svg"), this, &MainWindow::about); } // ── Themed resize grip (replaces ugly default QSizeGrip) ── // Positioned as a direct child of MainWindow at the bottom-right corner, // NOT inside the status bar layout (which is font-height dependent). class ResizeGrip : public QWidget { public: static constexpr int kSize = 16; // widget size static constexpr int kPad = 4; // padding from window corner (identical right & bottom) explicit ResizeGrip(QWidget* parent) : QWidget(parent) { setFixedSize(kSize, kSize); setCursor(Qt::SizeFDiagCursor); m_color = rcx::ThemeManager::instance().current().textFaint; } void setGripColor(const QColor& c) { m_color = c; update(); } // Call from parent's resizeEvent to pin to bottom-right corner void reposition() { QWidget* w = parentWidget(); if (w) move(w->width() - kSize - kPad, w->height() - kSize - kPad); } protected: void paintEvent(QPaintEvent*) override { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); p.setPen(Qt::NoPen); p.setBrush(m_color); // 6 dots in a triangle pointing bottom-right (VS2022 style) // Dot grid is centered within the widget: same inset from right and bottom const double r = 1.0, s = 4.0; const double inset = 4.0; double bx = width() - inset; double by = height() - inset; // bottom row: 3 dots p.drawEllipse(QPointF(bx, by), r, r); p.drawEllipse(QPointF(bx - s, by), r, r); p.drawEllipse(QPointF(bx - 2 * s, by), r, r); // middle row: 2 dots p.drawEllipse(QPointF(bx, by - s), r, r); p.drawEllipse(QPointF(bx - s, by - s), r, r); // top row: 1 dot p.drawEllipse(QPointF(bx, by - 2 * s), r, r); } void mousePressEvent(QMouseEvent* e) override { if (e->button() == Qt::LeftButton) { window()->windowHandle()->startSystemResize(Qt::BottomEdge | Qt::RightEdge); e->accept(); } } private: QColor m_color; }; // ── Dock title-bar grip (VS2022-style dot pattern) ── class DockGripWidget : public QWidget { public: explicit DockGripWidget(QWidget* parent) : QWidget(parent) { setFixedWidth(6); setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding); m_color = rcx::ThemeManager::instance().current().textFaint; } void setGripColor(const QColor& c) { m_color = c; update(); } protected: void paintEvent(QPaintEvent*) override { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); p.setPen(Qt::NoPen); p.setBrush(m_color); const double r = 0.75, s = 3.0; double cx = width() / 2.0; double cy = height() / 2.0; // 2 columns x 4 rows, centered for (int row = -2; row <= 1; row++) { p.drawEllipse(QPointF(cx - s * 0.5, cy + row * s), r, r); p.drawEllipse(QPointF(cx + s * 0.5, cy + row * s), r, r); } } private: QColor m_color; }; // ── Custom-painted view tab button (no CSS) ── class ViewTabButton : public QPushButton { public: static constexpr int kAccentH = 3; // accent line height in pixels static constexpr int kPadLR = 12; // horizontal padding static constexpr int kPadBot = 4; // extra bottom padding int baselineY = -1; // set by FlatStatusBar for cross-widget text alignment QColor colBg, colBgChecked, colBgHover, colBgPressed; QColor colText, colTextMuted, colAccent, colBorder; explicit ViewTabButton(const QString& text, QWidget* parent = nullptr) : QPushButton(text, parent) { setCheckable(true); setFlat(true); setCursor(Qt::PointingHandCursor); setContentsMargins(0, 0, 0, 0); setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Ignored); } QSize sizeHint() const override { QFontMetrics fm(font()); int w = fm.horizontalAdvance(text()) + 2 * kPadLR; int h = qRound((fm.height() + kAccentH + kPadBot) * 1.33); return QSize(w, h); } protected: void paintEvent(QPaintEvent*) override { QPainter p(this); // Background QColor bg = colBg; if (isDown()) bg = colBgPressed; else if (underMouse()) bg = colBgHover; else if (isChecked()) bg = colBgChecked; p.fillRect(rect(), bg); // Top border (continuous with status bar hairline) if (colBorder.isValid()) p.fillRect(0, 0, width(), 1, colBorder); // Accent line at y=0 when checked (paints over border) if (isChecked()) p.fillRect(0, 0, width(), kAccentH, colAccent); // Text — use shared baseline if set, otherwise fall back to VCenter p.setPen(isChecked() || underMouse() || isDown() ? colText : colTextMuted); p.setFont(font()); if (baselineY >= 0) { p.drawText(kPadLR, baselineY, text()); } else { QRect textRect(kPadLR, kAccentH, width() - 2 * kPadLR, height() - kAccentH); p.drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft, text()); } } void enterEvent(QEnterEvent*) override { update(); } void leaveEvent(QEvent*) override { update(); } }; // ── Shimmer label — gradient text sweep for MCP activity ── class ShimmerLabel : public QWidget { public: explicit ShimmerLabel(QWidget* parent = nullptr) : QWidget(parent) { m_timer.setInterval(30); connect(&m_timer, &QTimer::timeout, this, [this]() { m_phase += 0.012f; if (m_phase > 1.0f) m_phase -= 1.0f; update(); }); } void setText(const QString& t) { m_text = t; m_dimSuffix.clear(); update(); } void setText(const QString& t, const QString& dimSuffix) { m_text = t; m_dimSuffix = dimSuffix; update(); } QString text() const { return m_text; } void setShimmerActive(bool on) { if (m_shimmer == on) return; m_shimmer = on; if (on) { m_phase = 0.0f; m_timer.start(); } else { m_timer.stop(); } update(); } bool shimmerActive() const { return m_shimmer; } void setAlignment(Qt::Alignment a) { m_align = a; update(); } // Colours configurable from theme QColor colBase; // normal text QColor colDim; // dimmed suffix text QColor colBright; // highlight sweep protected: void paintEvent(QPaintEvent*) override { if (m_text.isEmpty()) return; QPainter p(this); p.setRenderHint(QPainter::TextAntialiasing); p.setFont(font()); QRect r = contentsRect(); if (!m_shimmer) { QColor c = colBase.isValid() ? colBase : palette().color(QPalette::WindowText); p.setPen(c); if (m_dimSuffix.isEmpty()) { p.drawText(r, m_align, m_text); } else { QFontMetrics fm(font()); int tw = fm.horizontalAdvance(m_text); p.drawText(r, m_align, m_text); QColor dc = colDim.isValid() ? colDim : c; p.setPen(dc); QRect sr = r; sr.setLeft(r.left() + tw); p.drawText(sr, m_align, m_dimSuffix); } return; } // Shimmer: sweeping glow band behind text + bright text QColor bright = colBright.isValid() ? colBright : QColor(255, 200, 80); // 1. Sweeping glow band (semi-transparent background highlight) qreal bandW = width() * 0.20; qreal bandCenter = -bandW + (width() + 2 * bandW) * m_phase; QLinearGradient bgGrad(bandCenter - bandW, 0, bandCenter + bandW, 0); QColor glow = bright; glow.setAlpha(35); bgGrad.setColorAt(0.0, Qt::transparent); bgGrad.setColorAt(0.5, glow); bgGrad.setColorAt(1.0, Qt::transparent); p.fillRect(rect(), QBrush(bgGrad)); // 2. Text in bright color p.setPen(bright); p.drawText(r, m_align, m_text); } private: QString m_text; QString m_dimSuffix; bool m_shimmer = false; float m_phase = 0.0f; Qt::Alignment m_align = Qt::AlignLeft | Qt::AlignVCenter; QTimer m_timer; }; // ── Borderless status bar with manual child layout ── // QStatusBarLayout hardcodes 2px margins that can't be overridden. // We bypass it entirely: children are placed manually in resizeEvent, // and addWidget() is NOT used. Instead, create children as direct // children and call manualLayout() to position them. class FlatStatusBar : public QStatusBar { public: QWidget* tabRow = nullptr; // set by createStatusBar ShimmerLabel* label = nullptr; // set by createStatusBar void setDividerColor(const QColor& c) { m_div = c; update(); } void setTopLineColor(const QColor& c) { m_top = c; update(); } explicit FlatStatusBar(QWidget* parent = nullptr) : QStatusBar(parent) { setSizeGripEnabled(false); } QSize sizeHint() const override { const int tabH = tabRow ? tabRow->sizeHint().height() : 0; const int textH = fontMetrics().height(); const int base = qMax(tabH, textH + 6); const int h = qRound(base * 1.15); return { QStatusBar::sizeHint().width(), h }; } QSize minimumSizeHint() const override { return sizeHint(); } protected: void paintEvent(QPaintEvent*) override { QPainter p(this); p.fillRect(rect(), palette().window()); // Top hairline separator if (m_top.isValid()) p.fillRect(0, 0, width(), 1, m_top); // Vertical divider between tabRow and label if (m_div.isValid() && m_divX >= 0) p.fillRect(m_divX, 4, 1, height() - 8, m_div); } void resizeEvent(QResizeEvent* e) override { QStatusBar::resizeEvent(e); manualLayout(); } void showEvent(QShowEvent* e) override { QStatusBar::showEvent(e); manualLayout(); } private: QColor m_div, m_top; int m_divX = -1; void manualLayout() { if (!label) return; const int h = height(); const int gutter = 6; if (tabRow) { const int tw = tabRow->sizeHint().width(); tabRow->setGeometry(0, 0, tw, h); m_divX = tw; label->setGeometry(tw + 1 + gutter, 0, qMax(0, width() - (tw + 1 + gutter)), h); } else { m_divX = -1; label->setGeometry(gutter, 0, qMax(0, width() - gutter), h); } label->setContentsMargins(0, 0, 0, 0); label->setAlignment(Qt::AlignVCenter | Qt::AlignLeft); } }; void MainWindow::createStatusBar() { // Replace the default QStatusBar with our borderless, manually-laid-out one. // QStatusBarLayout hardcodes 2px margins; we bypass addWidget entirely. auto* sb = new FlatStatusBar; setStatusBar(sb); m_statusLabel = new ShimmerLabel(sb); m_statusLabel->setText(""); m_statusLabel->setContentsMargins(0, 0, 0, 0); m_statusLabel->setAlignment(Qt::AlignVCenter | Qt::AlignLeft); // View toggle is now per-pane via QTabWidget tab bar (Reclass / Code tabs) sb->tabRow = nullptr; sb->label = m_statusLabel; // Grip is a direct child of the main window, NOT in the status bar layout. // Positioned via reposition() in resizeEvent — immune to font/margin changes. auto* grip = new ResizeGrip(this); grip->setObjectName("resizeGrip"); grip->raise(); { const auto& t = ThemeManager::instance().current(); QPalette sbPal = statusBar()->palette(); sbPal.setColor(QPalette::Window, t.background); sbPal.setColor(QPalette::WindowText, t.textDim); statusBar()->setPalette(sbPal); statusBar()->setAutoFillBackground(true); sb->setTopLineColor(t.border); sb->setDividerColor(t.border); m_statusLabel->colBase = t.textDim; m_statusLabel->colDim = t.textMuted; m_statusLabel->colBright = t.indHoverSpan; } // Sync status bar font to global editor font (10pt monospace) { QSettings s("Reclass", "Reclass"); QFont f(s.value("font", "JetBrains Mono").toString(), 10); f.setFixedPitch(true); m_statusLabel->setFont(f); sb->setMinimumHeight(QFontMetrics(f).height() + 6); } } void MainWindow::setAppStatus(const QString& text) { m_appStatus = text; m_appStatusDim.clear(); if (!m_mcpBusy) { m_statusLabel->setText(text); m_statusLabel->setShimmerActive(false); } } void MainWindow::setAppStatus(const QString& text, const QString& dimSuffix) { m_appStatus = text; m_appStatusDim = dimSuffix; if (!m_mcpBusy) { m_statusLabel->setText(text, dimSuffix); m_statusLabel->setShimmerActive(false); } } void MainWindow::setMcpStatus(const QString& text) { // Cancel any pending clear — new activity extends the shimmer if (m_mcpClearTimer) m_mcpClearTimer->stop(); m_mcpBusy = true; m_statusLabel->setText(text); m_statusLabel->setShimmerActive(true); } void MainWindow::clearMcpStatus() { // Delay the clear so the shimmer stays visible for at least 750ms if (!m_mcpClearTimer) { m_mcpClearTimer = new QTimer(this); m_mcpClearTimer->setSingleShot(true); connect(m_mcpClearTimer, &QTimer::timeout, this, [this]() { m_mcpBusy = false; m_statusLabel->setText(m_appStatus, m_appStatusDim); m_statusLabel->setShimmerActive(false); }); } m_mcpClearTimer->start(750); } MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) { SplitPane pane; pane.tabWidget = new QTabWidget; pane.tabWidget->setTabPosition(QTabWidget::South); pane.tabWidget->tabBar()->setVisible(true); pane.tabWidget->setDocumentMode(true); // kill QTabWidget frame border // Style to match the top dock tab bar, with accent line on selected tab { const auto& t = ThemeManager::instance().current(); QSettings s("Reclass", "Reclass"); QString editorFont = s.value("font", "JetBrains Mono").toString(); pane.tabWidget->setStyleSheet(QStringLiteral( "QTabWidget::pane { border: none; }" "QTabBar { border: none; }" "QTabBar::tab {" " background: %1; color: %2; padding: 0px 16px; border: none; border-radius: 0px; height: 26px;" " font-family: '%7'; font-size: 10pt;" "}" "QTabBar::tab:selected { color: %3; background: %4;" " border-top: 3px solid %6; padding-top: -3px; }" "QTabBar::tab:hover { color: %3; background: %5; }") .arg(t.background.name(), t.textMuted.name(), t.text.name(), t.backgroundAlt.name(), t.hover.name(), t.indHoverSpan.name(), editorFont)); } // Create editor via controller (parent = tabWidget for ownership) pane.editor = tab.ctrl->addSplitEditor(pane.tabWidget); pane.editor->setRelativeOffsets( QSettings("Reclass", "Reclass").value("relativeOffsets", true).toBool()); // Sync View menu checkbox when editor toggles offset mode (double-click / context menu) connect(pane.editor, &RcxEditor::relativeOffsetsChanged, this, [this](bool rel) { QSettings("Reclass", "Reclass").setValue("relativeOffsets", rel); if (m_actRelOfs) m_actRelOfs->setChecked(rel); // Propagate to all other editors so they stay in sync for (auto& tab : m_tabs) for (auto& p : tab.panes) if (p.editor && p.editor != sender()) p.editor->setRelativeOffsets(rel); }); pane.tabWidget->addTab(pane.editor, "Reclass"); // index 0 // Create per-pane rendered C++ view with find bar pane.renderedContainer = new QWidget; auto* rvLayout = new QVBoxLayout(pane.renderedContainer); rvLayout->setContentsMargins(0, 0, 0, 0); rvLayout->setSpacing(0); pane.rendered = new QsciScintilla; setupRenderedSci(pane.rendered); rvLayout->addWidget(pane.rendered); // Find bar with prev/next buttons (hidden by default) pane.findContainer = new QWidget; auto* fcLayout = new QHBoxLayout(pane.findContainer); fcLayout->setContentsMargins(4, 1, 4, 1); fcLayout->setSpacing(2); const auto& fbTheme = ThemeManager::instance().current(); auto* ccPrevBtn = new QToolButton; ccPrevBtn->setText(QStringLiteral("\u25C0")); ccPrevBtn->setFixedSize(24, 24); auto* ccNextBtn = new QToolButton; ccNextBtn->setText(QStringLiteral("\u25B6")); ccNextBtn->setFixedSize(24, 24); auto* ccCloseBtn = new QToolButton; ccCloseBtn->setText(QStringLiteral("\u2715")); ccCloseBtn->setFixedSize(24, 24); QString btnCss = QStringLiteral( "QToolButton { background: %1; color: %2; border: 1px solid %3; border-radius: 2px; }" "QToolButton:hover { background: %4; }" "QToolButton:pressed { background: %5; }") .arg(fbTheme.background.name(), fbTheme.text.name(), fbTheme.border.name(), fbTheme.hover.name(), fbTheme.backgroundAlt.name()); ccPrevBtn->setStyleSheet(btnCss); ccNextBtn->setStyleSheet(btnCss); ccCloseBtn->setStyleSheet(btnCss); pane.findBar = new QLineEdit; pane.findBar->setPlaceholderText("Find..."); pane.findBar->setFixedHeight(24); pane.findBar->setStyleSheet( QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;" " padding: 2px 6px; font-size: 13px; }") .arg(fbTheme.backgroundAlt.name(), fbTheme.text.name(), fbTheme.border.name())); fcLayout->addWidget(ccPrevBtn); fcLayout->addWidget(ccNextBtn); fcLayout->addWidget(ccCloseBtn); fcLayout->addWidget(pane.findBar); pane.findContainer->setVisible(false); rvLayout->addWidget(pane.findContainer); // Ctrl+F to show find bar QsciScintilla* sci = pane.rendered; QLineEdit* fb = pane.findBar; QWidget* fc = pane.findContainer; auto* findAction = new QAction(pane.renderedContainer); findAction->setShortcut(QKeySequence::Find); findAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); pane.renderedContainer->addAction(findAction); connect(findAction, &QAction::triggered, fb, [fb, fc]() { fc->setVisible(true); fb->setFocus(); fb->selectAll(); }); // Escape to hide find bar auto* escAction = new QAction(fb); escAction->setShortcut(QKeySequence(Qt::Key_Escape)); escAction->setShortcutContext(Qt::WidgetShortcut); fb->addAction(escAction); connect(escAction, &QAction::triggered, fb, [fc, sci]() { fc->setVisible(false); sci->setFocus(); }); // Search on text change and Enter connect(fb, &QLineEdit::textChanged, sci, [sci](const QString& text) { if (text.isEmpty()) return; sci->findFirst(text, false, false, false, true, true, 0, 0); }); connect(fb, &QLineEdit::returnPressed, sci, [sci, fb]() { QString text = fb->text(); if (text.isEmpty()) return; if (!sci->findNext()) sci->findFirst(text, false, false, false, true, true, 0, 0); }); connect(ccNextBtn, &QToolButton::clicked, sci, [sci, fb]() { if (!sci->findNext()) sci->findFirst(fb->text(), false, false, false, true, true, 0, 0); }); connect(ccPrevBtn, &QToolButton::clicked, sci, [sci, fb]() { QString text = fb->text(); if (text.isEmpty()) return; int line, col; sci->getCursorPosition(&line, &col); sci->findFirst(text, false, false, false, true, false, line, col); }); connect(ccCloseBtn, &QToolButton::clicked, sci, [fc, sci]() { fc->setVisible(false); sci->setFocus(); }); pane.tabWidget->addTab(pane.renderedContainer, "Code"); // index 1 // Corner widget: format combo + gear icon { const auto& ct = ThemeManager::instance().current(); QSettings cs("Reclass", "Reclass"); QString ef = cs.value("font", "JetBrains Mono").toString(); auto* cornerWidget = new QWidget; auto* cornerLayout = new QHBoxLayout(cornerWidget); cornerLayout->setContentsMargins(0, 0, 4, 0); cornerLayout->setSpacing(2); pane.fmtCombo = new QComboBox; for (int fi = 0; fi < static_cast(CodeFormat::_Count); ++fi) pane.fmtCombo->addItem(codeFormatName(static_cast(fi))); pane.fmtCombo->setCurrentIndex(cs.value("codeFormat", 0).toInt()); pane.fmtCombo->setFixedHeight(22); pane.fmtCombo->setStyleSheet(QStringLiteral( "QComboBox { background: %1; color: %2; border: 1px solid %3;" " padding: 1px 6px; font-family: '%6'; font-size: 9pt; }" "QComboBox::drop-down { border: none; width: 14px; }" "QComboBox::down-arrow { image: url(:/vsicons/chevron-down.svg);" " width: 10px; height: 10px; }" "QComboBox QAbstractItemView { background: %4; color: %2;" " selection-background-color: %5; border: 1px solid %3; }") .arg(ct.background.name(), ct.textMuted.name(), ct.border.name(), ct.backgroundAlt.name(), ct.hover.name(), ef)); pane.fmtGear = new QToolButton; pane.fmtGear->setIcon(QIcon(":/vsicons/settings-gear.svg")); pane.fmtGear->setFixedSize(22, 22); pane.fmtGear->setToolTip("Generator Options"); pane.fmtGear->setStyleSheet(QStringLiteral( "QToolButton { background: %1; color: %2; border: 1px solid %3; border-radius: 2px; }" "QToolButton:hover { background: %4; }") .arg(ct.background.name(), ct.textMuted.name(), ct.border.name(), ct.hover.name())); pane.scopeCombo = new QComboBox; for (int si = 0; si < static_cast(CodeScope::_Count); ++si) pane.scopeCombo->addItem(codeScopeName(static_cast(si))); pane.scopeCombo->setCurrentIndex(cs.value("codeScope", 0).toInt()); pane.scopeCombo->setFixedHeight(22); pane.scopeCombo->setStyleSheet(pane.fmtCombo->styleSheet()); cornerLayout->addWidget(pane.fmtCombo); cornerLayout->addWidget(pane.scopeCombo); cornerLayout->addWidget(pane.fmtGear); pane.tabWidget->setCornerWidget(cornerWidget, Qt::BottomRightCorner); cornerWidget->setVisible(false); // hidden until Code tab selected auto refreshAllRendered = [this]() { for (auto& tab : m_tabs) for (auto& p : tab.panes) if (p.viewMode == VM_Rendered) updateRenderedView(tab, p); }; connect(pane.fmtCombo, QOverload::of(&QComboBox::currentIndexChanged), this, [this, refreshAllRendered](int idx) { QSettings("Reclass", "Reclass").setValue("codeFormat", idx); refreshAllRendered(); for (auto& tab : m_tabs) for (auto& p : tab.panes) if (p.fmtCombo && p.fmtCombo->currentIndex() != idx) p.fmtCombo->setCurrentIndex(idx); }); connect(pane.scopeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, [this, refreshAllRendered](int idx) { QSettings("Reclass", "Reclass").setValue("codeScope", idx); refreshAllRendered(); for (auto& tab : m_tabs) for (auto& p : tab.panes) if (p.scopeCombo && p.scopeCombo->currentIndex() != idx) p.scopeCombo->setCurrentIndex(idx); }); connect(pane.fmtGear, &QToolButton::clicked, this, [this]() { showOptionsDialog(2); // Generator page }); } pane.tabWidget->setCurrentIndex(0); pane.viewMode = VM_Reclass; // Add to splitter tab.splitter->addWidget(pane.tabWidget); // Connect per-pane page switching (driven by status bar buttons via setViewMode) QTabWidget* tw = pane.tabWidget; connect(tw, &QTabWidget::currentChanged, this, [this, tw](int index) { SplitPane* p = findPaneByTabWidget(tw); if (!p) return; // Show/hide corner controls (format combo, scope combo, gear) if (auto* cw = tw->cornerWidget(Qt::BottomRightCorner)) cw->setVisible(index == 1); p->viewMode = (index == 1) ? VM_Rendered : VM_Reclass; // Sync status bar buttons if this is the active pane auto* tab = activeTab(); if (tab && tab->activePaneIdx >= 0 && tab->activePaneIdx < tab->panes.size() && &tab->panes[tab->activePaneIdx] == p) syncViewButtons(p->viewMode); if (index == 1) { for (auto& tab : m_tabs) { for (auto& pane : tab.panes) { if (&pane == p) { updateRenderedView(tab, pane); break; } } } } }); return pane; } MainWindow::SplitPane* MainWindow::findPaneByTabWidget(QTabWidget* tw) { for (auto& tab : m_tabs) { for (auto& pane : tab.panes) { if (pane.tabWidget == tw) return &pane; } } return nullptr; } MainWindow::SplitPane* MainWindow::findActiveSplitPane() { auto* tab = activeTab(); if (!tab || tab->panes.isEmpty()) return nullptr; int idx = qBound(0, tab->activePaneIdx, tab->panes.size() - 1); return &tab->panes[idx]; } RcxEditor* MainWindow::activePaneEditor() { auto* pane = findActiveSplitPane(); return pane ? pane->editor : nullptr; } // Event filter to manage border overlay + resize grip on floating dock widgets class DockBorderFilter : public QObject { public: BorderOverlay* border; ResizeGrip* grip; DockBorderFilter(BorderOverlay* b, ResizeGrip* g, QObject* parent) : QObject(parent), border(b), grip(g) {} bool eventFilter(QObject* obj, QEvent* ev) override { auto* dock = qobject_cast(obj); if (!dock || !dock->isFloating()) return false; if (ev->type() == QEvent::Resize) { border->setGeometry(0, 0, dock->width(), dock->height()); border->raise(); grip->reposition(); grip->raise(); } else if (ev->type() == QEvent::WindowActivate) { border->color = ThemeManager::instance().current().borderFocused; border->update(); } else if (ev->type() == QEvent::WindowDeactivate) { border->color = ThemeManager::instance().current().border; border->update(); } return false; } }; static QString rootName(const NodeTree& tree, uint64_t viewRootId = 0) { if (viewRootId != 0) { int idx = tree.indexOfId(viewRootId); if (idx >= 0) { const auto& n = tree.nodes[idx]; if (!n.structTypeName.isEmpty()) return n.structTypeName; if (!n.name.isEmpty()) return n.name; } } for (const auto& n : tree.nodes) { if (n.parentId == 0 && n.kind == NodeKind::Struct) { if (!n.structTypeName.isEmpty()) return n.structTypeName; if (!n.name.isEmpty()) return n.name; } } return QStringLiteral("Untitled"); } QString MainWindow::tabTitle(const TabState& tab) const { QString name = rootName(tab.doc->tree, tab.ctrl->viewRootId()); int srcIdx = tab.ctrl->activeSourceIndex(); const auto& sources = tab.ctrl->savedSources(); if (srcIdx >= 0 && srcIdx < sources.size()) { const auto& src = sources[srcIdx]; if (!src.displayName.isEmpty()) name += QStringLiteral(" \u2014 ") + src.displayName; } return name; } // Create a sentinel dock — invisible tab that keeps Qt's tab bar on-screen // when only 1 real dock remains in a group. QDockWidget* MainWindow::createSentinelDock() { auto* sentinel = new QDockWidget(this); sentinel->setObjectName(QStringLiteral("_sentinel_%1").arg(quintptr(sentinel), 0, 16)); sentinel->setFeatures(QDockWidget::NoDockWidgetFeatures); sentinel->setWidget(new QWidget(sentinel)); auto* stb = new QWidget(sentinel); stb->setFixedHeight(0); sentinel->setTitleBarWidget(stb); sentinel->setWindowTitle(QStringLiteral("\u200B")); m_sentinelDocks.append(sentinel); return sentinel; } QDockWidget* MainWindow::createTab(RcxDocument* doc) { auto* splitter = new QSplitter(Qt::Horizontal); splitter->setHandleWidth(1); auto* ctrl = new RcxController(doc, splitter); QString title = rootName(doc->tree); auto* dock = new QDockWidget(title, this); dock->setObjectName(QStringLiteral("DocDock_%1").arg(quintptr(dock), 0, 16)); dock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable); dock->setAttribute(Qt::WA_DeleteOnClose); // Two title bar widgets: a hidden one (docked) and a draggable one (floating) auto* emptyTitleBar = new QWidget(dock); emptyTitleBar->setFixedHeight(0); auto* floatTitleBar = new QWidget(dock); { const auto& t = ThemeManager::instance().current(); floatTitleBar->setFixedHeight(24); floatTitleBar->setAutoFillBackground(true); { QPalette tbPal = floatTitleBar->palette(); tbPal.setColor(QPalette::Window, t.backgroundAlt); floatTitleBar->setPalette(tbPal); } auto* hl = new QHBoxLayout(floatTitleBar); hl->setContentsMargins(4, 2, 2, 2); hl->setSpacing(4); auto* grip = new DockGripWidget(floatTitleBar); grip->setObjectName("dockFloatGrip"); hl->addWidget(grip); auto* lbl = new QLabel(title, floatTitleBar); lbl->setObjectName("dockFloatTitle"); { QPalette lp = lbl->palette(); lp.setColor(QPalette::WindowText, t.textDim); lbl->setPalette(lp); } { QSettings settings("Reclass", "Reclass"); QFont f(settings.value("font", "JetBrains Mono").toString(), 12); f.setFixedPitch(true); lbl->setFont(f); } hl->addWidget(lbl); hl->addStretch(); auto* closeBtn = new QToolButton(floatTitleBar); closeBtn->setObjectName("dockFloatClose"); closeBtn->setText(QStringLiteral("\u2715")); closeBtn->setAutoRaise(true); closeBtn->setCursor(Qt::PointingHandCursor); closeBtn->setStyleSheet(QStringLiteral( "QToolButton { color: %1; border: none; padding: 0px 4px 2px 4px; font-size: 12px; }" "QToolButton:hover { color: %2; }") .arg(t.textDim.name(), t.indHoverSpan.name())); connect(closeBtn, &QToolButton::clicked, dock, &QDockWidget::close); hl->addWidget(closeBtn); } floatTitleBar->setContextMenuPolicy(Qt::CustomContextMenu); connect(floatTitleBar, &QWidget::customContextMenuRequested, this, [this, dock, floatTitleBar](const QPoint& pos) { QMenu menu; menu.addAction("Dock", [dock]() { dock->setFloating(false); }); menu.addSeparator(); auto* alwaysFloat = menu.addAction("Always Floating"); alwaysFloat->setCheckable(true); bool locked = !(dock->features() & QDockWidget::DockWidgetMovable); alwaysFloat->setChecked(locked); connect(alwaysFloat, &QAction::toggled, dock, [dock](bool checked) { auto features = dock->features(); if (checked) features &= ~QDockWidget::DockWidgetMovable; else features |= QDockWidget::DockWidgetMovable; dock->setFeatures(features); }); menu.addSeparator(); menu.addAction("Close", [dock]() { dock->close(); }); menu.exec(floatTitleBar->mapToGlobal(pos)); }); dock->setTitleBarWidget(emptyTitleBar); dock->setWidget(splitter); // Border overlay and resize grip for floating state auto* dockBorder = new BorderOverlay(dock); dockBorder->color = ThemeManager::instance().current().borderFocused; dockBorder->hide(); auto* dockGrip = new ResizeGrip(dock); dockGrip->hide(); // Swap title bar when floating/docking, show/hide border + grip connect(dock, &QDockWidget::topLevelChanged, this, [this, dock, emptyTitleBar, floatTitleBar, dockBorder, dockGrip](bool floating) { dock->setTitleBarWidget(floating ? floatTitleBar : emptyTitleBar); if (floating) { dockBorder->setGeometry(0, 0, dock->width(), dock->height()); dockBorder->raise(); dockBorder->show(); dockGrip->reposition(); dockGrip->raise(); dockGrip->show(); } else { dockBorder->hide(); dockGrip->hide(); // Re-docking creates a new tab bar — reinstall pin/close buttons QTimer::singleShot(0, this, [this]() { setupDockTabBars(); }); } }); dock->installEventFilter(new DockBorderFilter(dockBorder, dockGrip, dock)); // Keep float title bar label in sync with dock title connect(dock, &QDockWidget::windowTitleChanged, floatTitleBar, [floatTitleBar](const QString& t) { if (auto* lbl = floatTitleBar->findChild("dockFloatTitle")) lbl->setText(t); }); // Tabify with existing doc docks, or add to top area if (!m_docDocks.isEmpty()) { tabifyDockWidget(m_docDocks.last(), dock); } else { addDockWidget(Qt::TopDockWidgetArea, dock); // Deferred sentinel — must wait for Qt to finish laying out the // first doc dock before tabifyDockWidget can merge them into tabs. QTimer::singleShot(0, this, [this, dock]() { if (!dock->isVisible()) return; // Check if this dock already has a sentinel (e.g. second createTab raced) for (auto* td : tabifiedDockWidgets(dock)) if (m_sentinelDocks.contains(static_cast(td))) return; auto* sentinel = createSentinelDock(); tabifyDockWidget(dock, sentinel); dock->raise(); setupDockTabBars(); }); } m_docDocks.append(dock); m_tabs[dock] = { doc, ctrl, splitter, {}, 0 }; m_activeDocDock = dock; auto& tab = m_tabs[dock]; // Create the initial split pane tab.panes.append(createSplitPane(tab)); // Apply global compact columns setting to new tab ctrl->setCompactColumns(QSettings("Reclass", "Reclass").value("compactColumns", true).toBool()); ctrl->setTreeLines(QSettings("Reclass", "Reclass").value("treeLines", true).toBool()); ctrl->setBraceWrap(QSettings("Reclass", "Reclass").value("braceWrap", false).toBool()); ctrl->setTypeHints(QSettings("Reclass", "Reclass").value("typeHints", false).toBool()); // Give every controller the shared document list for cross-tab type visibility ctrl->setProjectDocuments(&m_allDocs); rebuildAllDocs(); // Track active tab via visibility connect(dock, &QDockWidget::visibilityChanged, this, [this, dock](bool visible) { if (visible) { m_activeDocDock = dock; updateWindowTitle(); // Sync view toggle buttons to this tab's active pane auto it = m_tabs.find(dock); if (it != m_tabs.end()) { auto& tab = *it; if (tab.activePaneIdx >= 0 && tab.activePaneIdx < tab.panes.size()) syncViewButtons(tab.panes[tab.activePaneIdx].viewMode); } } // Keep border overlay on top after dock rearrangements if (m_borderOverlay) m_borderOverlay->raise(); }); // Cleanup on close connect(dock, &QObject::destroyed, this, [this, dock]() { auto it = m_tabs.find(dock); if (it != m_tabs.end()) { RcxDocument* doc = it->doc; m_tabs.erase(it); // Only delete the doc if no other tab references it bool docStillUsed = false; for (auto jt = m_tabs.begin(); jt != m_tabs.end(); ++jt) { if (jt->doc == doc) { docStillUsed = true; break; } } if (!docStillUsed) doc->deleteLater(); } m_docDocks.removeOne(dock); if (m_activeDocDock == dock) m_activeDocDock = m_docDocks.isEmpty() ? nullptr : m_docDocks.last(); rebuildAllDocs(); rebuildWorkspaceModel(); if (m_tabs.isEmpty() && !m_closingAll) project_new(); }); connect(ctrl, &RcxController::nodeSelected, this, [this, ctrl, dock](int nodeIdx) { if (nodeIdx >= 0 && nodeIdx < ctrl->document()->tree.nodes.size()) { auto& tree = ctrl->document()->tree; auto& node = tree.nodes[nodeIdx]; // Build "StructName.fieldName" — walk up to root struct QString rootName; if (node.parentId == 0) { // Root node — use its own structTypeName or name rootName = node.structTypeName.isEmpty() ? node.name : node.structTypeName; } else { // Walk up to root int cur = nodeIdx; while (cur >= 0 && tree.nodes[cur].parentId != 0) cur = tree.indexOfId(tree.nodes[cur].parentId); if (cur >= 0) { auto& root = tree.nodes[cur]; rootName = root.structTypeName.isEmpty() ? root.name : root.structTypeName; } } QString main; if (node.parentId == 0) main = rootName; else if (!rootName.isEmpty()) main = rootName + "." + node.name; else main = node.name; QString dimPart = QString(" +0x%1").arg(node.offset, 2, 16, QChar('0')); auto* ap = findActiveSplitPane(); if (ap && ap->viewMode == VM_Rendered) setAppStatus(QString("Rendered: %1").arg(main)); else setAppStatus(main, dimPart); } // Update all rendered panes on selection change auto it = m_tabs.find(dock); if (it != m_tabs.end()) updateAllRenderedPanes(*it); }); connect(ctrl, &RcxController::selectionChanged, this, [this](int count) { if (count > 1) setAppStatus(QString("%1 nodes selected").arg(count)); }); // Append Float/Close actions to any editor context menu connect(ctrl, &RcxController::contextMenuAboutToShow, this, [this, dock](QMenu* menu, int /*line*/) { menu->addSeparator(); menu->addAction(dock->isFloating() ? "Dock" : "Float", [dock]() { dock->setFloating(!dock->isFloating()); }); menu->addAction("Close Tab", [dock]() { dock->close(); }); }); // Open a new tab with a plugin-provided provider (e.g. kernel physical memory) connect(ctrl, &RcxController::requestOpenProviderTab, this, [this](const QString& pluginId, const QString& target, const QString& title) { auto* newDoc = new RcxDocument(this); QByteArray data(4096, '\0'); newDoc->loadData(data); newDoc->tree.baseAddress = 0; auto* newDock = createTab(newDoc); auto it = m_tabs.find(newDock); if (it != m_tabs.end()) { it->ctrl->attachViaPlugin(pluginId, target); // Try to load PageTables.rcx template for physical kernel tabs QString examplesPath = QCoreApplication::applicationDirPath() + QStringLiteral("/examples/PageTables.rcx"); if (QFile::exists(examplesPath)) newDoc->load(examplesPath); // Set base address from provider (template has baseAddress=0, // but we want to start at the target physical address) if (newDoc->provider) newDoc->tree.baseAddress = newDoc->provider->base(); } newDock->setWindowTitle(title); rebuildWorkspaceModelNow(); }); // Update rendered panes and workspace on document changes and undo/redo // Use QPointer to guard against dock being destroyed before deferred timer fires QPointer dockGuard = dock; connect(doc, &RcxDocument::documentChanged, this, [this, dockGuard]() { if (!dockGuard) return; auto it = m_tabs.find(dockGuard); if (it != m_tabs.end()) QTimer::singleShot(0, this, [this, dockGuard]() { if (!dockGuard) return; auto it2 = m_tabs.find(dockGuard); if (it2 != m_tabs.end()) { updateAllRenderedPanes(*it2); dockGuard->setWindowTitle(tabTitle(*it2)); } rebuildWorkspaceModel(); rebuildModulesModel(); updateWindowTitle(); }); }); // Notify MCP clients of tree changes connect(doc, &RcxDocument::documentChanged, this, [this]() { if (m_mcp) m_mcp->notifyTreeChanged(); }); connect(&doc->undoStack, &QUndoStack::indexChanged, this, [this, dockGuard](int) { if (!dockGuard) return; auto it = m_tabs.find(dockGuard); if (it != m_tabs.end()) QTimer::singleShot(0, this, [this, dockGuard]() { if (!dockGuard) return; auto it2 = m_tabs.find(dockGuard); if (it2 != m_tabs.end()) { updateAllRenderedPanes(*it2); dockGuard->setWindowTitle(tabTitle(*it2)); } updateWindowTitle(); rebuildWorkspaceModel(); }); }); // Auto-focus on first root struct (don't show all roots) for (const auto& n : doc->tree.nodes) { if (n.parentId == 0 && n.kind == NodeKind::Struct) { ctrl->setViewRootId(n.id); break; } } ctrl->refresh(); rebuildWorkspaceModel(); dock->raise(); dock->show(); // Ensure the new dock's tab is activated in the tab bar. // Since we tabify with the last dock, the new tab is always appended last. for (auto* tabBar : findChildren()) { if (tabBar->parent() != this) continue; if (tabBar->count() > 0) { tabBar->setCurrentIndex(tabBar->count() - 1); break; } } // Install context menu + pin/close buttons on dock tab bars // (deferred — tab bar created after tabification) QTimer::singleShot(0, this, [this]() { setupDockTabBars(); }); return dock; } // ── Setup dock tab bars ── // Installs pin/close buttons, context menu, font, and style on all // dock tab bars owned by this QMainWindow. Safe to call repeatedly — // skips tabs that already have buttons and tab bars that already have // a context menu. void MainWindow::setupDockTabBars() { const auto& theme = ThemeManager::instance().current(); for (auto* tabBar : findChildren()) { if (tabBar->parent() != this) continue; // No stylesheet — painting handled by MenuBarStyle tabBar->setStyleSheet(QString()); tabBar->setAttribute(Qt::WA_Hover, true); tabBar->setElideMode(Qt::ElideNone); tabBar->setExpanding(false); // Set editor font so tab width sizing matches our label painting { QSettings s("Reclass", "Reclass"); QFont tabFont(s.value("font", "JetBrains Mono").toString(), 10); tabFont.setFixedPitch(true); tabBar->setFont(tabFont); } QPalette tp = tabBar->palette(); tp.setColor(QPalette::WindowText, theme.textDim); tp.setColor(QPalette::Text, theme.text); tp.setColor(QPalette::Window, theme.background); tp.setColor(QPalette::Mid, theme.hover); tp.setColor(QPalette::Dark, theme.border); tp.setColor(QPalette::Link, theme.indHoverSpan); tabBar->setPalette(tp); // Style scroll arrows (appear when tabs overflow) for (auto* btn : tabBar->findChildren()) { if (btn->arrowType() == Qt::LeftArrow) { btn->setArrowType(Qt::NoArrow); btn->setIcon(QIcon(QStringLiteral(":/vsicons/chevron-left.svg"))); btn->setIconSize(QSize(14, 14)); } else if (btn->arrowType() == Qt::RightArrow) { btn->setArrowType(Qt::NoArrow); btn->setIcon(QIcon(QStringLiteral(":/vsicons/chevron-right.svg"))); btn->setIconSize(QSize(14, 14)); } else continue; btn->setStyleSheet(QStringLiteral( "QToolButton { background: %1; border: 1px solid %2; padding: 2px; }" "QToolButton:hover { background: %3; }") .arg(theme.background.name(), theme.border.name(), theme.hover.name())); } // Hide sentinel tabs so user sees only real doc tabs. // Qt's updateTabBar() rebuilds tabs each layout pass, resetting // visibility, so we must re-hide every call. static const QString sentinelTitle = QStringLiteral("\u200B"); for (int i = 0; i < tabBar->count(); ++i) { if (tabBar->tabText(i) == sentinelTitle) tabBar->setTabVisible(i, false); } // Helper: find any dock widget by title (doc tabs + sidebar docks) auto findDockByTitle = [this](const QString& title) -> QDockWidget* { for (auto* d : m_docDocks) if (d->windowTitle() == title) return d; for (auto* d : {m_workspaceDock, m_scannerDock, m_symbolsDock}) if (d && d->windowTitle() == title) return d; return nullptr; }; // Install tab buttons for any tab that doesn't have them yet for (int i = 0; i < tabBar->count(); ++i) { if (tabBar->tabText(i) == sentinelTitle) continue; auto* existing = qobject_cast( tabBar->tabButton(i, QTabBar::RightSide)); if (existing) continue; auto* btns = new DockTabButtons(tabBar); btns->applyTheme(theme.hover); // Find dock by matching tab title (doc tabs + sidebar docks) QDockWidget* target = findDockByTitle(tabBar->tabText(i)); if (target) { connect(btns->closeBtn, &QToolButton::clicked, target, &QDockWidget::close); } tabBar->setTabButton(i, QTabBar::RightSide, btns); } // Middle-click close + context menu (install only once) if (tabBar->contextMenuPolicy() == Qt::CustomContextMenu) continue; tabBar->installEventFilter(this); tabBar->setContextMenuPolicy(Qt::CustomContextMenu); connect(tabBar, &QTabBar::customContextMenuRequested, this, [this, tabBar](const QPoint& pos) { int idx = tabBar->tabAt(pos); if (idx < 0) return; // Find target dock (doc tabs + sidebar docks) QString tabTitle = tabBar->tabText(idx); QDockWidget* target = nullptr; for (auto* d : m_docDocks) if (d->windowTitle() == tabTitle) { target = d; break; } if (!target) { for (auto* d : {m_workspaceDock, m_scannerDock, m_symbolsDock}) if (d && d->windowTitle() == tabTitle) { target = d; break; } } if (!target) return; bool isDocDock = m_docDocks.contains(target); QMenu menu; // Close menu.addAction(makeIcon(":/vsicons/close.svg"), "Close", [target]() { target->close(); }); // Doc-only actions if (isDocDock) { auto tabIt = m_tabs.find(target); menu.addSeparator(); // Close All Tabs menu.addAction(makeIcon(":/vsicons/close-all.svg"), "Close All Tabs", [this]() { closeAllDocDocks(); }); // Close All But This if (m_docDocks.size() > 1) { menu.addAction("Close All But This", [this, target]() { auto docks = m_docDocks; for (auto* d : docks) if (d != target) d->close(); }); } menu.addSeparator(); // Copy Full Path / Open Containing Folder (only if saved) if (tabIt != m_tabs.end() && !tabIt->doc->filePath.isEmpty()) { QString path = tabIt->doc->filePath; menu.addAction(makeIcon(":/vsicons/clippy.svg"), "Copy Full Path", [path]() { QGuiApplication::clipboard()->setText(path); }); menu.addAction(makeIcon(":/vsicons/folder-opened.svg"), "Open Containing Folder", [path]() { QDesktopServices::openUrl( QUrl::fromLocalFile(QFileInfo(path).absolutePath())); }); } } menu.addSeparator(); // Float / Dock menu.addAction(target->isFloating() ? "Dock" : "Float", [target]() { target->setFloating(!target->isFloating()); }); // New Document Groups (doc tabs only, >1 visible tab) if (isDocDock) { menu.addSeparator(); int visibleTabs = 0; for (int i = 0; i < tabBar->count(); ++i) if (tabBar->isTabVisible(i)) ++visibleTabs; if (visibleTabs > 1) { menu.addAction(makeIcon(":/vsicons/split-horizontal.svg"), "New Horizontal Document Group", [this, target]() { Qt::DockWidgetArea area = dockWidgetArea(target); if (area == Qt::NoDockWidgetArea) area = Qt::TopDockWidgetArea; removeDockWidget(target); addDockWidget(area, target, Qt::Horizontal); target->show(); QList docks; QList sizes; for (auto* d : m_docDocks) { if (!d->isFloating() && d->isVisible() && dockWidgetArea(d) == area) { docks.append(d); sizes.append(width() / 2); } } if (docks.size() >= 2) resizeDocks(docks, sizes, Qt::Horizontal); QTimer::singleShot(0, this, [this, target]() { auto* s = createSentinelDock(); tabifyDockWidget(target, s); target->raise(); QTimer::singleShot(0, this, [this]() { setupDockTabBars(); }); }); }); menu.addAction(makeIcon(":/vsicons/split-vertical.svg"), "New Vertical Document Group", [this, target]() { Qt::DockWidgetArea area = dockWidgetArea(target); if (area == Qt::NoDockWidgetArea) area = Qt::TopDockWidgetArea; removeDockWidget(target); addDockWidget(area, target, Qt::Vertical); target->show(); QList docks; QList sizes; for (auto* d : m_docDocks) { if (!d->isFloating() && d->isVisible() && dockWidgetArea(d) == area) { docks.append(d); sizes.append(height() / 2); } } if (docks.size() >= 2) resizeDocks(docks, sizes, Qt::Vertical); QTimer::singleShot(0, this, [this, target]() { auto* s = createSentinelDock(); tabifyDockWidget(target, s); target->raise(); QTimer::singleShot(0, this, [this]() { setupDockTabBars(); }); }); }); } } menu.exec(tabBar->mapToGlobal(pos)); }); } } bool MainWindow::eventFilter(QObject* obj, QEvent* event) { if (event->type() == QEvent::MouseButtonPress) { auto* me = static_cast(event); if (me->button() == Qt::MiddleButton) { if (auto* tabBar = qobject_cast(obj)) { int idx = tabBar->tabAt(me->pos()); if (idx >= 0) { QString title = tabBar->tabText(idx); for (auto* d : m_docDocks) { if (d->windowTitle() == title) { d->close(); return true; } } for (auto* d : {m_workspaceDock, m_scannerDock, m_symbolsDock}) { if (d && d->windowTitle() == title) { d->close(); return true; } } return true; } } } } return QMainWindow::eventFilter(obj, event); } // Build a minimal empty struct for new documents static int s_classCounter = 0; static void buildEmptyStruct(NodeTree& tree, const QString& classKeyword = QString()) { // ── Enum: bare node with empty enumMembers, no hex children ── if (classKeyword == QStringLiteral("enum")) { int idx = s_classCounter++; Node root; root.kind = NodeKind::Struct; root.name = QStringLiteral("UnnamedEnum%1").arg(idx); root.structTypeName = root.name; root.classKeyword = classKeyword; root.parentId = 0; root.offset = 0; root.enumMembers = { {QStringLiteral("Member0"), 0}, {QStringLiteral("Member1"), 1}, {QStringLiteral("Member2"), 2}, {QStringLiteral("Member3"), 3}, {QStringLiteral("Member4"), 4}, }; tree.addNode(root); return; } int idx = s_classCounter++; Node root; root.kind = NodeKind::Struct; root.name = QStringLiteral("instance%1").arg(idx); root.structTypeName = QStringLiteral("UnnamedClass%1").arg(idx); root.classKeyword = classKeyword; root.parentId = 0; root.offset = 0; int ri = tree.addNode(root); uint64_t rootId = tree.nodes[ri].id; for (int i = 0; i < 16; i++) { Node n; n.kind = NodeKind::Hex64; n.name = QStringLiteral("field_%1").arg(i * 8, 2, 16, QChar('0')); n.parentId = rootId; n.offset = i * 8; tree.addNode(n); } // Default project: add an example enum and a class with a union if (classKeyword.isEmpty()) { // ── Example enum: _POOL_TYPE ── { Node e; e.kind = NodeKind::Struct; e.name = QStringLiteral("_POOL_TYPE"); e.structTypeName = QStringLiteral("_POOL_TYPE"); e.classKeyword = QStringLiteral("enum"); e.parentId = 0; e.collapsed = false; e.enumMembers = { {QStringLiteral("NonPagedPool"), 0}, {QStringLiteral("PagedPool"), 1}, {QStringLiteral("NonPagedPoolMustSucceed"), 2}, {QStringLiteral("DontUseThisType"), 3}, {QStringLiteral("NonPagedPoolCacheAligned"), 4}, {QStringLiteral("PagedPoolCacheAligned"), 5}, }; tree.addNode(e); } } } MainWindow::~MainWindow() { /* * When MainWindow is destroyed: * * 1. ~MainWindow() runs (our code — plugin DLLs still loaded) * 2. MainWindow member variables are destroyed (m_pluginManager — unloads plugin DLLs) * 3. QObject::~QObject() runs — destroys child widgets (QMdiSubWindow → RcxController → ~RcxController()) * */ // Disconnect all signals before members are torn down, // so the lambdas capturing 'this' never fire on a half-destroyed object. for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) { disconnect(it.key(), &QObject::destroyed, this, nullptr); disconnect(&it->doc->undoStack, nullptr, this, nullptr); // Release providers now while plugin DLLs are still loaded; // if deferred to Qt child cleanup the DLL code may already be unloaded. it->doc->provider.reset(); it->ctrl->resetProvider(); } m_tabs.clear(); } void MainWindow::newClass() { project_new(QStringLiteral("class")); } void MainWindow::newStruct() { project_new(); } void MainWindow::newEnum() { project_new(QStringLiteral("enum")); } static void buildEditorDemo(NodeTree& tree, uintptr_t editorAddr) { tree.nodes.clear(); tree.invalidateIdCache(); tree.m_nextId = 1; tree.baseAddress = static_cast(editorAddr); // ── Root struct: RcxEditor ── Node root; root.kind = NodeKind::Struct; root.name = QStringLiteral("editor"); root.structTypeName = QStringLiteral("RcxEditor"); root.classKeyword = QStringLiteral("class"); int ri = tree.addNode(root); uint64_t rootId = tree.nodes[ri].id; // ── VTable struct definition (separate root) ── Node vtStruct; vtStruct.kind = NodeKind::Struct; vtStruct.name = QStringLiteral("VTable"); vtStruct.structTypeName = QStringLiteral("QWidgetVTable"); int vti = tree.addNode(vtStruct); uint64_t vtId = tree.nodes[vti].id; // VTable entries — these are real virtual function pointers from QObject/QWidget static const char* vfNames[] = { "deleting_dtor", "metaObject", "qt_metacast", "qt_metacall", "event", "eventFilter", "timerEvent", "childEvent", "customEvent", "connectNotify", "disconnectNotify", "devType", "setVisible", "sizeHint", "minimumSizeHint", "heightForWidth", }; for (int i = 0; i < 16; i++) { Node fn; fn.kind = NodeKind::FuncPtr64; fn.name = QString::fromLatin1(vfNames[i]); fn.parentId = vtId; fn.offset = i * 8; tree.addNode(fn); } // ── RcxEditor fields ── // offset 0: vtable pointer → QWidgetVTable { Node n; n.kind = NodeKind::Pointer64; n.name = QStringLiteral("__vptr"); n.parentId = rootId; n.offset = 0; n.refId = vtId; tree.addNode(n); } // offset 8: QObjectData* d_ptr (QObject internals) { Node n; n.kind = NodeKind::Pointer64; n.name = QStringLiteral("d_ptr"); n.parentId = rootId; n.offset = 8; tree.addNode(n); } // The rest of the object: raw memory visible as Hex64 fields // QWidget base is large (~200+ bytes), then RcxEditor members follow. // Lay out enough to cover the interesting editor state. for (int off = 16; off < 512; off += 8) { Node n; n.kind = NodeKind::Hex64; n.name = QStringLiteral("field_%1").arg(off, 3, 16, QLatin1Char('0')); n.parentId = rootId; n.offset = off; tree.addNode(n); } // ── Example enum: _POOL_TYPE ── { Node e; e.kind = NodeKind::Struct; e.name = QStringLiteral("_POOL_TYPE"); e.structTypeName = QStringLiteral("_POOL_TYPE"); e.classKeyword = QStringLiteral("enum"); e.parentId = 0; e.collapsed = false; e.enumMembers = { {QStringLiteral("NonPagedPool"), 0}, {QStringLiteral("PagedPool"), 1}, {QStringLiteral("NonPagedPoolMustSucceed"), 2}, {QStringLiteral("DontUseThisType"), 3}, {QStringLiteral("NonPagedPoolCacheAligned"), 4}, {QStringLiteral("PagedPoolCacheAligned"), 5}, }; tree.addNode(e); } } void MainWindow::selfTest() { #ifdef Q_OS_WIN // Tab 2: Editor demo with live process memory (created first) project_new(); auto* ctrl = activeController(); if (!ctrl || ctrl->editors().isEmpty()) return; auto* editor = ctrl->editors().first(); auto* doc = ctrl->document(); // Build a tree describing RcxEditor, based at the real object address buildEditorDemo(doc->tree, reinterpret_cast(editor)); // Attach process memory to self — provider base will be set to the editor address DWORD pid = GetCurrentProcessId(); QString target = QString("%1:Reclass.exe").arg(pid); ctrl->attachViaPlugin(QStringLiteral("processmemory"), target); // Tab 1: Empty class for user work (created second, becomes active) auto* userTab = project_new(QStringLiteral("class")); userTab->raise(); userTab->show(); #else project_new(); auto* userTab = project_new(QStringLiteral("class")); userTab->raise(); userTab->show(); #endif } void MainWindow::openFile() { project_open(); } void MainWindow::saveFile() { project_save(nullptr, false); } void MainWindow::saveFileAs() { project_save(nullptr, true); } void MainWindow::closeFile() { project_close(); } void MainWindow::addNode() { auto* ctrl = activeController(); if (!ctrl) return; uint64_t parentId = ctrl->viewRootId(); // default to current view root auto* primary = activePaneEditor(); if (primary && primary->isEditing()) return; if (primary) { int ni = primary->currentNodeIndex(); if (ni >= 0) { auto& node = ctrl->document()->tree.nodes[ni]; if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) parentId = node.id; else parentId = node.parentId; } } ctrl->insertNode(parentId, -1, NodeKind::Hex64, "newField"); } void MainWindow::removeNode() { auto* ctrl = activeController(); if (!ctrl) return; auto* primary = activePaneEditor(); if (primary && primary->isEditing()) return; QSet ids = ctrl->selectedIds(); QVector indices; for (uint64_t id : ids) { int idx = ctrl->document()->tree.indexOfId( id & ~(kFooterIdBit | kArrayElemBit | kArrayElemMask | kMemberBit | kMemberSubMask)); if (idx >= 0) indices.append(idx); } if (indices.size() > 1) ctrl->batchRemoveNodes(indices); else if (indices.size() == 1) ctrl->removeNode(indices.first()); } void MainWindow::changeNodeType() { auto* ctrl = activeController(); if (!ctrl) return; auto* primary = activePaneEditor(); if (!primary) return; primary->beginInlineEdit(EditTarget::Type); } void MainWindow::renameNodeAction() { auto* ctrl = activeController(); if (!ctrl) return; auto* primary = activePaneEditor(); if (!primary) return; primary->beginInlineEdit(EditTarget::Name); } void MainWindow::duplicateNodeAction() { auto* ctrl = activeController(); if (!ctrl) return; auto* primary = activePaneEditor(); if (!primary || primary->isEditing()) return; int ni = primary->currentNodeIndex(); if (ni >= 0) ctrl->duplicateNode(ni); } void MainWindow::splitView() { auto* tab = activeTab(); if (!tab) return; tab->panes.append(createSplitPane(*tab)); } void MainWindow::unsplitView() { auto* tab = activeTab(); if (!tab || tab->panes.size() <= 1) return; auto pane = tab->panes.takeLast(); tab->ctrl->removeSplitEditor(pane.editor); pane.tabWidget->deleteLater(); tab->activePaneIdx = qBound(0, tab->activePaneIdx, tab->panes.size() - 1); } void MainWindow::undo() { auto* tab = activeTab(); if (tab) tab->doc->undoStack.undo(); } void MainWindow::redo() { auto* tab = activeTab(); if (tab) tab->doc->undoStack.redo(); } void MainWindow::about() { QDialog dlg(this); dlg.setWindowTitle("About Reclass"); dlg.setFixedSize(260, 120); auto* lay = new QVBoxLayout(&dlg); lay->setContentsMargins(20, 16, 20, 16); lay->setSpacing(12); auto* buildLabel = new QLabel( QStringLiteral("" "Build " __DATE__ " " __TIME__ "") .arg(ThemeManager::instance().current().textDim.name())); buildLabel->setAlignment(Qt::AlignCenter); lay->addWidget(buildLabel); auto* ghBtn = new QPushButton("GitHub"); ghBtn->setCursor(Qt::PointingHandCursor); { const auto& t = ThemeManager::instance().current(); ghBtn->setStyleSheet(QStringLiteral( "QPushButton {" " background: %1; color: %2; border: 1px solid %3;" " border-radius: 4px; padding: 5px 16px; font-size: 12px;" "}" "QPushButton:hover { background: %4; border-color: %5; }") .arg(t.indCmdPill.name(), t.text.name(), t.border.name(), t.button.name(), t.textFaint.name())); } connect(ghBtn, &QPushButton::clicked, this, []() { QDesktopServices::openUrl(QUrl("https://github.com/IChooseYou/Reclass")); }); lay->addWidget(ghBtn, 0, Qt::AlignCenter); { QPalette dlgPal = dlg.palette(); dlgPal.setColor(QPalette::Window, ThemeManager::instance().current().background); dlg.setPalette(dlgPal); dlg.setAutoFillBackground(true); } dlg.exec(); } void MainWindow::toggleMcp() { if (m_mcp->isRunning()) { m_mcp->stop(); m_mcpAction->setText("Start &MCP Server"); setAppStatus("MCP server stopped"); } else { m_mcp->start(); m_mcpAction->setText("Stop &MCP Server"); setAppStatus("MCP server listening on pipe: ReclassMcpBridge"); } } void MainWindow::applyTheme(const Theme& theme) { applyGlobalTheme(theme); #ifdef __APPLE__ applyMacTitleBarTheme(this, theme); #endif // Dock separator is 1px via PM_DockWidgetSeparatorExtent in MenuBarStyle // 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. // 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: %1; }" "QDockWidget { border: none; }" "QDockWidget > QWidget { border: none; }").arg(theme.background.name())); // Custom title bar — applied AFTER setStyleSheet() because the MainWindow // stylesheet re-resolves descendant palettes and would reset the QMenuBar palette. if (m_titleBar) m_titleBar->applyTheme(theme); for (auto* tabBar : findChildren()) { // Only style tab bars owned directly by this QMainWindow (dock tabs), // skip ones inside SplitPane QTabWidgets etc. if (tabBar->parent() == this) { // No stylesheet — painting handled by MenuBarStyle (CE_TabBarTabShape/Label) tabBar->setStyleSheet(QString()); tabBar->setAttribute(Qt::WA_Hover, true); tabBar->setElideMode(Qt::ElideNone); tabBar->setExpanding(false); // Set editor font so tab width sizing matches our label painting { QSettings s("Reclass", "Reclass"); QFont tabFont(s.value("font", "JetBrains Mono").toString(), 10); tabFont.setFixedPitch(true); tabBar->setFont(tabFont); } QPalette tp = tabBar->palette(); tp.setColor(QPalette::WindowText, theme.textDim); tp.setColor(QPalette::Text, theme.text); tp.setColor(QPalette::Window, theme.background); tp.setColor(QPalette::Mid, theme.hover); tp.setColor(QPalette::Dark, theme.border); tp.setColor(QPalette::Link, theme.indHoverSpan); tabBar->setPalette(tp); // Update DockTabButtons theme for (int i = 0; i < tabBar->count(); ++i) { auto* btns = qobject_cast( tabBar->tabButton(i, QTabBar::RightSide)); if (btns) btns->applyTheme(theme.hover); } // Update scroll arrow styling for (auto* btn : tabBar->findChildren(QString(), Qt::FindDirectChildrenOnly)) { if (btn->icon().isNull()) continue; // skip non-arrow buttons btn->setStyleSheet(QStringLiteral( "QToolButton { background: %1; border: 1px solid %2; padding: 2px; }" "QToolButton:hover { background: %3; }") .arg(theme.background.name(), theme.border.name(), theme.hover.name())); } } } // Restyle per-pane view tab bars (Reclass / Code) { QString editorFont = QSettings("Reclass", "Reclass").value("font", "JetBrains Mono").toString(); QString paneTabStyle = QStringLiteral( "QTabWidget::pane { border: none; }" "QTabBar { border: none; }" "QTabBar::tab {" " background: %1; color: %2; padding: 0px 16px; border: none; border-radius: 0px; height: 26px;" " font-family: '%7'; font-size: 10pt;" "}" "QTabBar::tab:selected { color: %3; background: %4;" " border-top: 3px solid %6; padding-top: -3px; }" "QTabBar::tab:hover { color: %3; background: %5; }") .arg(theme.background.name(), theme.textMuted.name(), theme.text.name(), theme.backgroundAlt.name(), theme.hover.name(), theme.indHoverSpan.name(), editorFont); QString comboStyle = QStringLiteral( "QComboBox { background: %1; color: %2; border: 1px solid %3;" " padding: 1px 6px; font-family: '%6'; font-size: 9pt; }" "QComboBox::drop-down { border: none; width: 14px; }" "QComboBox::down-arrow { image: url(:/vsicons/chevron-down.svg);" " width: 10px; height: 10px; }" "QComboBox QAbstractItemView { background: %4; color: %2;" " selection-background-color: %5; border: 1px solid %3; }") .arg(theme.background.name(), theme.textMuted.name(), theme.border.name(), theme.backgroundAlt.name(), theme.hover.name(), editorFont); QString gearStyle = QStringLiteral( "QToolButton { background: %1; color: %2; border: 1px solid %3; border-radius: 2px; }" "QToolButton:hover { background: %4; }") .arg(theme.background.name(), theme.textMuted.name(), theme.border.name(), theme.hover.name()); for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) { for (auto& pane : it->panes) { if (pane.tabWidget) pane.tabWidget->setStyleSheet(paneTabStyle); if (pane.fmtCombo) pane.fmtCombo->setStyleSheet(comboStyle); if (pane.scopeCombo) pane.scopeCombo->setStyleSheet(comboStyle); if (pane.fmtGear) pane.fmtGear->setStyleSheet(gearStyle); } } } // Status bar { QPalette sbPal = statusBar()->palette(); sbPal.setColor(QPalette::Window, theme.background); sbPal.setColor(QPalette::WindowText, theme.textDim); statusBar()->setPalette(sbPal); m_statusLabel->colBase = theme.textDim; m_statusLabel->colDim = theme.textMuted; m_statusLabel->colBright = theme.indHoverSpan; } // Status bar chrome { auto* fsb = static_cast(statusBar()); fsb->setTopLineColor(theme.border); fsb->setDividerColor(theme.border); } // Resize grip (direct child of main window, not in status bar) if (auto* w = findChild("resizeGrip")) static_cast(w)->setGripColor(theme.textFaint); // Workspace tree: delegate colors, palette, stylesheet if (m_workspaceDelegate) m_workspaceDelegate->setThemeColors(theme); if (m_workspaceTree) { QPalette tp = m_workspaceTree->palette(); tp.setColor(QPalette::Text, theme.textDim); tp.setColor(QPalette::Highlight, theme.selected); tp.setColor(QPalette::HighlightedText, theme.text); m_workspaceTree->setPalette(tp); m_workspaceTree->setStyleSheet(QStringLiteral( "QTreeView { background: %1; border: none; padding-left: 4px; }" "QTreeView::branch:has-children:closed { image: url(:/vsicons/chevron-right.svg); }" "QTreeView::branch:has-children:open { image: url(:/vsicons/chevron-down.svg); }" "QTreeView::branch { width: 12px; }" "QAbstractScrollArea::corner { background: %1; border: none; }" "QHeaderView { background: %1; border: none; }" "QHeaderView::section { background: %1; border: none; }") .arg(theme.background.name())); m_workspaceTree->viewport()->update(); } if (m_workspaceSearch) { m_workspaceSearch->setStyleSheet(QStringLiteral( "QLineEdit { background: %1; color: %2;" " border: 1px solid %4;" " padding: 4px 8px 4px 2px; }" "QLineEdit:focus { border: 1px solid %5; }" "QLineEdit QToolButton { padding: 0px 8px; }" "QLineEdit QToolButton:hover { background: %3; }") .arg(theme.background.name(), theme.textDim.name(), theme.hover.name(), theme.border.name(), theme.borderFocused.name())); } // Workspace tab bar + separator theme update if (m_workspaceDock) { if (auto* tabBar = m_workspaceDock->findChild("workspaceTabBar")) { for (auto* btn : tabBar->findChildren()) { btn->setStyleSheet(QStringLiteral( "QToolButton { color: %1; border: none; border-bottom: 2px solid transparent;" " padding: 4px 0; }" "QToolButton:checked { color: %2; border-bottom: 2px solid %3; }") .arg(theme.textMuted.name(), theme.text.name(), theme.borderFocused.name())); } } if (auto* sep = m_workspaceDock->findChild("workspaceSep")) { sep->setStyleSheet(QStringLiteral("background: %1; border: none;").arg(theme.border.name())); } m_workspaceDock->setStyleSheet(QStringLiteral( "QDockWidget { border: 1px solid %1; }").arg(theme.border.name())); } // Dock titlebar: restyle via stylesheet + close button if (m_dockTitleLabel) m_dockTitleLabel->setStyleSheet( QStringLiteral("color: %1;").arg(theme.textDim.name())); if (auto* titleBar = m_workspaceDock ? m_workspaceDock->titleBarWidget() : nullptr) { QPalette tbPal = titleBar->palette(); tbPal.setColor(QPalette::Window, theme.backgroundAlt); titleBar->setPalette(tbPal); } if (m_dockCloseBtn) m_dockCloseBtn->setStyleSheet(QStringLiteral( "QToolButton { color: %1; border: none; padding: 0px 4px 2px 4px; font-size: 12px; }" "QToolButton:hover { color: %2; }") .arg(theme.textDim.name(), theme.indHoverSpan.name())); if (m_dockGrip) m_dockGrip->setGripColor(theme.textFaint); if (m_workspaceDock) m_workspaceDock->setStyleSheet(QStringLiteral( "QDockWidget { border: 1px solid %1; }").arg(theme.border.name())); // Scanner dock if (m_scannerDock) m_scannerDock->setStyleSheet(QStringLiteral( "QDockWidget { border: 1px solid %1; }").arg(theme.border.name())); if (m_scannerPanel) m_scannerPanel->applyTheme(theme); if (m_scanDockTitle) m_scanDockTitle->setStyleSheet( QStringLiteral("color: %1;").arg(theme.textDim.name())); if (auto* titleBar = m_scannerDock ? m_scannerDock->titleBarWidget() : nullptr) { QPalette tbPal = titleBar->palette(); tbPal.setColor(QPalette::Window, theme.backgroundAlt); titleBar->setPalette(tbPal); } if (m_scanDockCloseBtn) m_scanDockCloseBtn->setStyleSheet(QStringLiteral( "QToolButton { color: %1; border: none; padding: 0px 4px 2px 4px; font-size: 12px; }" "QToolButton:hover { color: %2; }") .arg(theme.textDim.name(), theme.indHoverSpan.name())); if (m_scanDockGrip) m_scanDockGrip->setGripColor(theme.textFaint); // Symbols dock if (m_symbolsDock) m_symbolsDock->setStyleSheet(QStringLiteral( "QDockWidget { border: 1px solid %1; }").arg(theme.border.name())); if (m_symDockTitle) m_symDockTitle->setStyleSheet( QStringLiteral("color: %1;").arg(theme.textDim.name())); if (auto* titleBar = m_symbolsDock ? m_symbolsDock->titleBarWidget() : nullptr) { QPalette tbPal = titleBar->palette(); tbPal.setColor(QPalette::Window, theme.backgroundAlt); titleBar->setPalette(tbPal); } if (m_symDockCloseBtn) m_symDockCloseBtn->setStyleSheet(QStringLiteral( "QToolButton { color: %1; border: none; padding: 0px 4px 2px 4px; font-size: 12px; }" "QToolButton:hover { color: %2; }") .arg(theme.textDim.name(), theme.indHoverSpan.name())); if (m_symDownloadBtn) m_symDownloadBtn->setStyleSheet(QStringLiteral( "QToolButton { border: none; padding: 2px 4px; }" "QToolButton:hover { background: %1; }") .arg(theme.hover.name())); if (m_symDockGrip) m_symDockGrip->setGripColor(theme.textFaint); if (m_symbolsSearch) { m_symbolsSearch->setStyleSheet(QStringLiteral( "QLineEdit { background: %1; color: %2;" " border: 1px solid %4;" " padding: 4px 8px 4px 2px; }" "QLineEdit:focus { border: 1px solid %5; }" "QLineEdit QToolButton { padding: 0px 8px; }" "QLineEdit QToolButton:hover { background: %3; }") .arg(theme.background.name(), theme.textDim.name(), theme.hover.name(), theme.border.name(), theme.borderFocused.name())); } if (m_symbolsTree) { QPalette tp = m_symbolsTree->palette(); tp.setColor(QPalette::Text, theme.textDim); tp.setColor(QPalette::Highlight, theme.selected); tp.setColor(QPalette::HighlightedText, theme.text); m_symbolsTree->setPalette(tp); m_symbolsTree->setStyleSheet(QStringLiteral( "QTreeView { background: %1; border: none; padding-left: 4px; }" "QTreeView::branch:has-children:closed { image: url(:/vsicons/chevron-right.svg); }" "QTreeView::branch:has-children:open { image: url(:/vsicons/chevron-down.svg); }" "QTreeView::branch { width: 12px; }" "QAbstractScrollArea::corner { background: %1; border: none; }" "QHeaderView { background: %1; border: none; }" "QHeaderView::section { background: %1; border: none; }") .arg(theme.background.name())); } if (auto* sep = m_symbolsDock ? m_symbolsDock->findChild("symbolsSep") : nullptr) { sep->setStyleSheet(QStringLiteral("background: %1; border: none;").arg(theme.border.name())); } if (m_modulesTree) { QPalette tp = m_modulesTree->palette(); tp.setColor(QPalette::Text, theme.textDim); tp.setColor(QPalette::Highlight, theme.selected); tp.setColor(QPalette::HighlightedText, theme.text); m_modulesTree->setPalette(tp); m_modulesTree->setStyleSheet(QStringLiteral( "QTreeView { background: %1; border: none; padding-left: 4px; }" "QAbstractScrollArea::corner { background: %1; border: none; }" "QHeaderView { background: %1; border: none; }" "QHeaderView::section { background: %1; border: none; }") .arg(theme.background.name())); } if (m_symTabWidget) { m_symTabWidget->setStyleSheet(QStringLiteral( "QTabWidget::pane { border: none; }" "QTabBar { background: %1; }" "QTabBar::tab { background: %1; color: %2; border: none;" " border-bottom: 2px solid transparent; padding: 4px 12px; }" "QTabBar::tab:selected { color: %3; border-bottom: 2px solid %4; }" "QTabBar::tab:hover { color: %3; }") .arg(theme.backgroundAlt.name(), theme.textMuted.name(), theme.text.name(), theme.borderFocused.name())); } // Doc dock floating title bars for (auto* dock : m_docDocks) { // The float title bar is stored alongside the empty one; find by object name for (auto* child : dock->findChildren(QString(), Qt::FindDirectChildrenOnly)) { if (auto* lbl = child->findChild("dockFloatTitle")) { // Restyle the float title bar background QPalette tbPal = child->palette(); tbPal.setColor(QPalette::Window, theme.backgroundAlt); child->setPalette(tbPal); // Label color QPalette lp = lbl->palette(); lp.setColor(QPalette::WindowText, theme.textDim); lbl->setPalette(lp); } if (auto* closeBtn = child->findChild("dockFloatClose")) { closeBtn->setStyleSheet(QStringLiteral( "QToolButton { color: %1; border: none; padding: 0px 4px 2px 4px; font-size: 12px; }" "QToolButton:hover { color: %2; }") .arg(theme.textDim.name(), theme.indHoverSpan.name())); } if (auto* gripW = child->findChild("dockFloatGrip")) { if (auto* grip = dynamic_cast(gripW)) grip->setGripColor(theme.textFaint); } } } // Rendered Code views: update lexer colors, paper, margins for (auto& tab : m_tabs) { for (auto& pane : tab.panes) { auto* sci = pane.rendered; if (!sci) continue; if (auto* lexer = qobject_cast(sci->lexer())) { lexer->setColor(theme.syntaxKeyword, QsciLexerCPP::Keyword); lexer->setColor(theme.syntaxKeyword, QsciLexerCPP::KeywordSet2); lexer->setColor(theme.syntaxNumber, QsciLexerCPP::Number); lexer->setColor(theme.syntaxString, QsciLexerCPP::DoubleQuotedString); lexer->setColor(theme.syntaxString, QsciLexerCPP::SingleQuotedString); lexer->setColor(theme.syntaxComment, QsciLexerCPP::Comment); lexer->setColor(theme.syntaxComment, QsciLexerCPP::CommentLine); lexer->setColor(theme.syntaxComment, QsciLexerCPP::CommentDoc); lexer->setColor(theme.text, QsciLexerCPP::Default); lexer->setColor(theme.text, QsciLexerCPP::Identifier); lexer->setColor(theme.syntaxPreproc, QsciLexerCPP::PreProcessor); lexer->setColor(theme.text, QsciLexerCPP::Operator); const QColor editorBg = theme.background.darker(115); for (int i = 0; i <= 127; i++) lexer->setPaper(editorBg, i); } sci->setPaper(theme.background.darker(115)); sci->setColor(theme.text); sci->setCaretForegroundColor(theme.text); sci->setCaretLineBackgroundColor(theme.hover); sci->setSelectionBackgroundColor(theme.selection); sci->setSelectionForegroundColor(theme.text); sci->setMarginsBackgroundColor(theme.backgroundAlt); sci->setMarginsForegroundColor(theme.textDim); } } } void MainWindow::editTheme() { auto& tm = ThemeManager::instance(); int idx = tm.currentIndex(); ThemeEditor dlg(idx, this); if (dlg.exec() == QDialog::Accepted) { tm.updateTheme(dlg.selectedIndex(), dlg.result()); } else { tm.revertPreview(); } } // TODO: when adding more and more options, this func becomes very clunky. Fix void MainWindow::showOptionsDialog() { showOptionsDialog(-1); } void MainWindow::showOptionsDialog(int initialPage) { auto& tm = ThemeManager::instance(); OptionsResult current; current.themeIndex = tm.currentIndex(); current.fontName = QSettings("Reclass", "Reclass").value("font", "JetBrains Mono").toString(); current.menuBarTitleCase = m_menuBarTitleCase; current.showIcon = m_titleBar ? QSettings("Reclass", "Reclass").value("showIcon", false).toBool() : false; current.autoStartMcp = QSettings("Reclass", "Reclass").value("autoStartMcp", true).toBool(); current.refreshMs = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt(); current.generatorAsserts = QSettings("Reclass", "Reclass").value("generatorAsserts", false).toBool(); current.braceWrap = QSettings("Reclass", "Reclass").value("braceWrap", false).toBool(); OptionsDialog dlg(current, this); if (initialPage >= 0) dlg.selectPage(initialPage); if (dlg.exec() != QDialog::Accepted) return; auto r = dlg.result(); if (r.themeIndex != current.themeIndex) tm.setCurrent(r.themeIndex); if (r.fontName != current.fontName) setEditorFont(r.fontName); if (r.menuBarTitleCase != current.menuBarTitleCase) { applyMenuBarTitleCase(r.menuBarTitleCase); QSettings("Reclass", "Reclass").setValue("menuBarTitleCase", r.menuBarTitleCase); } if (r.showIcon != current.showIcon) { if (m_titleBar) m_titleBar->setShowIcon(r.showIcon); QSettings("Reclass", "Reclass").setValue("showIcon", r.showIcon); } if (r.autoStartMcp != current.autoStartMcp) QSettings("Reclass", "Reclass").setValue("autoStartMcp", r.autoStartMcp); if (r.refreshMs != current.refreshMs) { QSettings("Reclass", "Reclass").setValue("refreshMs", r.refreshMs); for (auto& tab : m_tabs) tab.ctrl->setRefreshInterval(r.refreshMs); } if (r.generatorAsserts != current.generatorAsserts) QSettings("Reclass", "Reclass").setValue("generatorAsserts", r.generatorAsserts); if (r.braceWrap != current.braceWrap) { QSettings("Reclass", "Reclass").setValue("braceWrap", r.braceWrap); for (auto& tab : m_tabs) tab.ctrl->setBraceWrap(r.braceWrap); } } void MainWindow::setEditorFont(const QString& fontName) { QSettings settings("Reclass", "Reclass"); settings.setValue("font", fontName); QFont f(fontName, 12); f.setFixedPitch(true); for (auto& state : m_tabs) { state.ctrl->setEditorFont(fontName); for (auto& pane : state.panes) { // Update rendered view font if (pane.rendered) { pane.rendered->setFont(f); if (auto* lex = pane.rendered->lexer()) { lex->setFont(f); for (int i = 0; i <= 127; i++) lex->setFont(f, i); } pane.rendered->setMarginsFont(f); } } } // Sync workspace tree, title, search, and status bar font (10pt monospace) { QFont wf(fontName, 10); wf.setFixedPitch(true); if (m_workspaceTree) m_workspaceTree->setFont(wf); if (m_dockTitleLabel) m_dockTitleLabel->setFont(wf); if (m_workspaceSearch) m_workspaceSearch->setFont(wf); if (m_statusLabel) { m_statusLabel->setFont(wf); auto* fsb = static_cast(statusBar()); fsb->setMinimumHeight(QFontMetrics(wf).height() + 6); } } // Sync scanner panel font if (m_scannerPanel) m_scannerPanel->setEditorFont(f); if (m_scanDockTitle) m_scanDockTitle->setFont(f); if (m_symDockTitle) m_symDockTitle->setFont(f); if (m_symbolsSearch) m_symbolsSearch->setFont(f); if (m_symbolsTree) m_symbolsTree->setFont(f); if (m_modulesTree) m_modulesTree->setFont(f); if (m_symTabWidget) m_symTabWidget->setFont(f); // Sync doc dock float title fonts for (auto* dock : m_docDocks) { if (auto* lbl = dock->findChild("dockFloatTitle")) lbl->setFont(f); } // Update dock tab bar font so tab sizing matches label painting { QFont tabFont(fontName, 10); tabFont.setFixedPitch(true); for (auto* tabBar : findChildren()) { if (tabBar->parent() == this) { tabBar->setFont(tabFont); tabBar->update(); } } // Pane tab bars (Reclass / Code) — re-apply stylesheet with new font // (stylesheet overrides setFont, so font must be in the CSS) applyTheme(ThemeManager::instance().current()); } } RcxController* MainWindow::activeController() const { if (m_activeDocDock && m_tabs.contains(m_activeDocDock)) return m_tabs[m_activeDocDock].ctrl; return nullptr; } MainWindow::TabState* MainWindow::activeTab() { if (m_activeDocDock && m_tabs.contains(m_activeDocDock)) return &m_tabs[m_activeDocDock]; return nullptr; } MainWindow::TabState* MainWindow::tabByIndex(int index) { if (index < 0 || index >= m_docDocks.size()) return nullptr; auto* dock = m_docDocks[index]; if (m_tabs.contains(dock)) return &m_tabs[dock]; return nullptr; } void MainWindow::updateWindowTitle() { #ifdef __APPLE__ setWindowTitle(QStringLiteral("Reclass")); #else QString title; auto* activeDock = m_activeDocDock; if (activeDock && m_tabs.contains(activeDock)) { auto& tab = m_tabs[activeDock]; QString name = rootName(tab.doc->tree, tab.ctrl->viewRootId()); if (tab.doc->modified) name += " *"; title = name + " - Reclass"; } else { title = "Reclass"; } setWindowTitle(title); #endif } // ── Rendered view setup ── void MainWindow::setupRenderedSci(QsciScintilla* sci) { QSettings settings("Reclass", "Reclass"); QString fontName = settings.value("font", "JetBrains Mono").toString(); QFont f(fontName, 12); f.setFixedPitch(true); sci->setFont(f); sci->setReadOnly(false); sci->setWrapMode(QsciScintilla::WrapNone); sci->setTabWidth(4); sci->setIndentationsUseTabs(false); sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRAASCENT, (long)2); sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRADESCENT, (long)2); // Line number margin sci->setMarginType(0, QsciScintilla::NumberMargin); sci->setMarginWidth(0, "00000"); const auto& theme = ThemeManager::instance().current(); sci->setMarginsBackgroundColor(theme.backgroundAlt); sci->setMarginsForegroundColor(theme.textDim); sci->setMarginsFont(f); // Hide other margins sci->setMarginWidth(1, 0); sci->setMarginWidth(2, 0); // C++ lexer for syntax highlighting — must be set BEFORE colors below, // because setLexer() resets caret line, selection, and paper colors. auto* lexer = new QsciLexerCPP(sci); lexer->setFont(f); lexer->setColor(theme.syntaxKeyword, QsciLexerCPP::Keyword); lexer->setColor(theme.syntaxKeyword, QsciLexerCPP::KeywordSet2); lexer->setColor(theme.syntaxNumber, QsciLexerCPP::Number); lexer->setColor(theme.syntaxString, QsciLexerCPP::DoubleQuotedString); lexer->setColor(theme.syntaxString, QsciLexerCPP::SingleQuotedString); lexer->setColor(theme.syntaxComment, QsciLexerCPP::Comment); lexer->setColor(theme.syntaxComment, QsciLexerCPP::CommentLine); lexer->setColor(theme.syntaxComment, QsciLexerCPP::CommentDoc); lexer->setColor(theme.text, QsciLexerCPP::Default); lexer->setColor(theme.text, QsciLexerCPP::Identifier); lexer->setColor(theme.syntaxPreproc, QsciLexerCPP::PreProcessor); lexer->setColor(theme.text, QsciLexerCPP::Operator); const QColor editorBg = theme.background.darker(115); for (int i = 0; i <= 127; i++) { lexer->setPaper(editorBg, i); lexer->setFont(f, i); } sci->setLexer(lexer); sci->setBraceMatching(QsciScintilla::NoBraceMatch); // Colors applied AFTER setLexer() — the lexer resets these on attach sci->setPaper(editorBg); sci->setColor(theme.text); sci->setCaretForegroundColor(theme.text); sci->setCaretLineVisible(true); sci->setCaretLineBackgroundColor(theme.hover); sci->setSelectionBackgroundColor(theme.selection); sci->setSelectionForegroundColor(theme.text); } // ── View mode / generator switching ── void MainWindow::setViewMode(ViewMode mode) { auto* pane = findActiveSplitPane(); if (!pane) return; pane->viewMode = mode; int idx = (mode == VM_Rendered) ? 1 : 0; pane->tabWidget->setCurrentIndex(idx); syncViewButtons(mode); } void MainWindow::syncViewButtons(ViewMode /*mode*/) { // View toggle is now per-pane via QTabWidget tab bar — nothing to sync globally } // ── Find the root-level struct ancestor for a node ── uint64_t MainWindow::findRootStructForNode(const NodeTree& tree, uint64_t nodeId) const { QSet visited; uint64_t cur = nodeId; uint64_t lastStruct = 0; while (cur != 0 && !visited.contains(cur)) { visited.insert(cur); int idx = tree.indexOfId(cur); if (idx < 0) break; const Node& n = tree.nodes[idx]; if (n.kind == NodeKind::Struct) lastStruct = n.id; if (n.parentId == 0) return (n.kind == NodeKind::Struct) ? n.id : lastStruct; cur = n.parentId; } return lastStruct; } // ── Update the rendered view for a single pane ── void MainWindow::updateRenderedView(TabState& tab, SplitPane& pane) { if (pane.viewMode != VM_Rendered) return; if (!pane.rendered) return; // Determine which struct to render based on selection uint64_t rootId = 0; QSet selIds = tab.ctrl->selectedIds(); if (selIds.size() >= 1) { uint64_t selId = *selIds.begin(); selId &= ~(kFooterIdBit | kArrayElemBit | kArrayElemMask | kMemberBit | kMemberSubMask); rootId = findRootStructForNode(tab.doc->tree, selId); } // Fall back to the controller's current view root (set by double-click / navigation) if (rootId == 0) rootId = findRootStructForNode(tab.doc->tree, tab.ctrl->viewRootId()); // Last resort: first root-level struct in the project if (rootId == 0) { for (const auto& n : tab.doc->tree.nodes) { if (n.parentId == 0 && n.kind == rcx::NodeKind::Struct) { rootId = n.id; break; } } } // Generate text const QHash* aliases = tab.doc->typeAliases.isEmpty() ? nullptr : &tab.doc->typeAliases; bool asserts = QSettings("Reclass", "Reclass").value("generatorAsserts", false).toBool(); CodeFormat fmt = static_cast( QSettings("Reclass", "Reclass").value("codeFormat", 0).toInt()); CodeScope scope = static_cast( QSettings("Reclass", "Reclass").value("codeScope", 0).toInt()); QString text; if (scope == CodeScope::FullSdk) { text = renderCodeAll(fmt, tab.doc->tree, aliases, asserts); } else if (rootId != 0) { if (scope == CodeScope::WithChildren) text = renderCodeTree(fmt, tab.doc->tree, rootId, aliases, asserts); else text = renderCode(fmt, tab.doc->tree, rootId, aliases, asserts); } else { text = renderCodeAll(fmt, tab.doc->tree, aliases, asserts); } // Scroll restoration: save if same root, reset if different int restoreLine = 0; if (rootId != 0 && rootId == pane.lastRenderedRootId) { restoreLine = (int)pane.rendered->SendScintilla( QsciScintillaBase::SCI_GETFIRSTVISIBLELINE); } pane.lastRenderedRootId = rootId; // Set text pane.rendered->setText(text); // Set horizontal scroll width to match the longest line (ignoring trailing spaces) { int maxLen = 0; const QStringList lines = text.split(QChar('\n')); for (const auto& line : lines) { int len = (int)line.size(); while (len > 0 && line[len - 1] == QChar(' ')) --len; maxLen = std::max(maxLen, len); } QFontMetrics fm(pane.rendered->font()); int pixelWidth = fm.horizontalAdvance(QString(maxLen, QChar('0'))); pane.rendered->SendScintilla(QsciScintillaBase::SCI_SETSCROLLWIDTH, (unsigned long)qMax(1, pixelWidth)); } // Update margin width for line count int lineCount = pane.rendered->lines(); QString marginStr = QString(QString::number(lineCount).size() + 2, '0'); pane.rendered->setMarginWidth(0, marginStr); // Restore scroll if (restoreLine > 0) { pane.rendered->SendScintilla(QsciScintillaBase::SCI_SETFIRSTVISIBLELINE, (unsigned long)restoreLine); } } void MainWindow::updateAllRenderedPanes(TabState& tab) { for (auto& pane : tab.panes) { if (pane.viewMode == VM_Rendered) updateRenderedView(tab, pane); } } // ── Export C++ header to file ── void MainWindow::exportCpp() { auto* tab = activeTab(); if (!tab) return; QString path = QFileDialog::getSaveFileName(this, "Export C++ Header", {}, "C++ Header (*.h);;All Files (*)"); if (path.isEmpty()) return; const QHash* aliases = tab->doc->typeAliases.isEmpty() ? nullptr : &tab->doc->typeAliases; bool asserts = QSettings("Reclass", "Reclass").value("generatorAsserts", false).toBool(); QString text = renderCppAll(tab->doc->tree, aliases, asserts); QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { QMessageBox::warning(this, "Export Failed", "Could not write to: " + path); return; } file.write(text.toUtf8()); setAppStatus("Exported to " + QFileInfo(path).fileName()); } // ── Export Rust structs ── void MainWindow::exportRust() { auto* tab = activeTab(); if (!tab) return; QString path = QFileDialog::getSaveFileName(this, "Export Rust Structs", {}, "Rust Source (*.rs);;All Files (*)"); if (path.isEmpty()) return; const QHash* aliases = tab->doc->typeAliases.isEmpty() ? nullptr : &tab->doc->typeAliases; bool asserts = QSettings("Reclass", "Reclass").value("generatorAsserts", false).toBool(); QString text = renderRustAll(tab->doc->tree, aliases, asserts); QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { QMessageBox::warning(this, "Export Failed", "Could not write to: " + path); return; } file.write(text.toUtf8()); setAppStatus("Exported to " + QFileInfo(path).fileName()); } // ── Export #define offsets ── void MainWindow::exportDefines() { auto* tab = activeTab(); if (!tab) return; QString path = QFileDialog::getSaveFileName(this, "Export #define Offsets", {}, "C Header (*.h);;All Files (*)"); if (path.isEmpty()) return; QString text = renderDefinesAll(tab->doc->tree); QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { QMessageBox::warning(this, "Export Failed", "Could not write to: " + path); return; } file.write(text.toUtf8()); setAppStatus("Exported to " + QFileInfo(path).fileName()); } // ── Export C# structs ── void MainWindow::exportCSharp() { auto* tab = activeTab(); if (!tab) return; QString path = QFileDialog::getSaveFileName(this, "Export C# Structs", {}, "C# Source (*.cs);;All Files (*)"); if (path.isEmpty()) return; const QHash* aliases = tab->doc->typeAliases.isEmpty() ? nullptr : &tab->doc->typeAliases; bool asserts = QSettings("Reclass", "Reclass").value("generatorAsserts", false).toBool(); QString text = renderCSharpAll(tab->doc->tree, aliases, asserts); QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { QMessageBox::warning(this, "Export Failed", "Could not write to: " + path); return; } file.write(text.toUtf8()); setAppStatus("Exported to " + QFileInfo(path).fileName()); } // ── Export Python ctypes ── void MainWindow::exportPython() { auto* tab = activeTab(); if (!tab) return; QString path = QFileDialog::getSaveFileName(this, "Export Python ctypes", {}, "Python Source (*.py);;All Files (*)"); if (path.isEmpty()) return; QString text = renderPythonAll(tab->doc->tree); QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { QMessageBox::warning(this, "Export Failed", "Could not write to: " + path); return; } file.write(text.toUtf8()); setAppStatus("Exported to " + QFileInfo(path).fileName()); } // ── Export ReClass XML ── void MainWindow::exportReclassXmlAction() { auto* tab = activeTab(); if (!tab) return; QString path = QFileDialog::getSaveFileName(this, "Export ReClass XML", {}, "ReClass XML (*.reclass);;All Files (*)"); if (path.isEmpty()) return; QString error; if (!rcx::exportReclassXml(tab->doc->tree, path, &error)) { QMessageBox::warning(this, "Export Failed", error.isEmpty() ? QStringLiteral("Could not export") : error); return; } int classCount = 0; for (const auto& n : tab->doc->tree.nodes) if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++; setAppStatus(QStringLiteral("Exported %1 classes to %2") .arg(classCount).arg(QFileInfo(path).fileName())); } // ── Import ReClass XML ── void MainWindow::importReclassXml() { QString filePath = QFileDialog::getOpenFileName(this, "Import ReClass XML", {}, "ReClass XML (*.reclass *.MemeCls *.xml);;All Files (*)"); if (filePath.isEmpty()) return; QString error; NodeTree tree = rcx::importReclassXml(filePath, &error); if (tree.nodes.isEmpty()) { QMessageBox::warning(this, "Import Failed", error.isEmpty() ? QStringLiteral("No data found in file") : error); return; } // Count root structs for status message int classCount = 0; for (const auto& n : tree.nodes) if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++; auto* doc = new RcxDocument(this); doc->tree = std::move(tree); { ClosingGuard guard(m_closingAll); closeAllDocDocks(); createTab(doc); } rebuildWorkspaceModel(); setAppStatus(QStringLiteral("Imported %1 classes from %2") .arg(classCount).arg(QFileInfo(filePath).fileName())); } // ── Import from Source ── void MainWindow::importFromSource() { QDialog dlg(this); dlg.setWindowTitle("Import from Source"); dlg.resize(700, 600); auto* layout = new QVBoxLayout(&dlg); auto* sci = new QsciScintilla(&dlg); setupRenderedSci(sci); sci->setReadOnly(false); sci->setMarginWidth(0, "00000"); layout->addWidget(sci); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dlg); buttons->button(QDialogButtonBox::Ok)->setText("Import"); layout->addWidget(buttons); connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept); connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); if (dlg.exec() != QDialog::Accepted) return; QString source = sci->text(); if (source.trimmed().isEmpty()) return; QString error; NodeTree tree = rcx::importFromSource(source, &error); if (tree.nodes.isEmpty()) { QMessageBox::warning(this, "Import Failed", error.isEmpty() ? QStringLiteral("No struct definitions found") : error); return; } int classCount = 0; for (const auto& n : tree.nodes) if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++; auto* doc = new RcxDocument(this); doc->tree = std::move(tree); { ClosingGuard guard(m_closingAll); closeAllDocDocks(); createTab(doc); } rebuildWorkspaceModel(); if (!m_docDocks.isEmpty()) { splitDockWidget(m_workspaceDock, m_docDocks.first(), Qt::Horizontal); resizeDocks({m_workspaceDock}, {128}, Qt::Horizontal); } m_workspaceDock->show(); setAppStatus(QStringLiteral("Imported %1 classes from source").arg(classCount)); } // ── Import PDB ── void MainWindow::importPdb() { rcx::PdbImportDialog dlg(this); if (dlg.exec() != QDialog::Accepted) return; QString pdbPath = dlg.pdbPath(); // Always load symbols into the SymbolStore when importing a PDB { QString symErr; auto symResult = rcx::extractPdbSymbols(pdbPath, &symErr); if (!symResult.symbols.isEmpty()) { QVector> symPairs; symPairs.reserve(symResult.symbols.size()); for (const auto& s : symResult.symbols) symPairs.emplaceBack(s.name, s.rva); int symCount = rcx::SymbolStore::instance().addModule( symResult.moduleName, pdbPath, symPairs); if (symCount > 0) setAppStatus(QStringLiteral("Loaded %1 symbols from %2") .arg(symCount).arg(QFileInfo(pdbPath).fileName())); } rebuildSymbolsModel(); } QVector indices = dlg.selectedTypeIndices(); if (indices.isEmpty()) return; QProgressDialog progress("Importing types...", "Cancel", 0, indices.size(), this); progress.setWindowModality(Qt::WindowModal); progress.setMinimumDuration(200); bool cancelled = false; QString error; NodeTree tree = rcx::importPdbSelected(pdbPath, indices, &error, [&](int current, int total) -> bool { progress.setMaximum(total); progress.setValue(current); QApplication::processEvents(); if (progress.wasCanceled()) { cancelled = true; return false; } return true; }); progress.close(); if (tree.nodes.isEmpty()) { if (!cancelled) QMessageBox::warning(this, "Import Failed", error.isEmpty() ? QStringLiteral("No types imported") : error); return; } int classCount = 0; for (const auto& n : tree.nodes) if (n.parentId == 0 && n.kind == rcx::NodeKind::Struct) classCount++; auto* doc = new rcx::RcxDocument(this); doc->tree = std::move(tree); { ClosingGuard guard(m_closingAll); closeAllDocDocks(); createTab(doc); } rebuildWorkspaceModel(); if (!m_docDocks.isEmpty()) { splitDockWidget(m_workspaceDock, m_docDocks.first(), Qt::Horizontal); resizeDocks({m_workspaceDock}, {128}, Qt::Horizontal); } m_workspaceDock->show(); setAppStatus(QStringLiteral("Imported %1 classes from %2") .arg(classCount).arg(QFileInfo(pdbPath).fileName())); } // ── Type Aliases Dialog ── void MainWindow::showTypeAliasesDialog() { auto* tab = activeTab(); if (!tab) return; QDialog dlg(this); dlg.setWindowTitle("Type Aliases"); dlg.resize(400, 380); auto* layout = new QVBoxLayout(&dlg); // Preset buttons (stdint + Windows only, no redundant Reset) auto* presetRow = new QHBoxLayout; auto* btnStdint = new QPushButton("stdint (C99)", &dlg); auto* btnWindows = new QPushButton("Windows (basetsd.h)", &dlg); presetRow->addWidget(btnStdint); presetRow->addWidget(btnWindows); presetRow->addStretch(); layout->addLayout(presetRow); auto* table = new QTableWidget(&dlg); table->setColumnCount(2); table->horizontalHeader()->setVisible(false); table->horizontalHeader()->setStretchLastSection(true); table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); table->setSelectionMode(QAbstractItemView::SingleSelection); table->verticalHeader()->setVisible(false); // Skip types that nobody aliases (Vec, Mat, Struct, Array) auto shouldSkip = [](NodeKind k) { return k == NodeKind::Vec2 || k == NodeKind::Vec3 || k == NodeKind::Vec4 || k == NodeKind::Mat4x4 || k == NodeKind::Struct || k == NodeKind::Array; }; // Build filtered row→meta index mapping QVector rowMap; int totalMeta = static_cast(std::size(kKindMeta)); for (int i = 0; i < totalMeta; i++) if (!shouldSkip(kKindMeta[i].kind)) rowMap.append(i); table->setRowCount(rowMap.size()); for (int row = 0; row < rowMap.size(); row++) { const auto& meta = kKindMeta[rowMap[row]]; auto* kindItem = new QTableWidgetItem(QString::fromLatin1(meta.name)); kindItem->setFlags(kindItem->flags() & ~Qt::ItemIsEditable); table->setItem(row, 0, kindItem); QString alias = tab->doc->typeAliases.value(meta.kind); table->setItem(row, 1, new QTableWidgetItem(alias)); } // stdint preset: actual typeName values from kKindMeta static QHash kStdintPreset; if (kStdintPreset.isEmpty()) { for (const auto& m : kKindMeta) kStdintPreset[m.kind] = QString::fromLatin1(m.typeName); } // Windows (basetsd.h) preset mapping static const QHash kWindowsPreset = { {NodeKind::Int8, QStringLiteral("CHAR")}, {NodeKind::Int16, QStringLiteral("SHORT")}, {NodeKind::Int32, QStringLiteral("LONG")}, {NodeKind::Int64, QStringLiteral("LONGLONG")}, {NodeKind::UInt8, QStringLiteral("UCHAR")}, {NodeKind::UInt16, QStringLiteral("USHORT")}, {NodeKind::UInt32, QStringLiteral("ULONG")}, {NodeKind::UInt64, QStringLiteral("ULONGLONG")}, {NodeKind::Float, QStringLiteral("FLOAT")}, {NodeKind::Double, QStringLiteral("DOUBLE")}, {NodeKind::Bool, QStringLiteral("BOOLEAN")}, {NodeKind::Pointer32, QStringLiteral("ULONG")}, {NodeKind::Pointer64, QStringLiteral("ULONG_PTR")}, {NodeKind::FuncPtr32, QStringLiteral("ULONG")}, {NodeKind::FuncPtr64, QStringLiteral("ULONG_PTR")}, {NodeKind::Hex8, QStringLiteral("BYTE")}, {NodeKind::Hex16, QStringLiteral("WORD")}, {NodeKind::Hex32, QStringLiteral("DWORD")}, {NodeKind::Hex64, QStringLiteral("DWORD64")}, {NodeKind::UTF8, QStringLiteral("CHAR[]")}, {NodeKind::UTF16, QStringLiteral("WCHAR[]")}, }; auto applyPreset = [&](const QHash& preset) { for (int row = 0; row < rowMap.size(); row++) table->item(row, 1)->setText(preset.value(kKindMeta[rowMap[row]].kind)); }; connect(btnStdint, &QPushButton::clicked, [&]() { applyPreset(kStdintPreset); }); connect(btnWindows, &QPushButton::clicked, [&]() { applyPreset(kWindowsPreset); }); layout->addWidget(table); auto* buttons = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dlg); layout->addWidget(buttons); connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept); connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); if (dlg.exec() != QDialog::Accepted) return; // Collect new aliases QHash newAliases; for (int row = 0; row < rowMap.size(); row++) { QString val = table->item(row, 1)->text().trimmed(); if (!val.isEmpty()) newAliases[kKindMeta[rowMap[row]].kind] = val; } tab->doc->typeAliases = newAliases; tab->doc->modified = true; tab->ctrl->refresh(); updateWindowTitle(); } // ── Project Lifecycle API ── QDockWidget* MainWindow::project_new(const QString& classKeyword) { auto* doc = new RcxDocument(this); QByteArray data(256, '\0'); doc->loadData(data); doc->tree.baseAddress = 0x00400000; buildEmptyStruct(doc->tree, classKeyword); // Inherit source from current tab (if any) auto* currentCtrl = activeController(); if (currentCtrl && currentCtrl->document()->provider && currentCtrl->document()->provider->isValid()) { doc->provider = currentCtrl->document()->provider; } auto* dock = createTab(doc); // Copy saved sources to new tab's controller if (currentCtrl && !currentCtrl->savedSources().isEmpty()) { auto& newTab = m_tabs[dock]; newTab.ctrl->copySavedSources(currentCtrl->savedSources(), currentCtrl->activeSourceIndex()); } // Ensure workspace dock is split alongside editor with sensible proportions if (m_docDocks.size() == 1 && m_workspaceDock) { splitDockWidget(m_workspaceDock, m_docDocks.first(), Qt::Horizontal); resizeDocks({m_workspaceDock}, {128}, Qt::Horizontal); m_workspaceDock->show(); } rebuildWorkspaceModelNow(); return dock; } QDockWidget* MainWindow::project_open(const QString& path) { QString filePath = path; if (filePath.isEmpty()) { filePath = QFileDialog::getOpenFileName(this, "Open Definition", {}, "Reclass (*.rcx)" ";;All (*)"); if (filePath.isEmpty()) return nullptr; } // Detect if this is an XML-based ReClass file by checking first bytes bool isXml = false; { QFile probe(filePath); if (probe.open(QIODevice::ReadOnly)) { QByteArray head = probe.read(64); isXml = head.trimmed().startsWith("tree = std::move(tree); QDockWidget* dock; { ClosingGuard guard(m_closingAll); closeAllDocDocks(); dock = createTab(doc); } rebuildWorkspaceModel(); if (!m_docDocks.isEmpty()) { splitDockWidget(m_workspaceDock, m_docDocks.first(), Qt::Horizontal); resizeDocks({m_workspaceDock}, {128}, Qt::Horizontal); } m_workspaceDock->show(); int classCount = 0; for (const auto& n : doc->tree.nodes) if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++; setAppStatus(QStringLiteral("Imported %1 classes from %2") .arg(classCount).arg(QFileInfo(filePath).fileName())); addRecentFile(filePath); return dock; } auto* doc = new RcxDocument(this); if (!doc->load(filePath)) { QMessageBox::warning(this, "Error", "Failed to load: " + filePath); delete doc; return nullptr; } // Close all existing tabs so the project replaces the current state QDockWidget* dock; { ClosingGuard guard(m_closingAll); closeAllDocDocks(); dock = createTab(doc); } rebuildWorkspaceModel(); if (!m_docDocks.isEmpty()) { splitDockWidget(m_workspaceDock, m_docDocks.first(), Qt::Horizontal); resizeDocks({m_workspaceDock}, {128}, Qt::Horizontal); } m_workspaceDock->show(); addRecentFile(filePath); return dock; } bool MainWindow::project_save(QDockWidget* dock, bool saveAs) { if (!dock) dock = m_activeDocDock; if (!dock || !m_tabs.contains(dock)) return false; auto& tab = m_tabs[dock]; if (saveAs || tab.doc->filePath.isEmpty()) { QString path = QFileDialog::getSaveFileName(this, "Save Definition", {}, "Reclass (*.rcx);;JSON (*.json)"); if (path.isEmpty()) return false; tab.doc->save(path); addRecentFile(path); } else { tab.doc->save(tab.doc->filePath); addRecentFile(tab.doc->filePath); } updateWindowTitle(); return true; } void MainWindow::project_close(QDockWidget* dock) { if (!dock) dock = m_activeDocDock; if (!dock) return; dock->close(); } void MainWindow::closeAllDocDocks() { // Take a copy since closing modifies m_docDocks via destroyed signal auto docks = m_docDocks; for (auto* dock : docks) dock->close(); } // ── Workspace Dock ── void MainWindow::createWorkspaceDock() { m_workspaceDock = new QDockWidget("Project", this); m_workspaceDock->setObjectName("WorkspaceDock"); m_workspaceDock->setAllowedAreas(Qt::AllDockWidgetAreas); m_workspaceDock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetMovable); // Custom titlebar: label + ✕ close button (matches MDI tab style) { const auto& t = ThemeManager::instance().current(); auto* titleBar = new QWidget(m_workspaceDock); titleBar->setFixedHeight(29); titleBar->setAutoFillBackground(true); { QPalette tbPal = titleBar->palette(); tbPal.setColor(QPalette::Window, t.backgroundAlt); titleBar->setPalette(tbPal); } auto* layout = new QHBoxLayout(titleBar); layout->setContentsMargins(4, 2, 2, 2); layout->setSpacing(4); m_dockGrip = new DockGripWidget(titleBar); layout->addWidget(m_dockGrip); m_dockTitleLabel = new QLabel("Project", titleBar); { m_dockTitleLabel->setStyleSheet( QStringLiteral("color: %1;").arg(t.textDim.name())); QSettings s("Reclass", "Reclass"); QFont f(s.value("font", "JetBrains Mono").toString(), 10); f.setFixedPitch(true); m_dockTitleLabel->setFont(f); } layout->addWidget(m_dockTitleLabel); layout->addStretch(); m_dockCloseBtn = new QToolButton(titleBar); m_dockCloseBtn->setIcon(QIcon(QStringLiteral(":/vsicons/close.svg"))); m_dockCloseBtn->setIconSize(QSize(14, 14)); m_dockCloseBtn->setAutoRaise(true); m_dockCloseBtn->setCursor(Qt::PointingHandCursor); m_dockCloseBtn->setStyleSheet(QStringLiteral( "QToolButton { border: none; padding: 0px 4px; }" "QToolButton:hover { background: %1; }") .arg(t.hover.name())); connect(m_dockCloseBtn, &QToolButton::clicked, m_workspaceDock, &QDockWidget::close); layout->addWidget(m_dockCloseBtn); m_workspaceDock->setTitleBarWidget(titleBar); } // Outer border around entire dock (header + search + tree) // background + ::title needed to suppress Fusion outline frame (renders ~#171717) { const auto& t = ThemeManager::instance().current(); m_workspaceDock->setStyleSheet(QStringLiteral( "QDockWidget { border: 1px solid %1; }").arg(t.border.name())); } // Container widget: search box + tree view auto* dockContainer = new QWidget(m_workspaceDock); auto* dockLayout = new QVBoxLayout(dockContainer); dockLayout->setContentsMargins(0, 0, 0, 0); dockLayout->setSpacing(0); m_workspaceSearch = new QLineEdit(dockContainer); m_workspaceSearch->setPlaceholderText(QStringLiteral("Filter types...")); // Clear button uses our close.svg icon instead of Qt's default circle-X { QSettings s("Reclass", "Reclass"); QFont f(s.value("font", "JetBrains Mono").toString(), 10); f.setFixedPitch(true); m_workspaceSearch->setFont(f); } { auto* searchAction = m_workspaceSearch->addAction( QIcon(QStringLiteral(":/vsicons/search.svg")), QLineEdit::LeadingPosition); // Find the QToolButton created for the action and shrink its icon for (auto* btn : m_workspaceSearch->findChildren()) { if (btn->defaultAction() == searchAction) { btn->setIconSize(QSize(14, 14)); break; } } } { auto* clearAction = m_workspaceSearch->addAction( QIcon(QStringLiteral(":/vsicons/close.svg")), QLineEdit::TrailingPosition); clearAction->setVisible(false); connect(clearAction, &QAction::triggered, m_workspaceSearch, &QLineEdit::clear); connect(m_workspaceSearch, &QLineEdit::textChanged, clearAction, [clearAction](const QString& text) { clearAction->setVisible(!text.isEmpty()); }); for (auto* btn : m_workspaceSearch->findChildren()) { if (btn->defaultAction() == clearAction) { btn->setIconSize(QSize(14, 14)); break; } } } { const auto& t = ThemeManager::instance().current(); m_workspaceSearch->setStyleSheet(QStringLiteral( "QLineEdit { background: %1; color: %2;" " border: 1px solid %4;" " padding: 4px 8px 4px 2px; }" "QLineEdit:focus { border: 1px solid %5; }" "QLineEdit QToolButton { padding: 0px 8px; }" "QLineEdit QToolButton:hover { background: %3; }") .arg(t.background.name(), t.textDim.name(), t.hover.name(), t.border.name(), t.borderFocused.name())); } m_workspaceSearch->setContentsMargins(6, 6, 6, 6); dockLayout->addWidget(m_workspaceSearch); // Separator below search { const auto& t = ThemeManager::instance().current(); auto* sep = new QFrame(dockContainer); sep->setObjectName(QStringLiteral("workspaceSep")); sep->setFrameShape(QFrame::HLine); sep->setFixedHeight(1); sep->setStyleSheet(QStringLiteral("background: %1; border: none;").arg(t.border.name())); dockLayout->addWidget(sep); } m_workspaceTree = new QTreeView(dockContainer); m_workspaceModel = new QStandardItemModel(this); m_workspaceModel->setHorizontalHeaderLabels({"Name"}); m_workspaceProxy = new QSortFilterProxyModel(this); m_workspaceProxy->setSourceModel(m_workspaceModel); m_workspaceProxy->setFilterCaseSensitivity(Qt::CaseInsensitive); m_workspaceProxy->setRecursiveFilteringEnabled(true); m_workspaceTree->setModel(m_workspaceProxy); m_workspaceTree->setHeaderHidden(true); m_workspaceTree->setEditTriggers(QAbstractItemView::NoEditTriggers); m_workspaceTree->setExpandsOnDoubleClick(false); m_workspaceTree->setMouseTracking(true); m_workspaceTree->setSelectionMode(QAbstractItemView::ExtendedSelection); { QSettings s("Reclass", "Reclass"); QFont f(s.value("font", "JetBrains Mono").toString(), 10); f.setFixedPitch(true); m_workspaceTree->setFont(f); } m_workspaceSearchTimer = new QTimer(this); m_workspaceSearchTimer->setSingleShot(true); m_workspaceSearchTimer->setInterval(150); connect(m_workspaceSearchTimer, &QTimer::timeout, this, [this]() { QString text = m_workspaceSearch->text(); m_workspaceProxy->setFilterFixedString(text); if (!text.isEmpty()) m_workspaceTree->expandAll(); else m_workspaceTree->collapseAll(); }); connect(m_workspaceSearch, &QLineEdit::textChanged, this, [this]() { m_workspaceSearchTimer->start(); }); // Custom delegate for rich text rendering (name bright, metadata dim) { const auto& t = ThemeManager::instance().current(); m_workspaceDelegate = new rcx::WorkspaceDelegate(m_workspaceTree); m_workspaceDelegate->setThemeColors(t); m_workspaceTree->setItemDelegate(m_workspaceDelegate); QPalette tp = m_workspaceTree->palette(); tp.setColor(QPalette::Text, t.textDim); tp.setColor(QPalette::Highlight, t.selected); tp.setColor(QPalette::HighlightedText, t.text); m_workspaceTree->setPalette(tp); m_workspaceTree->setStyleSheet(QStringLiteral( "QTreeView { background: %1; border: none; padding-left: 4px; }" "QTreeView::branch:has-children:closed { image: url(:/vsicons/chevron-right.svg); }" "QTreeView::branch:has-children:open { image: url(:/vsicons/chevron-down.svg); }" "QTreeView::branch { width: 12px; }" "QAbstractScrollArea::corner { background: %1; border: none; }" "QHeaderView { background: %1; border: none; }" "QHeaderView::section { background: %1; border: none; }") .arg(t.background.name())); } m_workspaceTree->setIndentation(12); dockLayout->addWidget(m_workspaceTree); m_workspaceTree->setContextMenuPolicy(Qt::CustomContextMenu); connect(m_workspaceTree, &QWidget::customContextMenuRequested, this, [this](const QPoint& pos) { QModelIndex clickedIndex = m_workspaceTree->indexAt(pos); // Right-click on empty area → New Class / New Struct / New Enum if (!clickedIndex.isValid()) { QMenu menu; auto* actClass = menu.addAction("New Class"); auto* actStruct = menu.addAction("New Struct"); auto* actEnum = menu.addAction("New Enum"); QAction* chosen = menu.exec(m_workspaceTree->viewport()->mapToGlobal(pos)); if (chosen == actClass) newClass(); else if (chosen == actStruct) newStruct(); else if (chosen == actEnum) newEnum(); return; } // If right-clicked item is not in current selection, select only it auto* sel = m_workspaceTree->selectionModel(); if (!sel->isSelected(clickedIndex)) sel->select(clickedIndex, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); // Gather all selected ROOT items (children are not independently actionable) struct SelItem { uint64_t structId; QDockWidget* dock; int nodeIdx; QString keyword; QString typeName; }; QVector items; for (const auto& idx : sel->selectedIndexes()) { if (idx.parent().isValid()) continue; // skip children auto idVar = idx.data(Qt::UserRole + 1); uint64_t sid = idVar.isValid() ? idVar.toULongLong() : 0; if (sid == 0) continue; auto subVar = idx.data(Qt::UserRole); if (!subVar.isValid()) continue; auto* dk = static_cast(subVar.value()); if (!dk || !m_tabs.contains(dk)) continue; int ni = m_tabs[dk].doc->tree.indexOfId(sid); if (ni < 0) continue; const auto& nd = m_tabs[dk].doc->tree.nodes[ni]; QString tn = nd.structTypeName.isEmpty() ? nd.name : nd.structTypeName; if (tn.isEmpty()) tn = QStringLiteral("(unnamed)"); items.push_back(SelItem{sid, dk, ni, nd.resolvedClassKeyword(), tn}); } if (items.isEmpty()) return; QMenu menu; // Navigation actions (single selection only) QAction* actOpenCurrent = nullptr; QAction* actOpenNew = nullptr; QAction* actDuplicate = nullptr; if (items.size() == 1) { actOpenCurrent = menu.addAction("Open in Current Tab"); actOpenNew = menu.addAction("Open in New Tab"); actDuplicate = menu.addAction("Duplicate"); menu.addSeparator(); } // Convert: only for single selection, class↔struct (not enum) QAction* actConvert = nullptr; if (items.size() == 1) { if (items[0].keyword == QStringLiteral("class")) actConvert = menu.addAction("Convert to Struct"); else if (items[0].keyword == QStringLiteral("struct")) actConvert = menu.addAction("Convert to Class"); } // Pin/Unpin bool allPinned = true; for (const auto& item : items) if (!m_pinnedIds.contains(item.structId)) { allPinned = false; break; } auto* actPin = menu.addAction( QIcon(QStringLiteral(":/vsicons/pin.svg")), allPinned ? QStringLiteral("Unpin") : QStringLiteral("Pin")); menu.addSeparator(); // Delete: works for single or multi QString delLabel = items.size() == 1 ? QStringLiteral("Delete") : QStringLiteral("Delete %1 items").arg(items.size()); auto* actDelete = menu.addAction(QIcon(":/vsicons/remove.svg"), delLabel); QAction* chosen = menu.exec(m_workspaceTree->viewport()->mapToGlobal(pos)); if (chosen == actDelete) { // Collect reference info across all selected items QStringList refDetails; QStringList typeNames; for (const auto& item : items) { typeNames << item.typeName; if (!m_tabs.contains(item.dock)) continue; for (const auto& n : m_tabs[item.dock].doc->tree.nodes) { if (n.refId == item.structId) { QString ownerName; uint64_t pid = n.parentId; while (pid != 0) { int pi = m_tabs[item.dock].doc->tree.indexOfId(pid); if (pi < 0) break; if (m_tabs[item.dock].doc->tree.nodes[pi].parentId == 0) { const auto& pn = m_tabs[item.dock].doc->tree.nodes[pi]; ownerName = pn.structTypeName.isEmpty() ? pn.name : pn.structTypeName; break; } pid = m_tabs[item.dock].doc->tree.nodes[pi].parentId; } QString fieldDesc = ownerName.isEmpty() ? n.name : QStringLiteral("%1::%2").arg(ownerName, n.name); refDetails << QStringLiteral(" \u2022 %1 (%2)") .arg(fieldDesc, kindToString(n.kind)); } } } QString msg; if (items.size() == 1) { msg = refDetails.isEmpty() ? QStringLiteral("Delete '%1'?").arg(typeNames[0]) : QStringLiteral("Delete '%1'?\n\n" "The following %2 field(s) reference this type " "and will become untyped (void):\n\n%3") .arg(typeNames[0]) .arg(refDetails.size()) .arg(refDetails.join('\n')); } else { msg = QStringLiteral("Delete %1 types?\n\n%2") .arg(items.size()) .arg(typeNames.join(QStringLiteral(", "))); if (!refDetails.isEmpty()) msg += QStringLiteral("\n\n%1 field(s) reference these types " "and will become untyped (void):\n\n%2") .arg(refDetails.size()) .arg(refDetails.join('\n')); } auto answer = QMessageBox::question(this, "Delete Type", msg, QMessageBox::Yes | QMessageBox::No, QMessageBox::No); if (answer != QMessageBox::Yes) return; // Group deletes by controller for single undo macro per document QHash> byCtrl; for (const auto& item : items) { if (!m_tabs.contains(item.dock)) continue; byCtrl[m_tabs[item.dock].ctrl].append(item.structId); } for (auto it = byCtrl.begin(); it != byCtrl.end(); ++it) { auto* ctrl = it.key(); const auto& ids = it.value(); if (ids.size() == 1) { ctrl->deleteRootStruct(ids[0]); } else { // Wrap multiple deletes in a single undo macro ctrl->document()->undoStack.beginMacro( QStringLiteral("Delete %1 types").arg(ids.size())); for (uint64_t sid : ids) ctrl->deleteRootStruct(sid); ctrl->document()->undoStack.endMacro(); } } rebuildWorkspaceModel(); } else if (chosen && chosen == actOpenCurrent && items.size() == 1) { // Open in current (active) tab — set viewRootId on active editor const auto& item = items[0]; if (!m_tabs.contains(item.dock)) return; RcxDocument* doc = m_tabs[item.dock].doc; int ni = doc->tree.indexOfId(item.structId); if (ni < 0) return; doc->tree.nodes[ni].collapsed = false; // Use the active tab if it shares the same document, else use owner QDockWidget* targetDock = item.dock; if (m_activeDocDock && m_tabs.contains(m_activeDocDock) && m_tabs[m_activeDocDock].doc == doc) targetDock = m_activeDocDock; auto& tab = m_tabs[targetDock]; tab.ctrl->setViewRootId(item.structId); tab.ctrl->refresh(); targetDock->raise(); targetDock->show(); m_activeDocDock = targetDock; QString structName = doc->tree.nodes[ni].structTypeName.isEmpty() ? doc->tree.nodes[ni].name : doc->tree.nodes[ni].structTypeName; if (!structName.isEmpty()) targetDock->setWindowTitle(structName); rebuildWorkspaceModel(); } else if (chosen && chosen == actOpenNew && items.size() == 1) { // Open in a brand new tab (sharing the same document) const auto& item = items[0]; if (!m_tabs.contains(item.dock)) return; RcxDocument* doc = m_tabs[item.dock].doc; int ni = doc->tree.indexOfId(item.structId); if (ni < 0) return; doc->tree.nodes[ni].collapsed = false; auto* newDock = createTab(doc); m_tabs[newDock].ctrl->setViewRootId(item.structId); m_tabs[newDock].ctrl->refresh(); QString structName = doc->tree.nodes[ni].structTypeName.isEmpty() ? doc->tree.nodes[ni].name : doc->tree.nodes[ni].structTypeName; if (!structName.isEmpty()) newDock->setWindowTitle(structName); rebuildWorkspaceModel(); } else if (chosen && chosen == actDuplicate && items.size() == 1) { // Duplicate: deep-copy the struct as a new root with a unique name const auto& item = items[0]; if (!m_tabs.contains(item.dock)) return; auto& tab = m_tabs[item.dock]; auto& tree = tab.doc->tree; // Generate unique name QString baseName = item.typeName + QStringLiteral("_copy"); QString newName = baseName; int counter = 1; QSet existing; for (const auto& n : tree.nodes) if (n.kind == rcx::NodeKind::Struct && !n.structTypeName.isEmpty()) existing.insert(n.structTypeName); while (existing.contains(newName)) newName = baseName + QString::number(counter++); tab.ctrl->setSuppressRefresh(true); tab.doc->undoStack.beginMacro(QStringLiteral("Duplicate ") + item.typeName); // Clone root node (re-lookup by ID since menu.exec() may have invalidated index) int ni = tree.indexOfId(item.structId); if (ni < 0) return; rcx::Node root = tree.nodes[ni]; root.id = tree.reserveId(); root.structTypeName = newName; root.name = newName; root.parentId = 0; tab.doc->undoStack.push(new rcx::RcxCommand(tab.ctrl, rcx::cmd::Insert{root})); // Clone children (re-lookup after insert since indices may shift) QVector children = tree.childrenOf(item.structId); for (int ci : children) { rcx::Node child = tree.nodes[ci]; child.id = tree.reserveId(); child.parentId = root.id; child.refId = 0; // don't copy pointer refs tab.doc->undoStack.push(new rcx::RcxCommand(tab.ctrl, rcx::cmd::Insert{child})); } tab.doc->undoStack.endMacro(); tab.ctrl->setSuppressRefresh(false); tab.ctrl->refresh(); rebuildWorkspaceModel(); } else if (chosen && chosen == actConvert && items.size() == 1) { const auto& item = items[0]; if (!m_tabs.contains(item.dock)) return; auto& tab = m_tabs[item.dock]; int ni = tab.doc->tree.indexOfId(item.structId); if (ni < 0) return; QString newKw = item.keyword == QStringLiteral("class") ? QStringLiteral("struct") : QStringLiteral("class"); tab.doc->undoStack.push(new rcx::RcxCommand(tab.ctrl, rcx::cmd::ChangeClassKeyword{item.structId, item.keyword, newKw})); // Sync all dock titles that share this document for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) if (it->doc == tab.doc) it.key()->setWindowTitle(tabTitle(*it)); rebuildWorkspaceModel(); } else if (chosen && chosen == actPin) { for (const auto& item : items) { if (allPinned) m_pinnedIds.remove(item.structId); else m_pinnedIds.insert(item.structId); } // Full rebuild to reorder pinned items to top m_workspaceModel->removeRows(0, m_workspaceModel->rowCount()); rebuildWorkspaceModelNow(); } }); // Ctrl+F focuses the workspace search field { auto* findAction = new QAction(dockContainer); findAction->setShortcut(QKeySequence::Find); findAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); dockContainer->addAction(findAction); connect(findAction, &QAction::triggered, this, [this]() { m_workspaceSearch->setFocus(); m_workspaceSearch->selectAll(); }); } m_workspaceTree->setMinimumWidth(0); m_workspaceSearch->setMinimumWidth(0); dockContainer->setMinimumWidth(0); m_workspaceDock->setWidget(dockContainer); addDockWidget(Qt::TopDockWidgetArea, m_workspaceDock); m_workspaceDock->hide(); connect(m_workspaceTree, &QTreeView::doubleClicked, this, [this](const QModelIndex& index) { auto structIdVar = index.data(Qt::UserRole + 1); uint64_t structId = structIdVar.isValid() ? structIdVar.toULongLong() : 0; auto subVar = index.data(Qt::UserRole); if (!subVar.isValid()) return; auto* ownerDock = static_cast(subVar.value()); if (!ownerDock || !m_tabs.contains(ownerDock)) return; RcxDocument* doc = m_tabs[ownerDock].doc; auto& tree = doc->tree; int ni = tree.indexOfId(structId); if (ni < 0) return; // For child members: navigate within the owner tab and scroll uint64_t parentId = tree.nodes[ni].parentId; if (parentId != 0) { ownerDock->raise(); ownerDock->show(); m_activeDocDock = ownerDock; auto& tab = m_tabs[ownerDock]; int pi = tree.indexOfId(parentId); if (pi >= 0) tree.nodes[pi].collapsed = false; tab.ctrl->setViewRootId(parentId); tab.ctrl->scrollToNodeId(structId); QPointer dockRef = ownerDock; QTimer::singleShot(0, this, [this, dockRef]() { if (!dockRef || !m_tabs.contains(dockRef)) return; auto& t = m_tabs[dockRef]; if (t.activePaneIdx >= 0 && t.activePaneIdx < t.panes.size()) { auto& p = t.panes[t.activePaneIdx]; if (p.viewMode == VM_Rendered) updateRenderedView(t, p); } }); return; } // Root struct/enum: check if any existing tab already views this struct for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) { if (it->doc == doc && it->ctrl->viewRootId() == structId) { it.key()->raise(); it.key()->show(); m_activeDocDock = it.key(); return; } } // Open in a new tab sharing the same document tree.nodes[ni].collapsed = false; auto* newDock = createTab(doc); m_tabs[newDock].ctrl->setViewRootId(structId); m_tabs[newDock].ctrl->refresh(); // Set tab title to struct name QString structName = tree.nodes[ni].structTypeName.isEmpty() ? tree.nodes[ni].name : tree.nodes[ni].structTypeName; if (!structName.isEmpty()) newDock->setWindowTitle(structName); rebuildWorkspaceModel(); }); // Single-click: peek (raise existing tab / scroll to member) — no new tab creation connect(m_workspaceTree, &QTreeView::clicked, this, [this](const QModelIndex& index) { // Modifier held → user is multi-selecting, don't navigate if (QApplication::keyboardModifiers() & (Qt::ControlModifier | Qt::ShiftModifier)) return; auto structIdVar = index.data(Qt::UserRole + 1); uint64_t structId = structIdVar.isValid() ? structIdVar.toULongLong() : 0; if (structId == 0) return; auto subVar = index.data(Qt::UserRole); if (!subVar.isValid()) return; auto* ownerDock = static_cast(subVar.value()); if (!ownerDock || !m_tabs.contains(ownerDock)) return; RcxDocument* doc = m_tabs[ownerDock].doc; auto& tree = doc->tree; int ni = tree.indexOfId(structId); if (ni < 0) return; uint64_t parentId = tree.nodes[ni].parentId; if (parentId != 0) { // Child member: navigate within owner tab, scroll to member ownerDock->raise(); ownerDock->show(); m_activeDocDock = ownerDock; auto& tab = m_tabs[ownerDock]; int pi = tree.indexOfId(parentId); if (pi >= 0) tree.nodes[pi].collapsed = false; tab.ctrl->setViewRootId(parentId); tab.ctrl->scrollToNodeId(structId); QPointer dockRef = ownerDock; QTimer::singleShot(0, this, [this, dockRef]() { if (!dockRef || !m_tabs.contains(dockRef)) return; auto& t = m_tabs[dockRef]; if (t.activePaneIdx >= 0 && t.activePaneIdx < t.panes.size()) { auto& p = t.panes[t.activePaneIdx]; if (p.viewMode == VM_Rendered) updateRenderedView(t, p); } }); } else { // Root item: raise existing tab if one views this struct (peek only) for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) { if (it->doc == doc && it->ctrl->viewRootId() == structId) { it.key()->raise(); it.key()->show(); m_activeDocDock = it.key(); return; } } } }); } // ── Scanner Dock ── void MainWindow::createScannerDock() { m_scannerDock = new QDockWidget("Scanner", this); m_scannerDock->setObjectName("ScannerDock"); m_scannerDock->setAllowedAreas( Qt::BottomDockWidgetArea | Qt::RightDockWidgetArea | Qt::LeftDockWidgetArea); m_scannerDock->setFeatures( QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable); // Custom titlebar: label + close button (matches workspace dock) { const auto& t = ThemeManager::instance().current(); auto* titleBar = new QWidget(m_scannerDock); titleBar->setFixedHeight(24); titleBar->setAutoFillBackground(true); { QPalette tbPal = titleBar->palette(); tbPal.setColor(QPalette::Window, t.backgroundAlt); titleBar->setPalette(tbPal); } auto* layout = new QHBoxLayout(titleBar); layout->setContentsMargins(4, 2, 2, 2); layout->setSpacing(4); m_scanDockGrip = new DockGripWidget(titleBar); layout->addWidget(m_scanDockGrip); m_scanDockTitle = new QLabel("Scanner", titleBar); m_scanDockTitle->setStyleSheet( QStringLiteral("color: %1;").arg(t.textDim.name())); layout->addWidget(m_scanDockTitle); layout->addStretch(); m_scanDockCloseBtn = new QToolButton(titleBar); m_scanDockCloseBtn->setText(QStringLiteral("\u2715")); m_scanDockCloseBtn->setAutoRaise(true); m_scanDockCloseBtn->setCursor(Qt::PointingHandCursor); m_scanDockCloseBtn->setStyleSheet(QStringLiteral( "QToolButton { color: %1; border: none; padding: 0px 4px 2px 4px; font-size: 12px; }" "QToolButton:hover { color: %2; }") .arg(t.textDim.name(), t.indHoverSpan.name())); connect(m_scanDockCloseBtn, &QToolButton::clicked, m_scannerDock, &QDockWidget::close); layout->addWidget(m_scanDockCloseBtn); m_scannerDock->setTitleBarWidget(titleBar); } { const auto& t = ThemeManager::instance().current(); m_scannerDock->setStyleSheet(QStringLiteral( "QDockWidget { border: 1px solid %1; }").arg(t.border.name())); } m_scannerPanel = new ScannerPanel(m_scannerDock); m_scannerPanel->applyTheme(ThemeManager::instance().current()); { QSettings settings("Reclass", "Reclass"); QString fontName = settings.value("font", "JetBrains Mono").toString(); QFont f(fontName, 12); f.setFixedPitch(true); m_scannerPanel->setEditorFont(f); m_scanDockTitle->setFont(f); } m_scannerDock->setWidget(m_scannerPanel); addDockWidget(Qt::BottomDockWidgetArea, m_scannerDock); m_scannerDock->hide(); // Border overlay and resize grip for floating state { auto* border = new BorderOverlay(m_scannerDock); border->color = ThemeManager::instance().current().borderFocused; border->hide(); auto* grip = new ResizeGrip(m_scannerDock); grip->hide(); connect(m_scannerDock, &QDockWidget::topLevelChanged, this, [this, border, grip](bool floating) { if (floating) { border->setGeometry(0, 0, m_scannerDock->width(), m_scannerDock->height()); border->raise(); border->show(); grip->reposition(); grip->raise(); grip->show(); } else { border->hide(); grip->hide(); } }); m_scannerDock->installEventFilter(new DockBorderFilter(border, grip, m_scannerDock)); } // Wire provider getter: lazily captures the active tab's provider at scan time m_scannerPanel->setProviderGetter([this]() -> std::shared_ptr { auto* ctrl = activeController(); return ctrl ? ctrl->document()->provider : nullptr; }); // Wire bounds getter: struct base + size for "Current Struct" filter m_scannerPanel->setBoundsGetter([this]() -> rcx::ScannerPanel::StructBounds { auto* ctrl = activeController(); if (!ctrl) return {}; auto& tree = ctrl->document()->tree; uint64_t base = tree.baseAddress; uint64_t viewRoot = ctrl->viewRootId(); int span = 0; if (viewRoot != 0) { span = tree.structSpan(viewRoot); } else { // Compute extent from all top-level nodes for (int i = 0; i < tree.nodes.size(); i++) { const auto& n = tree.nodes[i]; int64_t off = tree.computeOffset(i); if (off < 0) continue; int sz = (n.kind == rcx::NodeKind::Struct || n.kind == rcx::NodeKind::Array) ? tree.structSpan(n.id) : n.byteSize(); int64_t end = off + sz; if (end > span) span = static_cast(end); } } return { base, static_cast(span) }; }); // Wire "Go to Address" to rebase the active tab connect(m_scannerPanel, &ScannerPanel::goToAddress, this, [this](uint64_t addr) { auto* ctrl = activeController(); if (!ctrl) return; ctrl->document()->tree.baseAddress = addr; ctrl->document()->tree.baseAddressFormula.clear(); ctrl->resetChangeTracking(); ctrl->refresh(); }); } void MainWindow::createSymbolsDock() { m_symbolsDock = new QDockWidget("Modules", this); m_symbolsDock->setObjectName("SymbolsDock"); m_symbolsDock->setAllowedAreas(Qt::AllDockWidgetAreas); m_symbolsDock->setFeatures( QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable); const auto& t = ThemeManager::instance().current(); QSettings s("Reclass", "Reclass"); QFont monoFont(s.value("font", "JetBrains Mono").toString(), 10); monoFont.setFixedPitch(true); // Custom titlebar (matches scanner dock) { auto* titleBar = new QWidget(m_symbolsDock); titleBar->setFixedHeight(24); titleBar->setAutoFillBackground(true); { QPalette tbPal = titleBar->palette(); tbPal.setColor(QPalette::Window, t.backgroundAlt); titleBar->setPalette(tbPal); } auto* layout = new QHBoxLayout(titleBar); layout->setContentsMargins(4, 2, 2, 2); layout->setSpacing(4); m_symDockGrip = new DockGripWidget(titleBar); layout->addWidget(m_symDockGrip); m_symDockTitle = new QLabel("Modules", titleBar); m_symDockTitle->setStyleSheet( QStringLiteral("color: %1;").arg(t.textDim.name())); m_symDockTitle->setFont(monoFont); layout->addWidget(m_symDockTitle); layout->addStretch(); m_symDownloadBtn = new QToolButton(titleBar); m_symDownloadBtn->setIcon(QIcon(QStringLiteral(":/vsicons/cloud-download.svg"))); m_symDownloadBtn->setIconSize(QSize(14, 14)); m_symDownloadBtn->setText(QStringLiteral("Download All")); m_symDownloadBtn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); m_symDownloadBtn->setAutoRaise(true); m_symDownloadBtn->setCursor(Qt::PointingHandCursor); m_symDownloadBtn->setToolTip(QStringLiteral("Load/Download all symbols")); m_symDownloadBtn->setStyleSheet(QStringLiteral( "QToolButton { border: none; padding: 2px 4px; }" "QToolButton:hover { background: %1; }") .arg(t.hover.name())); connect(m_symDownloadBtn, &QToolButton::clicked, this, &MainWindow::downloadSymbolsForProcess); layout->addWidget(m_symDownloadBtn); m_symDockCloseBtn = new QToolButton(titleBar); m_symDockCloseBtn->setText(QStringLiteral("\u2715")); m_symDockCloseBtn->setAutoRaise(true); m_symDockCloseBtn->setCursor(Qt::PointingHandCursor); m_symDockCloseBtn->setStyleSheet(QStringLiteral( "QToolButton { color: %1; border: none; padding: 0px 4px 2px 4px; font-size: 12px; }" "QToolButton:hover { color: %2; }") .arg(t.textDim.name(), t.indHoverSpan.name())); connect(m_symDockCloseBtn, &QToolButton::clicked, m_symbolsDock, &QDockWidget::close); layout->addWidget(m_symDockCloseBtn); m_symbolsDock->setTitleBarWidget(titleBar); } m_symbolsDock->setStyleSheet(QStringLiteral( "QDockWidget { border: 1px solid %1; }").arg(t.border.name())); // Helper: style a tree view to match theme auto styleTree = [&](QTreeView* tree) { tree->setFont(monoFont); QPalette tp = tree->palette(); tp.setColor(QPalette::Text, t.textDim); tp.setColor(QPalette::Highlight, t.selected); tp.setColor(QPalette::HighlightedText, t.text); tree->setPalette(tp); tree->setStyleSheet(QStringLiteral( "QTreeView { background: %1; border: none; padding-left: 4px; }" "QTreeView::branch:has-children:closed { image: url(:/vsicons/chevron-right.svg); }" "QTreeView::branch:has-children:open { image: url(:/vsicons/chevron-down.svg); }" "QTreeView::branch { width: 12px; }" "QAbstractScrollArea::corner { background: %1; border: none; }" "QHeaderView { background: %1; border: none; }" "QHeaderView::section { background: %1; border: none; }") .arg(t.background.name())); tree->setHeaderHidden(true); tree->setEditTriggers(QAbstractItemView::NoEditTriggers); tree->setMouseTracking(true); tree->setSelectionMode(QAbstractItemView::SingleSelection); tree->setIndentation(12); }; // Container with tab widget auto* container = new QWidget(m_symbolsDock); auto* containerLayout = new QVBoxLayout(container); containerLayout->setContentsMargins(0, 0, 0, 0); containerLayout->setSpacing(0); m_symTabWidget = new QTabWidget(container); m_symTabWidget->setObjectName(QStringLiteral("symTabWidget")); m_symTabWidget->setDocumentMode(true); m_symTabWidget->setFont(monoFont); m_symTabWidget->setStyleSheet(QStringLiteral( "QTabWidget::pane { border: none; }" "QTabBar { background: %1; }" "QTabBar::tab { background: %1; color: %2; border: none;" " border-bottom: 2px solid transparent; padding: 4px 12px; }" "QTabBar::tab:selected { color: %3; border-bottom: 2px solid %4; }" "QTabBar::tab:hover { color: %3; }") .arg(t.backgroundAlt.name(), t.textMuted.name(), t.text.name(), t.borderFocused.name())); // ── Modules tab ── { m_modulesTree = new QTreeView(); m_modulesModel = new QStandardItemModel(this); m_modulesTree->setModel(m_modulesModel); styleTree(m_modulesTree); m_modulesTree->setExpandsOnDoubleClick(false); // Double-click: set base address and trigger PDB import connect(m_modulesTree, &QTreeView::doubleClicked, this, [this](const QModelIndex& idx) { auto* item = m_modulesModel->itemFromIndex(idx); if (!item) return; uint64_t base = item->data(Qt::UserRole).toULongLong(); QString name = item->data(Qt::UserRole + 1).toString(); QString fullPath = item->data(Qt::UserRole + 2).toString(); auto* ctrl = activeController(); if (!ctrl) return; // Set base address ctrl->document()->tree.baseAddress = base; ctrl->document()->tree.baseAddressFormula.clear(); ctrl->resetChangeTracking(); ctrl->refresh(); // Already have symbols for this module? QString canonical = rcx::SymbolStore::instance().resolveAlias(name); if (rcx::SymbolStore::instance().moduleData(canonical)) { setAppStatus(QStringLiteral("Base set to %1 (0x%2) — symbols already loaded") .arg(name).arg(base, 0, 16)); return; } // Try to load symbols: local PDB → cache → download auto prov = ctrl->document()->provider; if (!prov) return; auto info = rcx::extractPdbDebugInfo(*prov, base); if (!info.valid) { setAppStatus(QStringLiteral("Base set to %1 (0x%2) — no debug info") .arg(name).arg(base, 0, 16)); return; } // Helper to load a PDB file into the symbol store (with type indices) auto loadPdb = [this, name](const QString& pdbPath) -> bool { int count = loadPdbIntoStore(pdbPath); if (count <= 0) return false; setAppStatus(QStringLiteral("Loaded %1 symbols for %2").arg(count).arg(name)); rebuildSymbolsModel(); if (auto* c = activeController()) c->refresh(); return true; }; // Check local QString localPdb = rcx::SymbolDownloader::findLocal(fullPath, info.pdbName); if (!localPdb.isEmpty() && loadPdb(localPdb)) return; // Check cache if (!m_symDownloader) m_symDownloader = new rcx::SymbolDownloader(this); rcx::SymbolDownloader::DownloadRequest req; req.moduleName = name; req.pdbName = info.pdbName; req.guidString = info.guidString; req.age = info.age; QString cached = m_symDownloader->findCached(req); if (!cached.isEmpty() && loadPdb(cached)) return; // Download setAppStatus(QStringLiteral("Downloading symbols for %1...").arg(name)); // One-shot connection for this download auto conn = std::make_shared(); *conn = connect(m_symDownloader, &rcx::SymbolDownloader::finished, this, [this, conn, loadPdb](const QString& mod, const QString& localPath, bool success, const QString& error) { disconnect(*conn); if (!success) { setAppStatus(QStringLiteral("Failed to download %1: %2").arg(mod, error)); return; } loadPdb(localPath); }); m_symDownloader->download(req); }); // Context menu for modules m_modulesTree->setContextMenuPolicy(Qt::CustomContextMenu); connect(m_modulesTree, &QWidget::customContextMenuRequested, this, [this](const QPoint& pos) { QModelIndex idx = m_modulesTree->indexAt(pos); if (!idx.isValid()) return; auto* item = m_modulesModel->itemFromIndex(idx); if (!item) return; uint64_t base = item->data(Qt::UserRole).toULongLong(); QString name = item->data(Qt::UserRole + 1).toString(); QString fullPath = item->data(Qt::UserRole + 2).toString(); QMenu menu; auto* actCopyBase = menu.addAction("Copy Base Address"); connect(actCopyBase, &QAction::triggered, this, [base]() { QApplication::clipboard()->setText( QStringLiteral("0x%1").arg(base, 16, 16, QLatin1Char('0'))); }); auto* actGoTo = menu.addAction("Go to Address"); connect(actGoTo, &QAction::triggered, this, [this, base]() { auto* ctrl = activeController(); if (!ctrl) return; ctrl->document()->tree.baseAddress = base; ctrl->document()->tree.baseAddressFormula.clear(); ctrl->resetChangeTracking(); ctrl->refresh(); }); menu.addSeparator(); // Check if symbols already loaded — change label accordingly QString canonical = rcx::SymbolStore::instance().resolveAlias(name); const auto* existingSyms = rcx::SymbolStore::instance().moduleData(canonical); auto* actDownload = menu.addAction(existingSyms ? QStringLiteral("Reload Symbols (%1 loaded)").arg(existingSyms->nameToRva.size()) : QStringLiteral("Download Symbols")); connect(actDownload, &QAction::triggered, this, [this, name, base, fullPath]() { auto* ctrl = activeController(); if (!ctrl || !ctrl->document()->provider) return; auto prov = ctrl->document()->provider; auto info = rcx::extractPdbDebugInfo(*prov, base); if (!info.valid) { setAppStatus(QStringLiteral("No debug info found in %1").arg(name)); return; } // Check local first QString localPdb = rcx::SymbolDownloader::findLocal(fullPath, info.pdbName); if (!localPdb.isEmpty()) { QString symErr; auto result = rcx::extractPdbSymbols(localPdb, &symErr); if (!result.symbols.isEmpty()) { QVector> pairs; pairs.reserve(result.symbols.size()); for (const auto& s : result.symbols) pairs.emplaceBack(s.name, s.rva); int count = rcx::SymbolStore::instance().addModule( result.moduleName, localPdb, pairs); setAppStatus(QStringLiteral("Loaded %1 symbols for %2 (local)") .arg(count).arg(name)); } rebuildSymbolsModel(); if (auto* c = activeController()) c->refresh(); return; } // Download from MS symbol server if (!m_symDownloader) { m_symDownloader = new rcx::SymbolDownloader(this); connect(m_symDownloader, &rcx::SymbolDownloader::progress, this, [this](const QString& mod, int received, int total) { if (total > 0) setAppStatus(QStringLiteral("Downloading %1... %2/%3 KB") .arg(mod).arg(received/1024).arg(total/1024)); else setAppStatus(QStringLiteral("Downloading %1... %2 KB") .arg(mod).arg(received/1024)); }); connect(m_symDownloader, &rcx::SymbolDownloader::finished, this, [this](const QString& mod, const QString& localPath, bool success, const QString& error) { if (!success) { setAppStatus(QStringLiteral("Failed to download %1: %2").arg(mod, error)); return; } QString symErr; auto result = rcx::extractPdbSymbols(localPath, &symErr); if (!result.symbols.isEmpty()) { QVector> pairs; pairs.reserve(result.symbols.size()); for (const auto& s : result.symbols) pairs.emplaceBack(s.name, s.rva); int count = rcx::SymbolStore::instance().addModule( result.moduleName, localPath, pairs); setAppStatus(QStringLiteral("Loaded %1 symbols for %2") .arg(count).arg(mod)); } rebuildSymbolsModel(); if (auto* c = activeController()) c->refresh(); }); } rcx::SymbolDownloader::DownloadRequest req; req.moduleName = name; req.pdbName = info.pdbName; req.guidString = info.guidString; req.age = info.age; QString cached = m_symDownloader->findCached(req); if (!cached.isEmpty()) { QString symErr; auto result = rcx::extractPdbSymbols(cached, &symErr); if (!result.symbols.isEmpty()) { QVector> pairs; pairs.reserve(result.symbols.size()); for (const auto& s : result.symbols) pairs.emplaceBack(s.name, s.rva); int count = rcx::SymbolStore::instance().addModule( result.moduleName, cached, pairs); setAppStatus(QStringLiteral("Loaded %1 symbols for %2 (cached)") .arg(count).arg(name)); } rebuildSymbolsModel(); if (auto* c = activeController()) c->refresh(); return; } m_symDownloader->download(req); }); auto* actBrowse = menu.addAction("Load PDB..."); connect(actBrowse, &QAction::triggered, this, [this, name]() { QString path = QFileDialog::getOpenFileName(this, QStringLiteral("Select PDB for %1").arg(name), {}, "PDB Files (*.pdb);;All Files (*)"); if (path.isEmpty()) return; QString symErr; auto result = rcx::extractPdbSymbols(path, &symErr); if (result.symbols.isEmpty()) { setAppStatus(symErr.isEmpty() ? QStringLiteral("No symbols found in PDB") : symErr); return; } QVector> pairs; pairs.reserve(result.symbols.size()); for (const auto& s : result.symbols) pairs.emplaceBack(s.name, s.rva); int count = rcx::SymbolStore::instance().addModule( result.moduleName, path, pairs); setAppStatus(QStringLiteral("Loaded %1 symbols for %2") .arg(count).arg(name)); rebuildSymbolsModel(); if (auto* c = activeController()) c->refresh(); }); menu.exec(m_modulesTree->viewport()->mapToGlobal(pos)); }); m_symTabWidget->addTab(m_modulesTree, "Modules"); } // ── Symbols tab ── { auto* symbolsPage = new QWidget(); auto* symLayout = new QVBoxLayout(symbolsPage); symLayout->setContentsMargins(0, 0, 0, 0); symLayout->setSpacing(0); // Search/filter box m_symbolsSearch = new QLineEdit(symbolsPage); m_symbolsSearch->setPlaceholderText(QStringLiteral("Filter symbols...")); m_symbolsSearch->setFont(monoFont); { auto* searchAction = m_symbolsSearch->addAction( QIcon(QStringLiteral(":/vsicons/search.svg")), QLineEdit::LeadingPosition); for (auto* btn : m_symbolsSearch->findChildren()) { if (btn->defaultAction() == searchAction) { btn->setIconSize(QSize(14, 14)); break; } } } { auto* clearAction = m_symbolsSearch->addAction( QIcon(QStringLiteral(":/vsicons/close.svg")), QLineEdit::TrailingPosition); clearAction->setVisible(false); connect(clearAction, &QAction::triggered, m_symbolsSearch, &QLineEdit::clear); connect(m_symbolsSearch, &QLineEdit::textChanged, clearAction, [clearAction](const QString& text) { clearAction->setVisible(!text.isEmpty()); }); for (auto* btn : m_symbolsSearch->findChildren()) { if (btn->defaultAction() == clearAction) { btn->setIconSize(QSize(14, 14)); break; } } } m_symbolsSearch->setStyleSheet(QStringLiteral( "QLineEdit { background: %1; color: %2;" " border: 1px solid %4;" " padding: 4px 8px 4px 2px; }" "QLineEdit:focus { border: 1px solid %5; }" "QLineEdit QToolButton { padding: 0px 8px; }" "QLineEdit QToolButton:hover { background: %3; }") .arg(t.background.name(), t.textDim.name(), t.hover.name(), t.border.name(), t.borderFocused.name())); m_symbolsSearch->setContentsMargins(6, 6, 6, 6); symLayout->addWidget(m_symbolsSearch); // Separator auto* sep = new QFrame(symbolsPage); sep->setObjectName(QStringLiteral("symbolsSep")); sep->setFrameShape(QFrame::HLine); sep->setFixedHeight(1); sep->setStyleSheet(QStringLiteral("background: %1; border: none;").arg(t.border.name())); symLayout->addWidget(sep); // Symbols tree m_symbolsTree = new QTreeView(symbolsPage); m_symbolsModel = new QStandardItemModel(this); m_symbolsModel->setHorizontalHeaderLabels({"Name"}); m_symbolsProxy = new QSortFilterProxyModel(this); m_symbolsProxy->setSourceModel(m_symbolsModel); m_symbolsProxy->setFilterCaseSensitivity(Qt::CaseInsensitive); m_symbolsProxy->setRecursiveFilteringEnabled(true); m_symbolsTree->setModel(m_symbolsProxy); m_symbolsTree->setExpandsOnDoubleClick(false); styleTree(m_symbolsTree); // Populate a module item's children (replaces sentinel with real symbols) auto populateModuleItem = [this](QStandardItem* item) { if (!item || item->parent()) return; if (item->rowCount() == 1 && item->child(0)->text().isEmpty()) { item->removeRows(0, 1); QString moduleName = item->data(Qt::UserRole).toString(); const auto* set = rcx::SymbolStore::instance().moduleData(moduleName); if (set) { static const QIcon symIcon(":/vsicons/symbol-method.svg"); for (const auto& sym : set->rvaToName) { auto* child = new QStandardItem(symIcon, QStringLiteral("%1 [0x%2]") .arg(sym.second) .arg(sym.first, 8, 16, QLatin1Char('0'))); child->setData(moduleName, Qt::UserRole); child->setData(sym.first, Qt::UserRole + 1); child->setData(sym.second, Qt::UserRole + 2); item->appendRow(child); } } } }; // Debounced search — force-populate all modules so filter can match children auto* searchTimer = new QTimer(this); searchTimer->setSingleShot(true); searchTimer->setInterval(150); connect(searchTimer, &QTimer::timeout, this, [this, populateModuleItem]() { QString text = m_symbolsSearch->text(); if (!text.isEmpty()) { // Force-populate all modules that still have sentinels for (int i = 0; i < m_symbolsModel->rowCount(); i++) populateModuleItem(m_symbolsModel->item(i)); } m_symbolsProxy->setFilterFixedString(text); if (!text.isEmpty()) m_symbolsTree->expandAll(); else m_symbolsTree->collapseAll(); }); connect(m_symbolsSearch, &QLineEdit::textChanged, this, [searchTimer]() { searchTimer->start(); }); symLayout->addWidget(m_symbolsTree); // Lazy-load children when a module node is expanded connect(m_symbolsTree, &QTreeView::expanded, this, [this, populateModuleItem](const QModelIndex& proxyIdx) { QModelIndex srcIdx = m_symbolsProxy->mapToSource(proxyIdx); populateModuleItem(m_symbolsModel->itemFromIndex(srcIdx)); }); // Double-click symbol → navigate to moduleBase + RVA connect(m_symbolsTree, &QTreeView::doubleClicked, this, [this](const QModelIndex& proxyIdx) { QModelIndex srcIdx = m_symbolsProxy->mapToSource(proxyIdx); auto* item = m_symbolsModel->itemFromIndex(srcIdx); if (!item) return; // Module-level: toggle expand if (!item->parent()) { if (m_symbolsTree->isExpanded(proxyIdx)) m_symbolsTree->collapse(proxyIdx); else m_symbolsTree->expand(proxyIdx); return; } // Symbol-level: navigate to moduleBase + RVA QString moduleName = item->data(Qt::UserRole).toString(); uint32_t rva = item->data(Qt::UserRole + 1).toUInt(); auto* ctrl = activeController(); if (!ctrl || !ctrl->document()->provider) return; uint64_t moduleBase = ctrl->document()->provider->symbolToAddress(moduleName); if (moduleBase == 0) moduleBase = ctrl->document()->provider->symbolToAddress(moduleName + QStringLiteral(".dll")); if (moduleBase == 0) moduleBase = ctrl->document()->provider->symbolToAddress(moduleName + QStringLiteral(".exe")); if (moduleBase == 0) moduleBase = ctrl->document()->provider->symbolToAddress(moduleName + QStringLiteral(".sys")); if (moduleBase == 0) return; uint64_t addr = moduleBase + rva; ctrl->document()->tree.baseAddress = addr; ctrl->document()->tree.baseAddressFormula.clear(); ctrl->resetChangeTracking(); ctrl->refresh(); QString symName = item->data(Qt::UserRole + 2).toString(); setAppStatus(QStringLiteral("Navigated to %1!%2 (0x%3)") .arg(moduleName, symName) .arg(addr, 0, 16)); }); // Context menu for symbols m_symbolsTree->setContextMenuPolicy(Qt::CustomContextMenu); connect(m_symbolsTree, &QWidget::customContextMenuRequested, this, [this](const QPoint& pos) { QModelIndex proxyIdx = m_symbolsTree->indexAt(pos); if (!proxyIdx.isValid()) return; QModelIndex srcIdx = m_symbolsProxy->mapToSource(proxyIdx); auto* item = m_symbolsModel->itemFromIndex(srcIdx); if (!item) return; QMenu menu; if (!item->parent()) { QString moduleName = item->data(Qt::UserRole).toString(); auto* actUnload = menu.addAction("Unload Module"); connect(actUnload, &QAction::triggered, this, [this, moduleName]() { rcx::SymbolStore::instance().unloadModule(moduleName); rebuildSymbolsModel(); if (auto* ctrl = activeController()) ctrl->refresh(); }); } else { QString moduleName = item->data(Qt::UserRole).toString(); QString symName = item->data(Qt::UserRole + 2).toString(); uint32_t rva = item->data(Qt::UserRole + 1).toUInt(); QString fullName = moduleName + QStringLiteral("!") + symName; auto* actCopyName = menu.addAction("Copy Symbol Name"); connect(actCopyName, &QAction::triggered, this, [fullName]() { QApplication::clipboard()->setText(fullName); }); auto* actCopyRva = menu.addAction("Copy RVA"); connect(actCopyRva, &QAction::triggered, this, [rva]() { QApplication::clipboard()->setText( QStringLiteral("0x%1").arg(rva, 8, 16, QLatin1Char('0'))); }); } menu.exec(m_symbolsTree->viewport()->mapToGlobal(pos)); }); m_symTabWidget->addTab(symbolsPage, "Symbols"); } containerLayout->addWidget(m_symTabWidget); // Allow free resizing — remove Qt's default minimum size constraints m_modulesTree->setMinimumWidth(0); m_modulesTree->setMinimumHeight(0); m_symbolsTree->setMinimumWidth(0); m_symbolsTree->setMinimumHeight(0); m_symbolsSearch->setMinimumWidth(0); m_symTabWidget->setMinimumWidth(0); m_symTabWidget->setMinimumHeight(0); container->setMinimumWidth(0); container->setMinimumHeight(0); m_symbolsDock->setWidget(container); addDockWidget(Qt::RightDockWidgetArea, m_symbolsDock); m_symbolsDock->hide(); // Border overlay and resize grip for floating state { auto* border = new BorderOverlay(m_symbolsDock); border->color = t.borderFocused; border->hide(); auto* grip = new ResizeGrip(m_symbolsDock); grip->hide(); connect(m_symbolsDock, &QDockWidget::topLevelChanged, this, [this, border, grip](bool floating) { if (floating) { border->setGeometry(0, 0, m_symbolsDock->width(), m_symbolsDock->height()); border->raise(); border->show(); grip->reposition(); grip->raise(); grip->show(); } else { border->hide(); grip->hide(); } }); m_symbolsDock->installEventFilter(new DockBorderFilter(border, grip, m_symbolsDock)); } } int MainWindow::loadPdbIntoStore(const QString& pdbPath) { QString symErr; auto result = rcx::extractPdbSymbols(pdbPath, &symErr); if (result.symbols.isEmpty()) return 0; QVector> pairs; QHash typeIndices; pairs.reserve(result.symbols.size()); for (const auto& s : result.symbols) { pairs.emplaceBack(s.name, s.rva); if (s.typeIndex != 0) typeIndices.insert(s.name, s.typeIndex); } int count = rcx::SymbolStore::instance().addModule( result.moduleName, pdbPath, pairs); if (!typeIndices.isEmpty()) rcx::SymbolStore::instance().addModuleTypeIndices( result.moduleName, typeIndices); return count; } void MainWindow::rebuildSymbolsModel() { if (!m_symbolsModel) return; m_symbolsModel->clear(); m_symbolsModel->setHorizontalHeaderLabels({"Name"}); auto& store = rcx::SymbolStore::instance(); for (const auto& moduleName : store.loadedModules()) { const auto* set = store.moduleData(moduleName); if (!set) continue; int count = set->nameToRva.size(); static const QIcon modIcon(":/vsicons/symbol-structure.svg"); auto* moduleItem = new QStandardItem(modIcon, QStringLiteral("%1 (%2 symbols)").arg(moduleName).arg(count)); moduleItem->setData(moduleName, Qt::UserRole); moduleItem->setToolTip(set->pdbPath); // Sentinel child for lazy loading (shows expand arrow) moduleItem->appendRow(new QStandardItem()); m_symbolsModel->appendRow(moduleItem); } } void MainWindow::rebuildModulesModel() { if (!m_modulesModel) return; m_modulesModel->clear(); auto* ctrl = activeController(); if (!ctrl || !ctrl->document()->provider) return; auto modules = ctrl->document()->provider->enumerateModules(); if (modules.isEmpty()) return; static const QIcon modIcon(":/vsicons/symbol-structure.svg"); static const QIcon modLoadedIcon(":/vsicons/symbol-key.svg"); auto& store = rcx::SymbolStore::instance(); for (const auto& mod : modules) { QString canonical = store.resolveAlias(mod.name); const auto* symSet = store.moduleData(canonical); bool hasSymbols = (symSet != nullptr); int symCount = hasSymbols ? symSet->nameToRva.size() : 0; QString label = hasSymbols ? QStringLiteral("%1 [0x%2] (%3 KB) \u2713 %4 syms") .arg(mod.name).arg(mod.base, 0, 16).arg(mod.size / 1024).arg(symCount) : QStringLiteral("%1 [0x%2] (%3 KB)") .arg(mod.name).arg(mod.base, 0, 16).arg(mod.size / 1024); auto* item = new QStandardItem(hasSymbols ? modLoadedIcon : modIcon, label); item->setData(QVariant::fromValue(mod.base), Qt::UserRole); item->setData(mod.name, Qt::UserRole + 1); item->setData(mod.fullPath, Qt::UserRole + 2); item->setToolTip(mod.fullPath.isEmpty() ? mod.name : mod.fullPath); m_modulesModel->appendRow(item); } } void MainWindow::downloadSymbolsForProcess() { auto* ctrl = activeController(); if (!ctrl || !ctrl->document()->provider) { setAppStatus(QStringLiteral("No process attached")); return; } auto prov = ctrl->document()->provider; auto modules = prov->enumerateModules(); if (modules.isEmpty()) { setAppStatus(QStringLiteral("No modules found in target process")); return; } // Create downloader on first use if (!m_symDownloader) { m_symDownloader = new rcx::SymbolDownloader(this); connect(m_symDownloader, &rcx::SymbolDownloader::progress, this, [this](const QString& mod, int received, int total) { if (total > 0) setAppStatus(QStringLiteral("Downloading %1... %2/%3 KB") .arg(mod).arg(received/1024).arg(total/1024)); else setAppStatus(QStringLiteral("Downloading %1... %2 KB") .arg(mod).arg(received/1024)); }); connect(m_symDownloader, &rcx::SymbolDownloader::finished, this, [this](const QString& mod, const QString& localPath, bool success, const QString& error) { if (!success) { qDebug() << "[SymbolDownloader]" << mod << "failed:" << error; return; } // Extract symbols and add to store QString symErr; auto result = rcx::extractPdbSymbols(localPath, &symErr); if (!result.symbols.isEmpty()) { QVector> pairs; pairs.reserve(result.symbols.size()); for (const auto& s : result.symbols) pairs.emplaceBack(s.name, s.rva); int count = rcx::SymbolStore::instance().addModule( result.moduleName, localPath, pairs); setAppStatus(QStringLiteral("Loaded %1 symbols for %2") .arg(count).arg(mod)); } rebuildSymbolsModel(); if (auto* c = activeController()) c->refresh(); }); } // Build download queue: skip modules already loaded struct PendingModule { QString name; QString fullPath; uint64_t base; rcx::PdbDebugInfo debugInfo; }; QVector pending; setAppStatus(QStringLiteral("Scanning %1 modules for debug info...").arg(modules.size())); QApplication::processEvents(); auto& store = rcx::SymbolStore::instance(); for (const auto& mod : modules) { // Strip extension for canonical name check QString canonical = store.resolveAlias(mod.name); if (store.moduleData(canonical)) continue; // already loaded // Extract PDB debug info from PE header in memory auto info = rcx::extractPdbDebugInfo(*prov, mod.base); if (!info.valid) continue; // Check local first (same directory as module) QString localPdb = rcx::SymbolDownloader::findLocal(mod.fullPath, info.pdbName); if (!localPdb.isEmpty()) { // Load directly QString symErr; auto result = rcx::extractPdbSymbols(localPdb, &symErr); if (!result.symbols.isEmpty()) { QVector> pairs; pairs.reserve(result.symbols.size()); for (const auto& s : result.symbols) pairs.emplaceBack(s.name, s.rva); int count = store.addModule(result.moduleName, localPdb, pairs); setAppStatus(QStringLiteral("Loaded %1 symbols for %2 (local)") .arg(count).arg(mod.name)); QApplication::processEvents(); } continue; } // Check cache rcx::SymbolDownloader::DownloadRequest req; req.moduleName = mod.name; req.pdbName = info.pdbName; req.guidString = info.guidString; req.age = info.age; QString cached = m_symDownloader->findCached(req); if (!cached.isEmpty()) { QString symErr; auto result = rcx::extractPdbSymbols(cached, &symErr); if (!result.symbols.isEmpty()) { QVector> pairs; pairs.reserve(result.symbols.size()); for (const auto& s : result.symbols) pairs.emplaceBack(s.name, s.rva); int count = store.addModule(result.moduleName, cached, pairs); setAppStatus(QStringLiteral("Loaded %1 symbols for %2 (cached)") .arg(count).arg(mod.name)); QApplication::processEvents(); } continue; } pending.push_back(PendingModule{mod.name, mod.fullPath, mod.base, info}); } rebuildSymbolsModel(); if (pending.isEmpty()) { setAppStatus(QStringLiteral("All available symbols loaded")); if (auto* c = activeController()) c->refresh(); return; } // Download pending modules sequentially auto queue = std::make_shared>(std::move(pending)); auto idx = std::make_shared(0); auto conn = std::make_shared(); auto processNext = [this, queue, idx, conn]() { if (*idx >= queue->size()) { setAppStatus(QStringLiteral("Symbol download complete (%1 modules)") .arg(queue->size())); disconnect(*conn); return; } const auto& mod = (*queue)[*idx]; (*idx)++; rcx::SymbolDownloader::DownloadRequest req; req.moduleName = mod.name; req.pdbName = mod.debugInfo.pdbName; req.guidString = mod.debugInfo.guidString; req.age = mod.debugInfo.age; m_symDownloader->download(req); }; // Chain downloads: when one finishes, start the next *conn = connect(m_symDownloader, &rcx::SymbolDownloader::finished, this, [this, processNext](const QString&, const QString&, bool, const QString&) { QTimer::singleShot(0, this, processNext); }); setAppStatus(QStringLiteral("Downloading symbols for %1 modules...").arg(queue->size())); processNext(); } void MainWindow::rebuildAllDocs() { m_allDocs.clear(); for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) { if (!m_allDocs.contains(it.value().doc)) m_allDocs.append(it.value().doc); } } void MainWindow::rebuildWorkspaceModel() { // Debounce: coalesce rapid calls into a single rebuild if (!m_workspaceRebuildTimer) { m_workspaceRebuildTimer = new QTimer(this); m_workspaceRebuildTimer->setSingleShot(true); m_workspaceRebuildTimer->setInterval(50); connect(m_workspaceRebuildTimer, &QTimer::timeout, this, &MainWindow::rebuildWorkspaceModelNow); } m_workspaceRebuildTimer->start(); } void MainWindow::rebuildWorkspaceModelNow() { QVector tabs; QSet seenDocs; for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) { TabState& tab = it.value(); if (seenDocs.contains(tab.doc)) continue; seenDocs.insert(tab.doc); QString name = rootName(tab.doc->tree, tab.ctrl->viewRootId()); tabs.push_back(rcx::TabInfo{ &tab.doc->tree, name, static_cast(it.key()) }); } rcx::syncProjectExplorer(m_workspaceModel, tabs, m_pinnedIds); // Mark items that are currently viewed in a tab + pinned state QSet viewedIds; for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) viewedIds.insert(it->ctrl->viewRootId()); for (int i = 0; i < m_workspaceModel->rowCount(); ++i) { auto* item = m_workspaceModel->item(i); if (!item) continue; uint64_t id = item->data(Qt::UserRole + 1).toULongLong(); item->setData(viewedIds.contains(id), Qt::UserRole + 3); item->setData(m_pinnedIds.contains(id), Qt::UserRole + 4); } if (m_dockTitleLabel) { int structs = 0, enums = 0; for (int i = 0; i < m_workspaceModel->rowCount(); ++i) { auto* item = m_workspaceModel->item(i); if (!item) continue; if (item->data(Qt::UserRole + 2).toBool()) ++enums; else ++structs; } QString title = QStringLiteral("Project"); if (structs || enums) { title += QStringLiteral(" \u2014 %1 struct%2") .arg(structs).arg(structs != 1 ? "s" : ""); if (enums) title += QStringLiteral(" \u00b7 %1 enum%2") .arg(enums).arg(enums != 1 ? "s" : ""); } m_dockTitleLabel->setText(title); } } void MainWindow::addRecentFile(const QString& path) { if (path.isEmpty()) return; QString absPath = QFileInfo(path).absoluteFilePath(); QSettings s("Reclass", "Reclass"); QStringList recent = s.value("recentFiles").toStringList(); recent.removeAll(absPath); recent.prepend(absPath); while (recent.size() > 10) recent.removeLast(); s.setValue("recentFiles", recent); updateRecentFilesMenu(); } void MainWindow::updateRecentFilesMenu() { if (!m_recentFilesMenu) return; m_recentFilesMenu->clear(); QSettings s("Reclass", "Reclass"); QStringList recent = s.value("recentFiles").toStringList(); int added = 0; for (const QString& path : recent) { if (!QFile::exists(path)) continue; QString label = QStringLiteral("&%1 %2").arg(added + 1).arg(QFileInfo(path).fileName()); m_recentFilesMenu->addAction(label, this, [this, path]() { project_open(path); })->setToolTip(path); if (++added >= 10) break; } if (added == 0) { auto* empty = m_recentFilesMenu->addAction(QStringLiteral("(empty)")); empty->setEnabled(false); } } void MainWindow::populateSourceMenu() { m_sourceMenu->clear(); auto* ctrl = activeController(); // Build saved sources for the shared menu builder QVector saved; if (ctrl) { const auto& ss = ctrl->savedSources(); for (int i = 0; i < ss.size(); i++) { SavedSourceDisplay d; d.text = QStringLiteral("%1 '%2'").arg(ss[i].kind, ss[i].displayName); d.active = (i == ctrl->activeSourceIndex()); saved.append(d); } } ProviderRegistry::populateSourceMenu(m_sourceMenu, saved); } void MainWindow::showPluginsDialog() { QDialog dialog(this); dialog.setWindowTitle("Plugins"); dialog.resize(600, 400); auto* layout = new QVBoxLayout(&dialog); auto* list = new QListWidget(); layout->addWidget(list); auto refreshList = [&]() { list->clear(); // Populate plugin list for (IPlugin* plugin : m_pluginManager.plugins()) { QString typeStr; switch (plugin->Type()) { case IPlugin::ProviderPlugin: typeStr = "Provider"; break; default: typeStr = "Unknown"; break; } QString text = QString("%1 v%2\n %3\n Type: %4\n Author: %5") .arg(QString::fromStdString(plugin->Name())) .arg(QString::fromStdString(plugin->Version())) .arg(QString::fromStdString(plugin->Description())) .arg(typeStr) .arg(QString::fromStdString(plugin->Author())); auto* item = new QListWidgetItem(plugin->Icon(), text); item->setData(Qt::UserRole, QString::fromStdString(plugin->Name())); list->addItem(item); } if (m_pluginManager.plugins().isEmpty()) { list->addItem("No plugins loaded"); } }; refreshList(); // Button row auto* btnLayout = new QHBoxLayout(); auto* btnLoad = new QPushButton("Load Plugin..."); connect(btnLoad, &QPushButton::clicked, [&, refreshList]() { QString path = QFileDialog::getOpenFileName(&dialog, "Load Plugin", QCoreApplication::applicationDirPath() + "/Plugins", "Plugins (*.dll *.so *.dylib);;All Files (*)"); if (!path.isEmpty()) { if (m_pluginManager.LoadPluginFromPath(path)) { refreshList(); setAppStatus("Plugin loaded successfully"); } else { QMessageBox::warning(&dialog, "Failed to Load Plugin", "Could not load the selected plugin.\nCheck the console for details."); } } }); auto* btnUnload = new QPushButton("Unload Selected"); connect(btnUnload, &QPushButton::clicked, [&, list, refreshList]() { auto* item = list->currentItem(); if (!item) { QMessageBox::information(&dialog, "No Selection", "Please select a plugin to unload."); return; } QString pluginName = item->data(Qt::UserRole).toString(); if (pluginName.isEmpty()) return; auto reply = QMessageBox::question(&dialog, "Unload Plugin", QString("Are you sure you want to unload '%1'?").arg(pluginName), QMessageBox::Yes | QMessageBox::No); if (reply == QMessageBox::Yes) { if (m_pluginManager.UnloadPlugin(pluginName)) { refreshList(); setAppStatus("Plugin unloaded"); } else { QMessageBox::warning(&dialog, "Failed to Unload", "Could not unload the selected plugin."); } } }); auto* btnClose = new QPushButton("Close"); connect(btnClose, &QPushButton::clicked, &dialog, &QDialog::accept); btnLayout->addWidget(btnLoad); btnLayout->addWidget(btnUnload); btnLayout->addStretch(); btnLayout->addWidget(btnClose); layout->addLayout(btnLayout); dialog.exec(); } void MainWindow::changeEvent(QEvent* event) { QMainWindow::changeEvent(event); if (event->type() == QEvent::ActivationChange) { const auto& t = ThemeManager::instance().current(); updateBorderColor(isActiveWindow() ? t.borderFocused : t.border); if (!isActiveWindow()) { for (auto& tab : m_tabs) for (auto& pane : tab.panes) if (pane.editor) pane.editor->dismissAllPopups(); } } if (event->type() == QEvent::WindowStateChange && m_titleBar) m_titleBar->updateMaximizeIcon(); // Keep border overlay on top after any state change if (m_borderOverlay) { m_borderOverlay->setGeometry(rect()); m_borderOverlay->raise(); } } void MainWindow::resizeEvent(QResizeEvent* event) { QMainWindow::resizeEvent(event); if (m_borderOverlay) { m_borderOverlay->setGeometry(rect()); m_borderOverlay->raise(); } if (auto* w = findChild("resizeGrip")) { auto* grip = static_cast(w); grip->reposition(); grip->raise(); } } void MainWindow::updateBorderColor(const QColor& color) { static_cast(m_borderOverlay)->color = color; m_borderOverlay->update(); } void MainWindow::showStartPage() { if (m_startPage) return; m_startPage = new StartPageWidget(this); m_startPage->applyTheme(ThemeManager::instance().current()); // Size the popup to ~90% of the main window QSize sz(qBound(900, int(width() * 0.9), width() - 20), qBound(560, int(height() * 0.85), height() - 20)); 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; auto* sp = m_startPage; m_startPage = nullptr; // null first — close() may re-enter via rejected signal sp->close(); sp->deleteLater(); } } // namespace rcx // ── Entry point ── int main(int argc, char* argv[]) { #ifdef _WIN32 SetUnhandledExceptionFilter(crashHandler); #endif #ifdef Q_OS_MACOS QCoreApplication::setAttribute(Qt::AA_DontUseNativeDialogs); #endif DarkApp app(argc, argv); app.setApplicationName("Reclass"); app.setOrganizationName("Reclass"); app.setStyle(new MenuBarStyle("Fusion")); // Fusion + generous menu sizing // Load embedded fonts int fontId = QFontDatabase::addApplicationFont(":/fonts/JetBrainsMono.ttf"); if (fontId == -1) qWarning("Failed to load embedded JetBrains Mono font"); // Apply saved font preference before creating any editors { QSettings settings("Reclass", "Reclass"); QString savedFont = settings.value("font", "JetBrains Mono").toString(); rcx::RcxEditor::setGlobalFontName(savedFont); } // Global theme applyGlobalTheme(rcx::ThemeManager::instance().current()); rcx::MainWindow window; window.setWindowIcon(QIcon(":/icons/class.png")); window.show(); #ifdef __linux__ window.showMaximized(); #endif // --screenshot : open default project, grab window, save, exit { QStringList args = app.arguments(); int ssIdx = args.indexOf("--screenshot"); if (ssIdx >= 0 && ssIdx + 1 < args.size()) { QString ssPath = args[ssIdx + 1]; QMetaObject::invokeMethod(&window, [&window, ssPath]() { window.project_new(); QTimer::singleShot(500, &window, [&window, ssPath]() { QPixmap px = window.grab(); px.save(ssPath); qApp->quit(); }); }, Qt::QueuedConnection); return app.exec(); } } // Show VS2022-style start page instead of jumping straight to demo QMetaObject::invokeMethod(&window, "showStartPage", Qt::QueuedConnection); return app.exec(); }