From a86912add1c22e5d7d38d3f44ee462528171e65d Mon Sep 17 00:00:00 2001 From: IChooseYou Date: Fri, 13 Feb 2026 16:23:12 -0700 Subject: [PATCH] Theme system overhaul, UI polish, and VS2022 Dark theme - Replaced hardcoded theme factories with JSON files + CMake build step - Shared ThemeFieldMeta table for DRY serialization and editor UI - Fixed live preview (auto-triggers on color change, no toggle button) - Fixed duplicate theme entries when editing built-in themes - Moved title bar from icon to bold "Reclass" text with View > Show Icon toggle - MDI tabs: 24px height, unicode close button styled like TypeSelectorPopup - Added VS2022 Dark theme with purple accent colors - Status bar padding, removed monospace font overrides on tabs/statusbar - Default startup opens Ball demo + Unnamed hex64 tabs --- CMakeLists.txt | 8 ++ src/main.cpp | 122 ++++++++++++++------- src/mainwindow.h | 1 + src/resources.qrc | 1 + src/themes/defaults/reclass_dark.json | 29 +++++ src/themes/defaults/vs.json | 29 +++++ src/themes/defaults/warm.json | 29 +++++ src/themes/theme.cpp | 140 +++++++----------------- src/themes/theme.h | 15 ++- src/themes/themeeditor.cpp | 147 ++++++++------------------ src/themes/themeeditor.h | 4 - src/themes/thememanager.cpp | 101 ++++++++++++++---- src/themes/thememanager.h | 9 +- src/titlebar.cpp | 46 ++++---- src/titlebar.h | 4 +- tests/test_theme.cpp | 72 +++++++------ 16 files changed, 429 insertions(+), 328 deletions(-) create mode 100644 src/themes/defaults/reclass_dark.json create mode 100644 src/themes/defaults/vs.json create mode 100644 src/themes/defaults/warm.json diff --git a/CMakeLists.txt b/CMakeLists.txt index db0b937..0f22188 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,6 +85,14 @@ endif() add_executable(ReclassMcpBridge tools/rcx-mcp-stdio.cpp) target_link_libraries(ReclassMcpBridge PRIVATE ${QT}::Core ${QT}::Network) +# Copy built-in theme JSON files to build directory +file(GLOB _theme_files "${CMAKE_SOURCE_DIR}/src/themes/defaults/*.json") +file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/themes") +foreach(_tf ${_theme_files}) + get_filename_component(_name ${_tf} NAME) + configure_file(${_tf} "${CMAKE_BINARY_DIR}/themes/${_name}" COPYONLY) +endforeach() + include(deploy) add_custom_target(screenshot ALL diff --git a/src/main.cpp b/src/main.cpp index cc0c30a..84b810f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -288,21 +288,13 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { const auto& t = ThemeManager::instance().current(); m_mdiArea->setStyleSheet(QStringLiteral( "QTabBar::tab {" - " background: %1; color: %2; padding: 6px 16px; border: none;" + " background: %1; color: %2; padding: 0px 16px; border: none; height: 24px;" "}" "QTabBar::tab:selected { color: %3; background: %4; }" "QTabBar::tab:hover { color: %3; background: %5; }") .arg(t.background.name(), t.textMuted.name(), t.text.name(), t.backgroundAlt.name(), t.hover.name())); } - { - QSettings settings("Reclass", "Reclass"); - QString fontName = settings.value("font", "JetBrains Mono").toString(); - QFont f(fontName, 12); - f.setFixedPitch(true); - if (auto* tb = m_mdiArea->findChild()) - tb->setFont(f); - } setCentralWidget(m_mdiArea); createWorkspaceDock(); @@ -410,6 +402,15 @@ void MainWindow::createMenus() { themeMenu->addAction("Edit Theme...", this, &MainWindow::editTheme); view->addSeparator(); + auto* actShowIcon = view->addAction("Show &Icon"); + actShowIcon->setCheckable(true); + actShowIcon->setChecked(settings.value("showIcon", false).toBool()); + if (actShowIcon->isChecked()) m_titleBar->setShowIcon(true); + connect(actShowIcon, &QAction::toggled, this, [this](bool checked) { + m_titleBar->setShowIcon(checked); + QSettings s("Reclass", "Reclass"); + s.setValue("showIcon", checked); + }); view->addAction(m_workspaceDock->toggleViewAction()); // Node @@ -432,6 +433,7 @@ void MainWindow::createMenus() { void MainWindow::createStatusBar() { m_statusLabel = new QLabel("Ready"); m_statusLabel->setContentsMargins(10, 0, 0, 0); + statusBar()->setContentsMargins(0, 4, 0, 4); statusBar()->addWidget(m_statusLabel, 1); { const auto& t = ThemeManager::instance().current(); @@ -441,20 +443,9 @@ void MainWindow::createStatusBar() { statusBar()->setPalette(sbPal); statusBar()->setAutoFillBackground(true); } - - QSettings settings("Reclass", "Reclass"); - QString fontName = settings.value("font", "JetBrains Mono").toString(); - QFont f(fontName, 12); - f.setFixedPitch(true); - statusBar()->setFont(f); } void MainWindow::applyTabWidgetStyle(QTabWidget* tw) { - QSettings settings("Reclass", "Reclass"); - QString fontName = settings.value("font", "JetBrains Mono").toString(); - QFont tabFont(fontName, 12); - tabFont.setFixedPitch(true); - tw->tabBar()->setFont(tabFont); const auto& t = ThemeManager::instance().current(); tw->setStyleSheet(QStringLiteral( "QTabWidget::pane { border: none; }" @@ -468,6 +459,37 @@ void MainWindow::applyTabWidgetStyle(QTabWidget* tw) { tw->tabBar()->setExpanding(false); } +void MainWindow::styleTabCloseButtons() { + auto* tabBar = m_mdiArea->findChild(); + if (!tabBar) return; + + const auto& t = ThemeManager::instance().current(); + QString style = QStringLiteral( + "QToolButton { color: %1; border: none; padding: 0px 4px; font-size: 12px; }" + "QToolButton:hover { color: %2; }") + .arg(t.textDim.name(), t.indHoverSpan.name()); + + auto subs = m_mdiArea->subWindowList(); + for (int i = 0; i < tabBar->count() && i < subs.size(); i++) { + auto* existing = qobject_cast( + tabBar->tabButton(i, QTabBar::RightSide)); + if (existing && existing->text() == QStringLiteral("\u2715")) { + // Already our button, just restyle + existing->setStyleSheet(style); + continue; + } + // Replace with ✕ text button + auto* btn = new QToolButton(tabBar); + btn->setText(QStringLiteral("\u2715")); + btn->setAutoRaise(true); + btn->setCursor(Qt::PointingHandCursor); + btn->setStyleSheet(style); + QMdiSubWindow* sub = subs[i]; + connect(btn, &QToolButton::clicked, sub, &QMdiSubWindow::close); + tabBar->setTabButton(i, QTabBar::RightSide, btn); + } +} + MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) { SplitPane pane; @@ -656,6 +678,7 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) { ctrl->refresh(); rebuildWorkspaceModel(); + styleTabCloseButtons(); return sub; } @@ -762,7 +785,41 @@ void MainWindow::newDocument() { } void MainWindow::selfTest() { + // Tab 1: Ball demo project_new(); + + // Tab 2: Unnamed struct with hex64 fields + { + auto* doc = new RcxDocument(this); + QByteArray data(256, '\0'); + doc->loadData(data); + doc->tree.baseAddress = 0x00400000; + + Node s; + s.kind = NodeKind::Struct; + s.name = "instance"; + s.structTypeName = "Unnamed"; + s.parentId = 0; + s.offset = 0; + int si = doc->tree.addNode(s); + uint64_t sId = doc->tree.nodes[si].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 = sId; + n.offset = i * 8; + doc->tree.addNode(n); + } + + createTab(doc); + rebuildWorkspaceModel(); + } + + // Focus Ball tab + if (auto* first = m_mdiArea->subWindowList().value(0)) + m_mdiArea->setActiveSubWindow(first); } void MainWindow::openFile() { @@ -930,13 +987,16 @@ void MainWindow::applyTheme(const Theme& theme) { // MDI area tabs m_mdiArea->setStyleSheet(QStringLiteral( "QTabBar::tab {" - " background: %1; color: %2; padding: 6px 16px; border: none;" + " background: %1; color: %2; padding: 0px 16px; border: none; height: 24px;" "}" "QTabBar::tab:selected { color: %3; background: %4; }" "QTabBar::tab:hover { color: %3; background: %5; }") .arg(theme.background.name(), theme.textMuted.name(), theme.text.name(), theme.backgroundAlt.name(), theme.hover.name())); + // Re-style ✕ close buttons on MDI tabs + styleTabCloseButtons(); + // Status bar { QPalette sbPal = statusBar()->palette(); @@ -958,16 +1018,7 @@ void MainWindow::editTheme() { int idx = tm.currentIndex(); ThemeEditor dlg(idx, this); if (dlg.exec() == QDialog::Accepted) { - tm.revertPreview(); - int selectedIdx = dlg.selectedIndex(); - Theme edited = dlg.result(); - // Switch to selected theme first (if changed) - if (selectedIdx != idx && selectedIdx >= 0 && selectedIdx < tm.themes().size()) - tm.setCurrent(selectedIdx); - // Apply edits - int applyIdx = selectedIdx >= 0 ? selectedIdx : idx; - if (applyIdx >= 0 && applyIdx < tm.themes().size()) - tm.updateTheme(applyIdx, edited); + tm.updateTheme(dlg.selectedIndex(), dlg.result()); } else { tm.revertPreview(); } @@ -991,9 +1042,6 @@ void MainWindow::setEditorFont(const QString& fontName) { } pane.rendered->setMarginsFont(f); } - // Update per-pane tab bar font - if (pane.tabWidget) - applyTabWidgetStyle(pane.tabWidget); } } // Sync workspace tree font @@ -1001,11 +1049,6 @@ void MainWindow::setEditorFont(const QString& fontName) { m_workspaceTree->setFont(f); // Sync status bar font statusBar()->setFont(f); - // Sync MDI tab bar font - if (auto* tb = m_mdiArea->findChild()) - tb->setFont(f); - // Sync menu bar / menu font via global stylesheet - applyGlobalTheme(ThemeManager::instance().current()); } RcxController* MainWindow::activeController() const { @@ -1045,7 +1088,6 @@ void MainWindow::updateWindowTitle() { title = "Reclass"; } setWindowTitle(title); - m_titleBar->setTitle(title); } // ── Rendered view setup ── diff --git a/src/mainwindow.h b/src/mainwindow.h index 4c0ffd4..6ac1768 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -107,6 +107,7 @@ private: SplitPane createSplitPane(TabState& tab); void applyTheme(const Theme& theme); void applyTabWidgetStyle(QTabWidget* tw); + void styleTabCloseButtons(); SplitPane* findPaneByTabWidget(QTabWidget* tw); SplitPane* findActiveSplitPane(); RcxEditor* activePaneEditor(); diff --git a/src/resources.qrc b/src/resources.qrc index 0c64cc9..8ae6a26 100644 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -3,6 +3,7 @@ icons/chevron-right.png icons/chevron-down.png icons/class.png + fonts/JetBrainsMono.ttf diff --git a/src/themes/defaults/reclass_dark.json b/src/themes/defaults/reclass_dark.json new file mode 100644 index 0000000..2cba2af --- /dev/null +++ b/src/themes/defaults/reclass_dark.json @@ -0,0 +1,29 @@ +{ + "name": "Reclass Dark", + "background": "#1e1e1e", + "backgroundAlt": "#252526", + "surface": "#2a2d2e", + "border": "#3c3c3c", + "borderFocused": "#888888", + "button": "#333333", + "text": "#d4d4d4", + "textDim": "#858585", + "textMuted": "#585858", + "textFaint": "#505050", + "hover": "#2b2b2b", + "selected": "#232323", + "selection": "#2b2b2b", + "syntaxKeyword": "#569cd6", + "syntaxNumber": "#b5cea8", + "syntaxString": "#ce9178", + "syntaxComment": "#6a9955", + "syntaxPreproc": "#c586c0", + "syntaxType": "#4EC9B0", + "indHoverSpan": "#E6B450", + "indCmdPill": "#2a2a2a", + "indDataChanged": "#8fbc7a", + "indHintGreen": "#5a8248", + "markerPtr": "#f44747", + "markerCycle": "#e5a00d", + "markerError": "#7a2e2e" +} diff --git a/src/themes/defaults/vs.json b/src/themes/defaults/vs.json new file mode 100644 index 0000000..0c6de9b --- /dev/null +++ b/src/themes/defaults/vs.json @@ -0,0 +1,29 @@ +{ + "name": "VS2022 Dark", + "background": "#1e1e1e", + "backgroundAlt": "#2d2d30", + "surface": "#333337", + "border": "#3f3f46", + "borderFocused": "#b180d7", + "button": "#3f3f46", + "text": "#dcdcdc", + "textDim": "#858585", + "textMuted": "#636369", + "textFaint": "#4d4d55", + "hover": "#3e3e42", + "selected": "#2d2d30", + "selection": "#264f78", + "syntaxKeyword": "#569cd6", + "syntaxNumber": "#b5cea8", + "syntaxString": "#d69d85", + "syntaxComment": "#57a64a", + "syntaxPreproc": "#9b9b9b", + "syntaxType": "#4ec9b0", + "indHoverSpan": "#b180d7", + "indCmdPill": "#2d2d30", + "indDataChanged": "#8fbc7a", + "indHintGreen": "#5a8248", + "markerPtr": "#f44747", + "markerCycle": "#e5a00d", + "markerError": "#7a2e2e" +} diff --git a/src/themes/defaults/warm.json b/src/themes/defaults/warm.json new file mode 100644 index 0000000..0f1d4af --- /dev/null +++ b/src/themes/defaults/warm.json @@ -0,0 +1,29 @@ +{ + "name": "Warm", + "background": "#212121", + "backgroundAlt": "#2a2a2a", + "surface": "#2a2a2a", + "border": "#373737", + "borderFocused": "#888888", + "button": "#373737", + "text": "#AAA99F", + "textDim": "#7a7a6e", + "textMuted": "#555550", + "textFaint": "#464646", + "hover": "#373737", + "selected": "#2d2d2d", + "selection": "#21213A", + "syntaxKeyword": "#AA9565", + "syntaxNumber": "#AAA98C", + "syntaxString": "#6B3B21", + "syntaxComment": "#464646", + "syntaxPreproc": "#AA9565", + "syntaxType": "#6B959F", + "indHoverSpan": "#AA9565", + "indCmdPill": "#2a2a2a", + "indDataChanged": "#6B959F", + "indHintGreen": "#464646", + "markerPtr": "#6B3B21", + "markerCycle": "#AA9565", + "markerError": "#3C2121" +} diff --git a/src/themes/theme.cpp b/src/themes/theme.cpp index 634176d..e97750e 100644 --- a/src/themes/theme.cpp +++ b/src/themes/theme.cpp @@ -1,122 +1,56 @@ #include "theme.h" +#include namespace rcx { -// ── Field table for DRY serialization ── +// ── Shared field metadata (serialization + editor UI) ── -struct ColorField { const char* key; QColor Theme::*ptr; }; - -static const ColorField kFields[] = { - {"background", &Theme::background}, - {"backgroundAlt", &Theme::backgroundAlt}, - {"surface", &Theme::surface}, - {"border", &Theme::border}, - {"borderFocused", &Theme::borderFocused}, - {"button", &Theme::button}, - {"text", &Theme::text}, - {"textDim", &Theme::textDim}, - {"textMuted", &Theme::textMuted}, - {"textFaint", &Theme::textFaint}, - {"hover", &Theme::hover}, - {"selected", &Theme::selected}, - {"selection", &Theme::selection}, - {"syntaxKeyword", &Theme::syntaxKeyword}, - {"syntaxNumber", &Theme::syntaxNumber}, - {"syntaxString", &Theme::syntaxString}, - {"syntaxComment", &Theme::syntaxComment}, - {"syntaxPreproc", &Theme::syntaxPreproc}, - {"syntaxType", &Theme::syntaxType}, - {"indHoverSpan", &Theme::indHoverSpan}, - {"indCmdPill", &Theme::indCmdPill}, - {"indDataChanged",&Theme::indDataChanged}, - {"indHintGreen", &Theme::indHintGreen}, - {"markerPtr", &Theme::markerPtr}, - {"markerCycle", &Theme::markerCycle}, - {"markerError", &Theme::markerError}, +const ThemeFieldMeta kThemeFields[] = { + {"background", "Background", "Chrome", &Theme::background}, + {"backgroundAlt", "Background Alt", "Chrome", &Theme::backgroundAlt}, + {"surface", "Surface", "Chrome", &Theme::surface}, + {"border", "Border", "Chrome", &Theme::border}, + {"borderFocused", "Border Focused", "Chrome", &Theme::borderFocused}, + {"button", "Button", "Chrome", &Theme::button}, + {"text", "Text", "Text", &Theme::text}, + {"textDim", "Text Dim", "Text", &Theme::textDim}, + {"textMuted", "Text Muted", "Text", &Theme::textMuted}, + {"textFaint", "Text Faint", "Text", &Theme::textFaint}, + {"hover", "Hover", "Interactive", &Theme::hover}, + {"selected", "Selected", "Interactive", &Theme::selected}, + {"selection", "Selection", "Interactive", &Theme::selection}, + {"syntaxKeyword", "Keyword", "Syntax", &Theme::syntaxKeyword}, + {"syntaxNumber", "Number", "Syntax", &Theme::syntaxNumber}, + {"syntaxString", "String", "Syntax", &Theme::syntaxString}, + {"syntaxComment", "Comment", "Syntax", &Theme::syntaxComment}, + {"syntaxPreproc", "Preprocessor", "Syntax", &Theme::syntaxPreproc}, + {"syntaxType", "Type", "Syntax", &Theme::syntaxType}, + {"indHoverSpan", "Hover Span", "Indicators", &Theme::indHoverSpan}, + {"indCmdPill", "Cmd Pill", "Indicators", &Theme::indCmdPill}, + {"indDataChanged","Data Changed", "Indicators", &Theme::indDataChanged}, + {"indHintGreen", "Hint Green", "Indicators", &Theme::indHintGreen}, + {"markerPtr", "Pointer", "Markers", &Theme::markerPtr}, + {"markerCycle", "Cycle", "Markers", &Theme::markerCycle}, + {"markerError", "Error", "Markers", &Theme::markerError}, }; +const int kThemeFieldCount = static_cast(std::extent_v); QJsonObject Theme::toJson() const { QJsonObject o; o["name"] = name; - for (const auto& f : kFields) - o[f.key] = (this->*f.ptr).name(); + for (int i = 0; i < kThemeFieldCount; i++) + o[kThemeFields[i].key] = (this->*kThemeFields[i].ptr).name(); return o; } Theme Theme::fromJson(const QJsonObject& o) { - Theme t = reclassDark(); - t.name = o["name"].toString(t.name); - for (const auto& f : kFields) { - if (o.contains(f.key)) - t.*f.ptr = QColor(o[f.key].toString()); + Theme t; + t.name = o["name"].toString("Untitled"); + for (int i = 0; i < kThemeFieldCount; i++) { + if (o.contains(kThemeFields[i].key)) + t.*kThemeFields[i].ptr = QColor(o[kThemeFields[i].key].toString()); } return t; } -// ── Built-in themes ── - -Theme Theme::reclassDark() { - Theme t; - t.name = "Reclass Dark"; - t.background = QColor("#1e1e1e"); - t.backgroundAlt = QColor("#252526"); - t.surface = QColor("#2a2d2e"); - t.border = QColor("#3c3c3c"); - t.borderFocused = QColor("#64e6b450"); // indHoverSpan at ~40% alpha - t.button = QColor("#333333"); - t.text = QColor("#d4d4d4"); - t.textDim = QColor("#858585"); - t.textMuted = QColor("#585858"); - t.textFaint = QColor("#505050"); - t.hover = QColor("#2b2b2b"); - t.selected = QColor("#232323"); - t.selection = QColor("#2b2b2b"); - t.syntaxKeyword = QColor("#569cd6"); - t.syntaxNumber = QColor("#b5cea8"); - t.syntaxString = QColor("#ce9178"); - t.syntaxComment = QColor("#6a9955"); - t.syntaxPreproc = QColor("#c586c0"); - t.syntaxType = QColor("#4EC9B0"); - t.indHoverSpan = QColor("#E6B450"); - t.indCmdPill = QColor("#2a2a2a"); - t.indDataChanged= QColor("#8fbc7a"); - t.indHintGreen = QColor("#5a8248"); - t.markerPtr = QColor("#f44747"); - t.markerCycle = QColor("#e5a00d"); - t.markerError = QColor("#7a2e2e"); - return t; -} - -Theme Theme::warm() { - Theme t; - t.name = "Warm"; - t.background = QColor("#212121"); - t.backgroundAlt = QColor("#2a2a2a"); - t.surface = QColor("#2a2a2a"); - t.border = QColor("#373737"); - t.borderFocused = QColor("#64aa9565"); // indHoverSpan at ~40% alpha - t.button = QColor("#373737"); - t.text = QColor("#AAA99F"); - t.textDim = QColor("#7a7a6e"); - t.textMuted = QColor("#555550"); - t.textFaint = QColor("#464646"); - t.hover = QColor("#373737"); - t.selected = QColor("#2d2d2d"); - t.selection = QColor("#21213A"); - t.syntaxKeyword = QColor("#AA9565"); - t.syntaxNumber = QColor("#AAA98C"); - t.syntaxString = QColor("#6B3B21"); - t.syntaxComment = QColor("#464646"); - t.syntaxPreproc = QColor("#AA9565"); - t.syntaxType = QColor("#6B959F"); - t.indHoverSpan = QColor("#AA9565"); - t.indCmdPill = QColor("#2a2a2a"); - t.indDataChanged= QColor("#6B959F"); - t.indHintGreen = QColor("#464646"); - t.markerPtr = QColor("#6B3B21"); - t.markerCycle = QColor("#AA9565"); - t.markerError = QColor("#3C2121"); - return t; -} - } // namespace rcx diff --git a/src/themes/theme.h b/src/themes/theme.h index 7555bce..6cb7234 100644 --- a/src/themes/theme.h +++ b/src/themes/theme.h @@ -48,9 +48,18 @@ struct Theme { QJsonObject toJson() const; static Theme fromJson(const QJsonObject& obj); - - static Theme reclassDark(); - static Theme warm(); }; +// ── Shared field metadata (serialization + editor UI) ── + +struct ThemeFieldMeta { + const char* key; // JSON key + const char* label; // display label + const char* group; // section group name + QColor Theme::*ptr; +}; + +extern const ThemeFieldMeta kThemeFields[]; +extern const int kThemeFieldCount; + } // namespace rcx diff --git a/src/themes/themeeditor.cpp b/src/themes/themeeditor.cpp index 4a2db83..1c33338 100644 --- a/src/themes/themeeditor.cpp +++ b/src/themes/themeeditor.cpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace rcx { @@ -70,7 +71,7 @@ ThemeEditor::ThemeEditor(int themeIndex, QWidget* parent) : QStringLiteral("File: %1").arg(path)); mainLayout->addWidget(m_fileInfoLabel); - // ── Scrollable area for swatches + contrast ── + // ── Scrollable area for swatches ── auto* scroll = new QScrollArea; scroll->setWidgetResizable(true); scroll->setFrameShape(QFrame::NoFrame); @@ -79,84 +80,49 @@ ThemeEditor::ThemeEditor(int themeIndex, QWidget* parent) scrollLayout->setContentsMargins(0, 0, 6, 0); // right margin for scrollbar scrollLayout->setSpacing(2); - // ── Color swatches ── - struct FieldDef { const char* label; QColor Theme::*ptr; }; + // ── Color swatches (driven by kThemeFields) ── + const char* currentGroup = nullptr; + for (int fi = 0; fi < kThemeFieldCount; fi++) { + const auto& f = kThemeFields[fi]; - auto addGroup = [&](const QString& title, std::initializer_list fields) { - scrollLayout->addWidget(makeSectionLabel(title)); - for (const auto& f : fields) { - int idx = m_swatches.size(); - - auto* row = new QHBoxLayout; - row->setSpacing(6); - row->setContentsMargins(8, 1, 0, 1); - - auto* lbl = new QLabel(QString::fromLatin1(f.label)); - lbl->setFixedWidth(120); - row->addWidget(lbl); - - auto* swatchBtn = new QPushButton; - swatchBtn->setFixedSize(32, 18); - swatchBtn->setCursor(Qt::PointingHandCursor); - connect(swatchBtn, &QPushButton::clicked, this, [this, idx]() { pickColor(idx); }); - row->addWidget(swatchBtn); - - auto* hexLbl = new QLabel; - hexLbl->setFixedWidth(60); - hexLbl->setStyleSheet(QStringLiteral("color: #aaa; font-size: 10px;")); - row->addWidget(hexLbl); - - row->addStretch(); - - SwatchEntry se; - se.label = f.label; - se.field = f.ptr; - se.swatchBtn = swatchBtn; - se.hexLabel = hexLbl; - m_swatches.append(se); - - scrollLayout->addLayout(row); + // Section header on group change + if (!currentGroup || std::strcmp(currentGroup, f.group) != 0) { + scrollLayout->addWidget(makeSectionLabel(QString::fromLatin1(f.group))); + currentGroup = f.group; } - }; - addGroup("Chrome", { - {"Background", &Theme::background}, - {"Background Alt", &Theme::backgroundAlt}, - {"Surface", &Theme::surface}, - {"Border", &Theme::border}, - {"Border Focused", &Theme::borderFocused}, - {"Button", &Theme::button}, - }); - addGroup("Text", { - {"Text", &Theme::text}, - {"Text Dim", &Theme::textDim}, - {"Text Muted", &Theme::textMuted}, - {"Text Faint", &Theme::textFaint}, - }); - addGroup("Interactive", { - {"Hover", &Theme::hover}, - {"Selected", &Theme::selected}, - {"Selection", &Theme::selection}, - }); - addGroup("Syntax", { - {"Keyword", &Theme::syntaxKeyword}, - {"Number", &Theme::syntaxNumber}, - {"String", &Theme::syntaxString}, - {"Comment", &Theme::syntaxComment}, - {"Preprocessor", &Theme::syntaxPreproc}, - {"Type", &Theme::syntaxType}, - }); - addGroup("Indicators", { - {"Hover Span", &Theme::indHoverSpan}, - {"Cmd Pill", &Theme::indCmdPill}, - {"Data Changed", &Theme::indDataChanged}, - {"Hint Green", &Theme::indHintGreen}, - }); - addGroup("Markers", { - {"Pointer", &Theme::markerPtr}, - {"Cycle", &Theme::markerCycle}, - {"Error", &Theme::markerError}, - }); + int idx = m_swatches.size(); + + auto* row = new QHBoxLayout; + row->setSpacing(6); + row->setContentsMargins(8, 1, 0, 1); + + auto* lbl = new QLabel(QString::fromLatin1(f.label)); + lbl->setFixedWidth(120); + row->addWidget(lbl); + + auto* swatchBtn = new QPushButton; + swatchBtn->setFixedSize(32, 18); + swatchBtn->setCursor(Qt::PointingHandCursor); + connect(swatchBtn, &QPushButton::clicked, this, [this, idx]() { pickColor(idx); }); + row->addWidget(swatchBtn); + + auto* hexLbl = new QLabel; + hexLbl->setFixedWidth(60); + hexLbl->setStyleSheet(QStringLiteral("color: #aaa; font-size: 10px;")); + row->addWidget(hexLbl); + + row->addStretch(); + + SwatchEntry se; + se.label = f.label; + se.field = f.ptr; + se.swatchBtn = swatchBtn; + se.hexLabel = hexLbl; + m_swatches.append(se); + + scrollLayout->addLayout(row); + } scrollLayout->addStretch(); scroll->setWidget(scrollWidget); @@ -164,28 +130,21 @@ ThemeEditor::ThemeEditor(int themeIndex, QWidget* parent) // ── Bottom bar ── auto* bottomRow = new QHBoxLayout; - m_previewBtn = new QPushButton(QStringLiteral("Live Preview")); - m_previewBtn->setCheckable(true); - connect(m_previewBtn, &QPushButton::toggled, this, [this](bool) { togglePreview(); }); - bottomRow->addWidget(m_previewBtn); - bottomRow->addStretch(); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(buttons, &QDialogButtonBox::rejected, this, [this]() { - if (m_previewing) { - ThemeManager::instance().revertPreview(); - m_previewing = false; - } + ThemeManager::instance().revertPreview(); reject(); }); bottomRow->addWidget(buttons); mainLayout->addLayout(bottomRow); - // Initial update + // Initial swatch update + start live preview for (int i = 0; i < m_swatches.size(); i++) updateSwatch(i); + tm.previewTheme(m_theme); } // ── Load a different theme into the editor ── @@ -207,8 +166,7 @@ void ThemeEditor::loadTheme(int index) { for (int i = 0; i < m_swatches.size(); i++) updateSwatch(i); - if (m_previewing) - tm.previewTheme(m_theme); + tm.previewTheme(m_theme); } // ── Swatch update ── @@ -231,19 +189,8 @@ void ThemeEditor::pickColor(int idx) { if (c.isValid()) { m_theme.*s.field = c; updateSwatch(idx); - if (m_previewing) - ThemeManager::instance().previewTheme(m_theme); + ThemeManager::instance().previewTheme(m_theme); } } -// ── Live preview toggle ── - -void ThemeEditor::togglePreview() { - m_previewing = m_previewBtn->isChecked(); - if (m_previewing) - ThemeManager::instance().previewTheme(m_theme); - else - ThemeManager::instance().revertPreview(); -} - } // namespace rcx diff --git a/src/themes/themeeditor.h b/src/themes/themeeditor.h index 2636945..e69a6ff 100644 --- a/src/themes/themeeditor.h +++ b/src/themes/themeeditor.h @@ -36,14 +36,10 @@ private: QComboBox* m_themeCombo = nullptr; QLineEdit* m_nameEdit = nullptr; QLabel* m_fileInfoLabel = nullptr; - QPushButton* m_previewBtn = nullptr; - bool m_previewing = false; void loadTheme(int index); - void rebuildSwatches(QVBoxLayout* swatchLayout); void updateSwatch(int idx); void pickColor(int idx); - void togglePreview(); }; } // namespace rcx diff --git a/src/themes/thememanager.cpp b/src/themes/thememanager.cpp index bdf2c78..9220474 100644 --- a/src/themes/thememanager.cpp +++ b/src/themes/thememanager.cpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace rcx { @@ -13,18 +14,40 @@ ThemeManager& ThemeManager::instance() { } ThemeManager::ThemeManager() { - m_builtIn.append(Theme::reclassDark()); - m_builtIn.append(Theme::warm()); + loadBuiltInThemes(); loadUserThemes(); QSettings settings("Reclass", "Reclass"); - QString saved = settings.value("theme", m_builtIn[0].name).toString(); + QString fallback = m_builtIn.isEmpty() ? QString() : m_builtIn[0].name; + QString saved = settings.value("theme", fallback).toString(); auto all = themes(); for (int i = 0; i < all.size(); i++) { if (all[i].name == saved) { m_currentIdx = i; break; } } } +// ── Load built-in themes from JSON files next to the executable ── + +QString ThemeManager::builtInDir() const { + return QCoreApplication::applicationDirPath() + "/themes"; +} + +void ThemeManager::loadBuiltInThemes() { + m_builtIn.clear(); + QDir dir(builtInDir()); + if (!dir.exists()) return; + for (const QString& name : dir.entryList({"*.json"}, QDir::Files, QDir::Name)) { + QFile f(dir.filePath(name)); + if (!f.open(QIODevice::ReadOnly)) continue; + QJsonDocument jdoc = QJsonDocument::fromJson(f.readAll()); + if (jdoc.isObject()) + m_builtIn.append(Theme::fromJson(jdoc.object())); + } + m_builtInDefaults = m_builtIn; +} + +// ── themes / current ── + QVector ThemeManager::themes() const { QVector all = m_builtIn; all.append(m_user); @@ -37,7 +60,10 @@ const Theme& ThemeManager::current() const { int userIdx = m_currentIdx - m_builtIn.size(); if (userIdx >= 0 && userIdx < m_user.size()) return m_user[userIdx]; - return m_builtIn[0]; + if (!m_builtIn.isEmpty()) + return m_builtIn[0]; + static const Theme empty; + return empty; } void ThemeManager::setCurrent(int index) { @@ -55,17 +81,20 @@ void ThemeManager::addTheme(const Theme& theme) { } void ThemeManager::updateTheme(int index, const Theme& theme) { + m_previewing = false; // commit any active preview + if (index < builtInCount()) { - // Can't overwrite built-in; save as user theme instead - m_user.append(theme); + m_builtIn[index] = theme; + m_currentIdx = index; } else { int ui = index - builtInCount(); if (ui >= 0 && ui < m_user.size()) m_user[ui] = theme; } saveUserThemes(); - if (index == m_currentIdx) - emit themeChanged(current()); + QSettings settings("Reclass", "Reclass"); + settings.setValue("theme", current().name); + emit themeChanged(current()); } void ThemeManager::removeTheme(int index) { @@ -82,7 +111,9 @@ void ThemeManager::removeTheme(int index) { saveUserThemes(); } -QString ThemeManager::themesDir() const { +// ── User theme persistence ── + +QString ThemeManager::userDir() const { QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/themes"; QDir().mkpath(dir); @@ -91,37 +122,69 @@ QString ThemeManager::themesDir() const { void ThemeManager::loadUserThemes() { m_user.clear(); - QDir dir(themesDir()); + QDir dir(userDir()); for (const QString& name : dir.entryList({"*.json"}, QDir::Files)) { QFile f(dir.filePath(name)); if (!f.open(QIODevice::ReadOnly)) continue; QJsonDocument jdoc = QJsonDocument::fromJson(f.readAll()); - if (jdoc.isObject()) - m_user.append(Theme::fromJson(jdoc.object())); + if (!jdoc.isObject()) continue; + Theme t = Theme::fromJson(jdoc.object()); + + // If this overrides a built-in (same name), replace it in-place + bool isOverride = false; + for (int i = 0; i < m_builtIn.size(); i++) { + if (m_builtIn[i].name == t.name) { + m_builtIn[i] = t; + isOverride = true; + break; + } + } + if (!isOverride) + m_user.append(t); } } void ThemeManager::saveUserThemes() const { - QString dir = themesDir(); - // Remove old files + QString dir = userDir(); QDir d(dir); for (const QString& name : d.entryList({"*.json"}, QDir::Files)) d.remove(name); - // Write current user themes + + // Save modified built-ins (compare against on-disk originals) + for (int i = 0; i < m_builtIn.size() && i < m_builtInDefaults.size(); i++) { + if (m_builtIn[i].toJson() != m_builtInDefaults[i].toJson()) { + QString filename = m_builtIn[i].name.toLower().replace(' ', '_') + ".json"; + QFile f(dir + "/" + filename); + if (f.open(QIODevice::WriteOnly)) + f.write(QJsonDocument(m_builtIn[i].toJson()).toJson(QJsonDocument::Indented)); + } + } + + // Save user themes for (int i = 0; i < m_user.size(); i++) { QString filename = m_user[i].name.toLower().replace(' ', '_') + ".json"; QFile f(dir + "/" + filename); - if (!f.open(QIODevice::WriteOnly)) continue; - f.write(QJsonDocument(m_user[i].toJson()).toJson(QJsonDocument::Indented)); + if (f.open(QIODevice::WriteOnly)) + f.write(QJsonDocument(m_user[i].toJson()).toJson(QJsonDocument::Indented)); } } QString ThemeManager::themeFilePath(int index) const { - if (index < builtInCount()) return {}; + if (index < builtInCount()) { + // Built-in has a user override file only if modified + if (index < m_builtInDefaults.size() + && m_builtIn[index].toJson() != m_builtInDefaults[index].toJson()) { + QString filename = m_builtIn[index].name.toLower().replace(' ', '_') + ".json"; + return userDir() + "/" + filename; + } + // Show the built-in source file + QString filename = m_builtIn[index].name.toLower().replace(' ', '_') + ".json"; + return builtInDir() + "/" + filename; + } int ui = index - builtInCount(); if (ui < 0 || ui >= m_user.size()) return {}; QString filename = m_user[ui].name.toLower().replace(' ', '_') + ".json"; - return themesDir() + "/" + filename; + return userDir() + "/" + filename; } void ThemeManager::previewTheme(const Theme& theme) { diff --git a/src/themes/thememanager.h b/src/themes/thememanager.h index b7f0891..66abd96 100644 --- a/src/themes/thememanager.h +++ b/src/themes/thememanager.h @@ -31,14 +31,17 @@ signals: private: ThemeManager(); - QVector m_builtIn; + QVector m_builtIn; // built-in themes (possibly overridden) + QVector m_builtInDefaults; // originals loaded from disk QVector m_user; int m_currentIdx = 0; int builtInCount() const { return m_builtIn.size(); } - QString themesDir() const; + void loadBuiltInThemes(); + QString builtInDir() const; + QString userDir() const; bool m_previewing = false; - Theme m_savedTheme; // stashed current theme during preview + Theme m_savedTheme; }; } // namespace rcx diff --git a/src/titlebar.cpp b/src/titlebar.cpp index b1a2f4c..28ba138 100644 --- a/src/titlebar.cpp +++ b/src/titlebar.cpp @@ -1,4 +1,5 @@ #include "titlebar.h" +#include "themes/thememanager.h" #include #include #include @@ -8,7 +9,7 @@ namespace rcx { TitleBarWidget::TitleBarWidget(QWidget* parent) : QWidget(parent) - , m_theme(Theme::reclassDark()) + , m_theme(ThemeManager::instance().current()) { setFixedHeight(32); @@ -16,13 +17,11 @@ TitleBarWidget::TitleBarWidget(QWidget* parent) layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); - // App icon - auto* iconLabel = new QLabel(this); - iconLabel->setPixmap(QPixmap(":/icons/class.png").scaled(24, 24, Qt::KeepAspectRatio, Qt::SmoothTransformation)); - iconLabel->setFixedSize(32, 32); - iconLabel->setAlignment(Qt::AlignCenter); - iconLabel->setAttribute(Qt::WA_TransparentForMouseEvents); - layout->addWidget(iconLabel); + // App name + m_appLabel = new QLabel(QStringLiteral("Reclass"), this); + m_appLabel->setContentsMargins(10, 0, 4, 0); + m_appLabel->setAttribute(Qt::WA_TransparentForMouseEvents); + layout->addWidget(m_appLabel); // Menu bar m_menuBar = new QMenuBar(this); @@ -32,14 +31,6 @@ TitleBarWidget::TitleBarWidget(QWidget* parent) layout->addStretch(); - // Title label (centered, transparent to mouse so drag works through it) - m_titleLabel = new QLabel(this); - m_titleLabel->setAlignment(Qt::AlignCenter); - m_titleLabel->setAttribute(Qt::WA_TransparentForMouseEvents); - layout->addWidget(m_titleLabel); - - layout->addStretch(); - // Chrome buttons m_btnMin = makeChromeButton(":/vsicons/chrome-minimize.svg"); m_btnMax = makeChromeButton(":/vsicons/chrome-maximize.svg"); @@ -70,10 +61,6 @@ QToolButton* TitleBarWidget::makeChromeButton(const QString& iconPath) { return btn; } -void TitleBarWidget::setTitle(const QString& title) { - m_titleLabel->setText(title); -} - void TitleBarWidget::applyTheme(const Theme& theme) { m_theme = theme; @@ -83,9 +70,9 @@ void TitleBarWidget::applyTheme(const Theme& theme) { pal.setColor(QPalette::Window, theme.background); setPalette(pal); - // Title text - m_titleLabel->setStyleSheet( - QStringLiteral("QLabel { color: %1; font-size: 12px; }") + // App label + m_appLabel->setStyleSheet( + QStringLiteral("QLabel { color: %1; font-size: 12px; font-weight: bold; }") .arg(theme.textDim.name())); // Menu bar styling — transparent background, themed text @@ -113,6 +100,19 @@ void TitleBarWidget::applyTheme(const Theme& theme) { update(); } +void TitleBarWidget::setShowIcon(bool show) { + if (show) { + m_appLabel->setText(QString()); + m_appLabel->setPixmap(QIcon(":/icons/class.png").pixmap(16, 16)); + } else { + m_appLabel->setPixmap(QPixmap()); + m_appLabel->setText(QStringLiteral("Reclass")); + m_appLabel->setStyleSheet( + QStringLiteral("QLabel { color: %1; font-size: 12px; font-weight: bold; }") + .arg(m_theme.textDim.name())); + } +} + void TitleBarWidget::updateMaximizeIcon() { if (window()->isMaximized()) m_btnMax->setIcon(QIcon(":/vsicons/chrome-restore.svg")); diff --git a/src/titlebar.h b/src/titlebar.h index 99ce817..84f27b6 100644 --- a/src/titlebar.h +++ b/src/titlebar.h @@ -14,8 +14,8 @@ public: explicit TitleBarWidget(QWidget* parent = nullptr); QMenuBar* menuBar() const { return m_menuBar; } - void setTitle(const QString& title); void applyTheme(const Theme& theme); + void setShowIcon(bool show); void updateMaximizeIcon(); @@ -25,8 +25,8 @@ protected: void paintEvent(QPaintEvent* event) override; private: + QLabel* m_appLabel = nullptr; QMenuBar* m_menuBar = nullptr; - QLabel* m_titleLabel = nullptr; QToolButton* m_btnMin = nullptr; QToolButton* m_btnMax = nullptr; QToolButton* m_btnClose = nullptr; diff --git a/tests/test_theme.cpp b/tests/test_theme.cpp index 790a786..85b35d0 100644 --- a/tests/test_theme.cpp +++ b/tests/test_theme.cpp @@ -11,31 +11,37 @@ class TestTheme : public QObject { Q_OBJECT private slots: void builtInThemes() { - Theme dark = Theme::reclassDark(); - QCOMPARE(dark.name, "Reclass Dark"); - QVERIFY(dark.background.isValid()); - QVERIFY(dark.text.isValid()); - QVERIFY(dark.syntaxKeyword.isValid()); - QVERIFY(dark.markerError.isValid()); + auto& tm = ThemeManager::instance(); + auto all = tm.themes(); + QVERIFY(all.size() >= 2); - Theme warm = Theme::warm(); - QCOMPARE(warm.name, "Warm"); - QVERIFY(warm.background.isValid()); - QVERIFY(warm.text.isValid()); - QCOMPARE(warm.background, QColor("#212121")); - QCOMPARE(warm.selection, QColor("#21213A")); - QCOMPARE(warm.syntaxKeyword, QColor("#AA9565")); - QCOMPARE(warm.syntaxType, QColor("#6B959F")); - } + // Find themes by name + const Theme* dark = nullptr; + const Theme* warm = nullptr; + for (const auto& t : all) { + if (t.name == "Reclass Dark") dark = &t; + if (t.name == "Warm") warm = &t; + } + QVERIFY(dark); + QCOMPARE(dark->name, QString("Reclass Dark")); + QVERIFY(dark->background.isValid()); + QVERIFY(dark->text.isValid()); + QVERIFY(dark->syntaxKeyword.isValid()); + QVERIFY(dark->markerError.isValid()); - void selectionColorFixed() { - Theme dark = Theme::reclassDark(); - QCOMPARE(dark.selection, QColor("#2b2b2b")); - QVERIFY(dark.selection != QColor("#264f78")); + QVERIFY(warm); + QCOMPARE(warm->name, QString("Warm")); + QVERIFY(warm->background.isValid()); + QVERIFY(warm->text.isValid()); + QCOMPARE(warm->background, QColor("#212121")); + QCOMPARE(warm->selection, QColor("#21213A")); + QCOMPARE(warm->syntaxKeyword, QColor("#AA9565")); + QCOMPARE(warm->syntaxType, QColor("#6B959F")); } void jsonRoundTrip() { - Theme orig = Theme::reclassDark(); + auto& tm = ThemeManager::instance(); + Theme orig = tm.themes()[0]; QJsonObject json = orig.toJson(); Theme loaded = Theme::fromJson(json); @@ -54,7 +60,12 @@ private slots: } void jsonRoundTripWarm() { - Theme orig = Theme::warm(); + auto& tm = ThemeManager::instance(); + auto all = tm.themes(); + Theme orig; + for (const auto& t : all) + if (t.name == "Warm") { orig = t; break; } + QJsonObject json = orig.toJson(); Theme loaded = Theme::fromJson(json); @@ -70,21 +81,20 @@ private slots: sparse["background"] = "#ff0000"; Theme t = Theme::fromJson(sparse); - QCOMPARE(t.name, "Sparse"); + QCOMPARE(t.name, QString("Sparse")); QCOMPARE(t.background, QColor("#ff0000")); - // Missing fields fall back to reclassDark defaults - Theme defaults = Theme::reclassDark(); - QCOMPARE(t.text, defaults.text); - QCOMPARE(t.syntaxKeyword, defaults.syntaxKeyword); - QCOMPARE(t.markerError, defaults.markerError); + // Missing fields are default (invalid) QColor + QVERIFY(!t.text.isValid()); + QVERIFY(!t.syntaxKeyword.isValid()); + QVERIFY(!t.markerError.isValid()); } void themeManagerHasBuiltIns() { auto& tm = ThemeManager::instance(); auto all = tm.themes(); QVERIFY(all.size() >= 2); - QCOMPARE(all[0].name, "Reclass Dark"); - QCOMPARE(all[1].name, "Warm"); + QCOMPARE(all[0].name, QString("Reclass Dark")); + QCOMPARE(all[1].name, QString("Warm")); } void themeManagerSwitch() { @@ -108,12 +118,12 @@ private slots: int initialCount = tm.themes().size(); // Add - Theme custom = Theme::reclassDark(); + Theme custom = tm.themes()[0]; custom.name = "Test Custom"; custom.background = QColor("#ff0000"); tm.addTheme(custom); QCOMPARE(tm.themes().size(), initialCount + 1); - QCOMPARE(tm.themes().last().name, "Test Custom"); + QCOMPARE(tm.themes().last().name, QString("Test Custom")); // Update int idx = tm.themes().size() - 1;