Add theme system with Reclass Dark and Warm built-in themes

Replaces ~40 hardcoded color values with 27 semantic color roles.
Adds ThemeManager singleton, theme editor dialog, View > Theme menu,
JSON persistence for user themes, and fixes inline edit selection
color from blue #264f78 to #2b2b2b.
This commit is contained in:
IChooseYou
2026-02-10 07:46:18 -07:00
parent 8eab304538
commit 24a7e68136
12 changed files with 885 additions and 208 deletions

View File

@@ -35,6 +35,12 @@ add_executable(ReclassX
src/pluginmanager.h
src/typeselectorpopup.h
src/typeselectorpopup.cpp
src/themes/theme.h
src/themes/theme.cpp
src/themes/thememanager.h
src/themes/thememanager.cpp
src/themes/themeeditor.h
src/themes/themeeditor.cpp
)
target_include_directories(ReclassX PRIVATE src)
@@ -112,7 +118,8 @@ if(BUILD_TESTING)
target_link_libraries(test_compose PRIVATE Qt6::Core Qt6::Test)
add_test(NAME test_compose COMMAND test_compose)
add_executable(test_editor tests/test_editor.cpp src/editor.cpp src/compose.cpp src/format.cpp src/providerregistry.cpp)
add_executable(test_editor tests/test_editor.cpp src/editor.cpp src/compose.cpp src/format.cpp src/providerregistry.cpp
src/themes/theme.cpp src/themes/thememanager.cpp)
target_include_directories(test_editor PRIVATE src)
target_link_libraries(test_editor PRIVATE
Qt6::Widgets Qt6::PrintSupport Qt6::Test
@@ -140,7 +147,8 @@ if(BUILD_TESTING)
add_executable(test_controller tests/test_controller.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp)
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp)
target_include_directories(test_controller PRIVATE src)
target_link_libraries(test_controller PRIVATE
Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test
@@ -153,7 +161,8 @@ if(BUILD_TESTING)
add_executable(test_validation tests/test_validation.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp)
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp)
target_include_directories(test_validation PRIVATE src)
target_link_libraries(test_validation PRIVATE
Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test
@@ -172,7 +181,8 @@ if(BUILD_TESTING)
add_executable(test_context_menu tests/test_context_menu.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp)
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp)
target_include_directories(test_context_menu PRIVATE src)
target_link_libraries(test_context_menu PRIVATE
Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test
@@ -193,7 +203,8 @@ if(BUILD_TESTING)
add_executable(test_new_features tests/test_new_features.cpp
src/generator.cpp src/compose.cpp src/format.cpp src/controller.cpp
src/editor.cpp src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp)
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp)
target_include_directories(test_new_features PRIVATE src)
target_link_libraries(test_new_features PRIVATE
Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test
@@ -206,11 +217,18 @@ if(BUILD_TESTING)
add_executable(test_type_selector tests/test_type_selector.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp)
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp)
target_include_directories(test_type_selector PRIVATE src)
target_link_libraries(test_type_selector PRIVATE
Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test
QScintilla::QScintilla dbghelp psapi)
add_test(NAME test_type_selector COMMAND test_type_selector)
add_executable(test_theme tests/test_theme.cpp
src/themes/theme.cpp src/themes/thememanager.cpp)
target_include_directories(test_theme PRIVATE src)
target_link_libraries(test_theme PRIVATE Qt6::Widgets Qt6::Test)
add_test(NAME test_theme COMMAND test_theme)
endif()
add_subdirectory(plugins/ProcessMemory)

View File

@@ -14,15 +14,10 @@
#include <QCursor>
#include <QMenu>
#include <QApplication>
#include "themes/thememanager.h"
namespace rcx {
// ── Theme constants ──
static const QColor kBgText("#1e1e1e");
static const QColor kBgMargin("#1e1e1e"); // matches regular editor background
static const QColor kFgMargin("#858585");
static const QColor kFgMarginDim("#505050");
static constexpr int IND_EDITABLE = 8;
static constexpr int IND_HEX_DIM = 9;
static constexpr int IND_BASE_ADDR = 10; // Default text color override for command row address
@@ -54,6 +49,10 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
setupMarkers();
allocateMarginStyles();
applyTheme(ThemeManager::instance().current());
connect(&ThemeManager::instance(), &ThemeManager::themeChanged,
this, &RcxEditor::applyTheme);
m_sci->installEventFilter(this);
m_sci->viewport()->installEventFilter(this);
m_sci->viewport()->setMouseTracking(true);
@@ -122,15 +121,9 @@ void RcxEditor::setupScintilla() {
// Arrow cursor by default — not the I-beam (this is a structured viewer, not a text editor)
m_sci->viewport()->setCursor(Qt::ArrowCursor);
m_sci->setPaper(kBgText);
m_sci->setColor(QColor("#d4d4d4"));
m_sci->setTabWidth(2);
m_sci->setIndentationsUseTabs(false);
// Caret color for dark theme
m_sci->setCaretForegroundColor(QColor("#d4d4d4"));
// Line spacing for readability
m_sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRAASCENT, (long)2);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRADESCENT, (long)2);
@@ -147,51 +140,37 @@ void RcxEditor::setupScintilla() {
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_EDITABLE, 5 /*INDIC_HIDDEN*/);
// Hex/Padding node dim indicator — overrides text color to gray
// Hex/Padding node dim indicator — overrides text color
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HEX_DIM, 17 /*INDIC_TEXTFORE*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HEX_DIM, QColor("#505050"));
// Base address indicator — default text color to override lexer green on command row
// Base address indicator — text color override on command row
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_BASE_ADDR, 17 /*INDIC_TEXTFORE*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_BASE_ADDR, QColor("#d4d4d4"));
// Hover span indicator — muted teal text (distinct from blue keywords)
// Hover span indicator — link-like text
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HOVER_SPAN, 17 /*INDIC_TEXTFORE*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HOVER_SPAN, QColor("#E6B450"));
// Command-row pill background (shadcn-ish chip)
// Command-row pill background
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_CMD_PILL, 8 /*INDIC_STRAIGHTBOX*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_CMD_PILL, QColor("#2a2a2a"));
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETALPHA,
IND_CMD_PILL, (long)100);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETUNDER,
IND_CMD_PILL, (long)1);
// Data-changed indicator — muted green text (derived from number green #b5cea8)
// Data-changed indicator
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_DATA_CHANGED, 17 /*INDIC_TEXTFORE*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_DATA_CHANGED, QColor("#8fbc7a"));
// Root class name — teal (VS Code type color)
// Root class name — type color
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_CLASS_NAME, 17 /*INDIC_TEXTFORE*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_CLASS_NAME, QColor("#4EC9B0"));
// Green text for hint/comment annotations
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HINT_GREEN, 17 /*INDIC_TEXTFORE*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HINT_GREEN, QColor("#5a8248"));
}
@@ -199,29 +178,8 @@ void RcxEditor::setupLexer() {
m_lexer = new QsciLexerCPP(m_sci);
QFont font = editorFont();
m_lexer->setFont(font);
// Dark theme colors
m_lexer->setColor(QColor("#569cd6"), QsciLexerCPP::Keyword);
m_lexer->setColor(QColor("#569cd6"), QsciLexerCPP::KeywordSet2);
m_lexer->setColor(QColor("#b5cea8"), QsciLexerCPP::Number);
m_lexer->setColor(QColor("#ce9178"), QsciLexerCPP::DoubleQuotedString);
m_lexer->setColor(QColor("#ce9178"), QsciLexerCPP::SingleQuotedString);
m_lexer->setColor(QColor("#6a9955"), QsciLexerCPP::Comment);
m_lexer->setColor(QColor("#6a9955"), QsciLexerCPP::CommentLine);
m_lexer->setColor(QColor("#6a9955"), QsciLexerCPP::CommentDoc);
m_lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Default);
m_lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Identifier);
m_lexer->setColor(QColor("#c586c0"), QsciLexerCPP::PreProcessor);
m_lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Operator);
// Dark background for all styles
for (int i = 0; i <= 127; i++) {
m_lexer->setPaper(kBgText, i);
for (int i = 0; i <= 127; i++)
m_lexer->setFont(font, i);
}
// Custom / user-defined types → teal (VS Code #4EC9B0)
m_lexer->setColor(QColor("#4EC9B0"), QsciLexerCPP::GlobalClass);
m_sci->setLexer(m_lexer);
m_sci->setBraceMatching(QsciScintilla::NoBraceMatch); // Disable - this is a structured viewer
@@ -245,8 +203,6 @@ void RcxEditor::setupMargins() {
// Margin 0: Offset text
m_sci->setMarginType(0, QsciScintilla::TextMarginRightJustified);
m_sci->setMarginWidth(0, " 00000000 "); // default 8-digit; resized dynamically in applyDocument()
m_sci->setMarginsBackgroundColor(kBgMargin);
m_sci->setMarginsForegroundColor(kFgMarginDim);
m_sci->setMarginSensitivity(0, true);
// Margin 1: hidden (fold chevrons moved to text column)
@@ -256,7 +212,6 @@ void RcxEditor::setupMargins() {
void RcxEditor::setupFolding() {
// Hide fold margin (fold indicators are text-based now)
m_sci->setMarginWidth(2, 0);
m_sci->setFoldMarginColors(kBgMargin, kBgMargin);
// Fold indicators are now text in the line content (kFoldCol prefix),
// so no Scintilla markers needed for fold state.
@@ -281,37 +236,26 @@ void RcxEditor::setupMarkers() {
// M_PAD (1): padding line (metadata only, no visual)
m_sci->markerDefine(QsciScintilla::Invisible, M_PAD);
// M_PTR0 (2): right triangle (red)
// M_PTR0 (2): right triangle
m_sci->markerDefine(QsciScintilla::RightTriangle, M_PTR0);
m_sci->setMarkerBackgroundColor(QColor("#f44747"), M_PTR0);
m_sci->setMarkerForegroundColor(QColor("#f44747"), M_PTR0);
// M_CYCLE (3): arrows (orange)
// M_CYCLE (3): arrows
m_sci->markerDefine(QsciScintilla::ThreeRightArrows, M_CYCLE);
m_sci->setMarkerBackgroundColor(QColor("#e5a00d"), M_CYCLE);
m_sci->setMarkerForegroundColor(QColor("#e5a00d"), M_CYCLE);
// M_ERR (4): background (dark red - brightened for visibility)
// M_ERR (4): background
m_sci->markerDefine(QsciScintilla::Background, M_ERR);
m_sci->setMarkerBackgroundColor(QColor("#7a2e2e"), M_ERR);
m_sci->setMarkerForegroundColor(QColor("#ffffff"), M_ERR);
// M_STRUCT_BG (5): struct header/footer (matches regular bg, may remove later)
// M_STRUCT_BG (5): struct header/footer
m_sci->markerDefine(QsciScintilla::Background, M_STRUCT_BG);
m_sci->setMarkerBackgroundColor(QColor("#1e1e1e"), M_STRUCT_BG);
m_sci->setMarkerForegroundColor(QColor("#d4d4d4"), M_STRUCT_BG);
// M_HOVER (6): full-row hover highlight
m_sci->markerDefine(QsciScintilla::Background, M_HOVER);
m_sci->setMarkerBackgroundColor(QColor(43, 43, 43), M_HOVER);
// M_SELECTED (7): full-row selection highlight (higher = wins over hover)
// M_SELECTED (7): full-row selection highlight
m_sci->markerDefine(QsciScintilla::Background, M_SELECTED);
m_sci->setMarkerBackgroundColor(QColor(35, 35, 35), M_SELECTED);
// M_CMD_ROW (8): distinct background for CommandRow bar
m_sci->markerDefine(QsciScintilla::Background, M_CMD_ROW);
m_sci->setMarkerBackgroundColor(QColor("#1e1e1e"), M_CMD_ROW);
}
void RcxEditor::allocateMarginStyles() {
@@ -322,21 +266,87 @@ void RcxEditor::allocateMarginStyles() {
m_marginStyleBase = (int)base;
m_sci->SendScintilla(QsciScintillaBase::SCI_MARGINSETSTYLEOFFSET, base);
const long bgrMargin = 0x1e1e1e; // BGR for #1e1e1e (matches editor bg)
QByteArray fontName = editorFont().family().toUtf8();
int fontSize = editorFont().pointSize();
// Margin styles (dim gray text)
for (int s = MSTYLE_NORMAL; s <= MSTYLE_CONT; s++) {
unsigned long abs = (unsigned long)(base + s);
m_sci->SendScintilla(QsciScintillaBase::SCI_STYLESETFORE, abs, (long)0x505050);
m_sci->SendScintilla(QsciScintillaBase::SCI_STYLESETBACK, abs, bgrMargin);
m_sci->SendScintilla(QsciScintillaBase::SCI_STYLESETFONT,
(uintptr_t)abs, fontName.constData());
m_sci->SendScintilla(QsciScintillaBase::SCI_STYLESETSIZE, abs, (long)fontSize);
}
}
void RcxEditor::applyTheme(const Theme& theme) {
// Paper and text
m_sci->setPaper(theme.background);
m_sci->setColor(theme.text);
m_sci->setCaretForegroundColor(theme.text);
// Indicator colors
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HEX_DIM, theme.textFaint);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_BASE_ADDR, theme.text);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HOVER_SPAN, theme.indHoverSpan);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_CMD_PILL, theme.indCmdPill);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_DATA_CHANGED, theme.indDataChanged);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_CLASS_NAME, theme.syntaxType);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HINT_GREEN, theme.indHintGreen);
// Lexer colors
m_lexer->setColor(theme.syntaxKeyword, QsciLexerCPP::Keyword);
m_lexer->setColor(theme.syntaxKeyword, QsciLexerCPP::KeywordSet2);
m_lexer->setColor(theme.syntaxNumber, QsciLexerCPP::Number);
m_lexer->setColor(theme.syntaxString, QsciLexerCPP::DoubleQuotedString);
m_lexer->setColor(theme.syntaxString, QsciLexerCPP::SingleQuotedString);
m_lexer->setColor(theme.syntaxComment, QsciLexerCPP::Comment);
m_lexer->setColor(theme.syntaxComment, QsciLexerCPP::CommentLine);
m_lexer->setColor(theme.syntaxComment, QsciLexerCPP::CommentDoc);
m_lexer->setColor(theme.text, QsciLexerCPP::Default);
m_lexer->setColor(theme.text, QsciLexerCPP::Identifier);
m_lexer->setColor(theme.syntaxPreproc, QsciLexerCPP::PreProcessor);
m_lexer->setColor(theme.text, QsciLexerCPP::Operator);
m_lexer->setColor(theme.syntaxType, QsciLexerCPP::GlobalClass);
for (int i = 0; i <= 127; i++)
m_lexer->setPaper(theme.background, i);
// Margins
m_sci->setMarginsBackgroundColor(theme.background);
m_sci->setMarginsForegroundColor(theme.textFaint);
m_sci->setFoldMarginColors(theme.background, theme.background);
// Markers
m_sci->setMarkerBackgroundColor(theme.markerPtr, M_PTR0);
m_sci->setMarkerForegroundColor(theme.markerPtr, M_PTR0);
m_sci->setMarkerBackgroundColor(theme.markerCycle, M_CYCLE);
m_sci->setMarkerForegroundColor(theme.markerCycle, M_CYCLE);
m_sci->setMarkerBackgroundColor(theme.markerError, M_ERR);
m_sci->setMarkerForegroundColor(QColor("#ffffff"), M_ERR);
m_sci->setMarkerBackgroundColor(theme.background, M_STRUCT_BG);
m_sci->setMarkerForegroundColor(theme.text, M_STRUCT_BG);
m_sci->setMarkerBackgroundColor(theme.hover, M_HOVER);
m_sci->setMarkerBackgroundColor(theme.selected, M_SELECTED);
m_sci->setMarkerBackgroundColor(theme.background, M_CMD_ROW);
// Margin extended styles
if (m_marginStyleBase >= 0) {
long base = m_marginStyleBase;
for (int s = 0; s <= 1; s++) {
unsigned long abs = (unsigned long)(base + s);
m_sci->SendScintilla(QsciScintillaBase::SCI_STYLESETFORE,
abs, theme.textFaint);
m_sci->SendScintilla(QsciScintillaBase::SCI_STYLESETBACK,
abs, theme.background);
}
}
}
void RcxEditor::applyDocument(const ComposeResult& result) {
// Silently deactivate inline edit (no signal — refresh is already happening)
if (m_editState.active)
@@ -1561,7 +1571,7 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line) {
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSELFORE, (long)0, (long)0);
if (!isPicker)
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSELBACK, (long)1,
QColor("#264f78"));
ThemeManager::instance().current().selection);
// Use correct UTF-8 position conversion (not lineStart + col!)
m_editState.posStart = posFromCol(m_sci, line, norm.start);

View File

@@ -1,5 +1,6 @@
#pragma once
#include "core.h"
#include "themes/theme.h"
#include <QWidget>
#include <QSet>
#include <QPoint>
@@ -47,6 +48,7 @@ public:
void setCommandRowText(const QString& line);
void setEditorFont(const QString& fontName);
static void setGlobalFontName(const QString& fontName);
void applyTheme(const Theme& theme);
// Custom type names (struct types from the tree) shown in type picker + lexer GlobalClass coloring
QString textWithMargins() const;

View File

@@ -41,6 +41,8 @@
#include <Qsci/qscilexercpp.h>
#include <QProxyStyle>
#include <QDesktopServices>
#include "themes/thememanager.h"
#include "themes/themeeditor.h"
#ifdef _WIN32
#include <windows.h>
@@ -128,6 +130,44 @@ public:
}
};
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.backgroundAlt);
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.hover);
pal.setColor(QPalette::HighlightedText, theme.text);
pal.setColor(QPalette::ToolTipBase, theme.backgroundAlt);
pal.setColor(QPalette::ToolTipText, theme.text);
pal.setColor(QPalette::Mid, theme.border);
pal.setColor(QPalette::Dark, theme.background);
pal.setColor(QPalette::Light, theme.textFaint);
qApp->setPalette(pal);
qApp->setStyleSheet(QStringLiteral(
"QMenu {"
" background-color: %1;"
" color: %2;"
" border: 1px solid %3;"
" padding: 4px 6px;"
"}"
"QMenu::item { padding: 4px 24px; }"
"QMenu::item:selected { background-color: %4; }"
"QMenu::separator { height: 1px; background: %3; margin: 4px 8px; }"
"QMenu::item:disabled { color: %5; }"
"QToolTip {"
" background-color: %1;"
" color: %2;"
" border: 1px solid %3;"
"}")
.arg(theme.backgroundAlt.name(), theme.text.name(), theme.border.name(),
theme.hover.name(), theme.textMuted.name()));
}
namespace rcx {
class MainWindow : public QMainWindow {
@@ -158,6 +198,7 @@ private slots:
void setEditorFont(const QString& fontName);
void exportCpp();
void showTypeAliasesDialog();
void editTheme();
public:
// Project Lifecycle API
@@ -208,6 +249,7 @@ private:
void setupRenderedSci(QsciScintilla* sci);
SplitPane createSplitPane(TabState& tab);
void applyTheme(const Theme& theme);
void applyTabWidgetStyle(QTabWidget* tw);
SplitPane* findPaneByTabWidget(QTabWidget* tw);
SplitPane* findActiveSplitPane();
@@ -229,22 +271,17 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
m_mdiArea->setViewMode(QMdiArea::TabbedView);
m_mdiArea->setTabsClosable(true);
m_mdiArea->setTabsMovable(true);
m_mdiArea->setStyleSheet(QStringLiteral(
"QTabBar::tab {"
" background: #1e1e1e;"
" color: #585858;"
" padding: 6px 16px;"
" border: none;"
"}"
"QTabBar::tab:selected {"
" color: #d4d4d4;"
" background: #252526;"
"}"
"QTabBar::tab:hover {"
" color: #d4d4d4;"
" background: #2b2b2b;"
"}"
));
{
const auto& t = ThemeManager::instance().current();
m_mdiArea->setStyleSheet(QStringLiteral(
"QTabBar::tab {"
" background: %1; color: %2; padding: 6px 16px; border: none;"
"}"
"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()));
}
setCentralWidget(m_mdiArea);
createWorkspaceDock();
@@ -257,6 +294,9 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
menuBar()->setStyle(new MenuBarStyle(menuBar()->style()));
}
connect(&ThemeManager::instance(), &ThemeManager::themeChanged,
this, &MainWindow::applyTheme);
// Load plugins
m_pluginManager.LoadPlugins();
@@ -327,6 +367,24 @@ void MainWindow::createMenus() {
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();
themeMenu->addAction("Edit Theme...", this, &MainWindow::editTheme);
view->addSeparator();
view->addAction(m_workspaceDock->toggleViewAction());
@@ -351,7 +409,12 @@ void MainWindow::createStatusBar() {
m_statusLabel = new QLabel("Ready");
m_statusLabel->setContentsMargins(10, 0, 0, 0);
statusBar()->addWidget(m_statusLabel, 1);
statusBar()->setStyleSheet("QStatusBar { background: #252526; color: #858585; }");
{
const auto& t = ThemeManager::instance().current();
statusBar()->setStyleSheet(QStringLiteral(
"QStatusBar { background: %1; color: %2; }")
.arg(t.backgroundAlt.name(), t.textDim.name()));
}
QSettings settings("ReclassX", "ReclassX");
QString fontName = settings.value("font", "JetBrains Mono").toString();
@@ -366,23 +429,16 @@ void MainWindow::applyTabWidgetStyle(QTabWidget* tw) {
QFont tabFont(fontName, 12);
tabFont.setFixedPitch(true);
tw->tabBar()->setFont(tabFont);
const auto& t = ThemeManager::instance().current();
tw->setStyleSheet(QStringLiteral(
"QTabWidget::pane { border: none; }"
"QTabBar::tab {"
" background: #1e1e1e;"
" color: #585858;"
" padding: 4px 12px;"
" border: none;"
" min-width: 60px;"
" background: %1; color: %2; padding: 4px 12px; border: none; min-width: 60px;"
"}"
"QTabBar::tab:selected {"
" color: #d4d4d4;"
"}"
"QTabBar::tab:hover {"
" color: #d4d4d4;"
" background: #2b2b2b;"
"}"
));
"QTabBar::tab:selected { color: %3; }"
"QTabBar::tab:hover { color: %3; background: %4; }")
.arg(t.background.name(), t.textMuted.name(),
t.text.name(), t.hover.name()));
tw->tabBar()->setExpanding(false);
}
@@ -754,28 +810,74 @@ void MainWindow::about() {
lay->setSpacing(12);
auto* buildLabel = new QLabel(
QStringLiteral("<span style='color:#858585;font-size:11px;'>"
"Build&ensp;" __DATE__ "&ensp;" __TIME__ "</span>"));
QStringLiteral("<span style='color:%1;font-size:11px;'>"
"Build&ensp;" __DATE__ "&ensp;" __TIME__ "</span>")
.arg(ThemeManager::instance().current().textDim.name()));
buildLabel->setAlignment(Qt::AlignCenter);
lay->addWidget(buildLabel);
auto* ghBtn = new QPushButton("GitHub");
ghBtn->setCursor(Qt::PointingHandCursor);
ghBtn->setStyleSheet(
"QPushButton {"
" background: #2a2a2a; color: #d4d4d4; border: 1px solid #3f3f3f;"
" border-radius: 4px; padding: 5px 16px; font-size: 12px;"
"}"
"QPushButton:hover { background: #333333; border-color: #505050; }");
{
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);
dlg.setStyleSheet("QDialog { background: #1e1e1e; }");
dlg.setStyleSheet(QStringLiteral("QDialog { background: %1; }")
.arg(ThemeManager::instance().current().background.name()));
dlg.exec();
}
void MainWindow::applyTheme(const Theme& theme) {
applyGlobalTheme(theme);
// MDI area tabs
m_mdiArea->setStyleSheet(QStringLiteral(
"QTabBar::tab {"
" background: %1; color: %2; padding: 6px 16px; border: none;"
"}"
"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()));
// Status bar
statusBar()->setStyleSheet(QStringLiteral(
"QStatusBar { background: %1; color: %2; }")
.arg(theme.backgroundAlt.name(), theme.textDim.name()));
// Split pane tab widgets
for (auto& state : m_tabs) {
for (auto& pane : state.panes) {
if (pane.tabWidget) applyTabWidgetStyle(pane.tabWidget);
}
}
}
void MainWindow::editTheme() {
auto& tm = ThemeManager::instance();
Theme edited = tm.current();
ThemeEditor dlg(edited, this);
if (dlg.exec() == QDialog::Accepted) {
edited = dlg.result();
int idx = tm.currentIndex();
if (idx < tm.themes().size() && idx >= 0) {
tm.updateTheme(idx, edited);
}
}
}
void MainWindow::setEditorFont(const QString& fontName) {
QSettings settings("ReclassX", "ReclassX");
settings.setValue("font", fontName);
@@ -852,8 +954,9 @@ void MainWindow::setupRenderedSci(QsciScintilla* sci) {
// Line number margin
sci->setMarginType(0, QsciScintilla::NumberMargin);
sci->setMarginWidth(0, "00000");
sci->setMarginsBackgroundColor(QColor("#252526"));
sci->setMarginsForegroundColor(QColor("#858585"));
const auto& theme = ThemeManager::instance().current();
sci->setMarginsBackgroundColor(theme.backgroundAlt);
sci->setMarginsForegroundColor(theme.textDim);
sci->setMarginsFont(f);
// Hide other margins
@@ -864,33 +967,33 @@ void MainWindow::setupRenderedSci(QsciScintilla* sci) {
// because setLexer() resets caret line, selection, and paper colors.
auto* lexer = new QsciLexerCPP(sci);
lexer->setFont(f);
lexer->setColor(QColor("#569cd6"), QsciLexerCPP::Keyword);
lexer->setColor(QColor("#569cd6"), QsciLexerCPP::KeywordSet2);
lexer->setColor(QColor("#b5cea8"), QsciLexerCPP::Number);
lexer->setColor(QColor("#ce9178"), QsciLexerCPP::DoubleQuotedString);
lexer->setColor(QColor("#ce9178"), QsciLexerCPP::SingleQuotedString);
lexer->setColor(QColor("#6a9955"), QsciLexerCPP::Comment);
lexer->setColor(QColor("#6a9955"), QsciLexerCPP::CommentLine);
lexer->setColor(QColor("#6a9955"), QsciLexerCPP::CommentDoc);
lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Default);
lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Identifier);
lexer->setColor(QColor("#c586c0"), QsciLexerCPP::PreProcessor);
lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Operator);
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);
for (int i = 0; i <= 127; i++) {
lexer->setPaper(QColor("#1e1e1e"), i);
lexer->setPaper(theme.background, i);
lexer->setFont(f, i);
}
sci->setLexer(lexer);
sci->setBraceMatching(QsciScintilla::NoBraceMatch);
// Colors applied AFTER setLexer() — the lexer resets these on attach
sci->setPaper(QColor("#1e1e1e"));
sci->setColor(QColor("#d4d4d4"));
sci->setCaretForegroundColor(QColor("#d4d4d4"));
sci->setPaper(theme.background);
sci->setColor(theme.text);
sci->setCaretForegroundColor(theme.text);
sci->setCaretLineVisible(true);
sci->setCaretLineBackgroundColor(QColor(43, 43, 43)); // Match Reclass M_HOVER
sci->setSelectionBackgroundColor(QColor("#264f78")); // Match Reclass edit selection
sci->setSelectionForegroundColor(QColor("#d4d4d4"));
sci->setCaretLineBackgroundColor(theme.hover);
sci->setSelectionBackgroundColor(theme.selection);
sci->setSelectionForegroundColor(theme.text);
}
// ── View mode / generator switching ──
@@ -1333,54 +1436,8 @@ int main(int argc, char* argv[]) {
rcx::RcxEditor::setGlobalFontName(savedFont);
}
// Global dark palette
QPalette darkPalette;
darkPalette.setColor(QPalette::Window, QColor("#1e1e1e"));
darkPalette.setColor(QPalette::WindowText, QColor("#d4d4d4"));
darkPalette.setColor(QPalette::Base, QColor("#252526"));
darkPalette.setColor(QPalette::AlternateBase, QColor("#2a2d2e"));
darkPalette.setColor(QPalette::Text, QColor("#d4d4d4"));
darkPalette.setColor(QPalette::Button, QColor("#333333"));
darkPalette.setColor(QPalette::ButtonText, QColor("#d4d4d4"));
darkPalette.setColor(QPalette::Highlight, QColor("#2b2b2b"));
darkPalette.setColor(QPalette::HighlightedText, QColor("#d4d4d4"));
darkPalette.setColor(QPalette::ToolTipBase, QColor("#252526"));
darkPalette.setColor(QPalette::ToolTipText, QColor("#d4d4d4"));
darkPalette.setColor(QPalette::Mid, QColor("#3c3c3c"));
darkPalette.setColor(QPalette::Dark, QColor("#1e1e1e"));
darkPalette.setColor(QPalette::Light, QColor("#505050"));
app.setPalette(darkPalette);
// ── Global widget styling ──
// QMenu: grey hover, amber accent border (replaces Fusion outline artifact)
// QToolTip: dark theme
app.setStyleSheet(QStringLiteral(
"QMenu {"
" background-color: #252526;"
" color: #d4d4d4;"
" border: 1px solid #3c3c3c;"
" padding: 4px 6px;"
"}"
"QMenu::item {"
" padding: 4px 24px;"
"}"
"QMenu::item:selected {"
" background-color: #2b2b2b;"
"}"
"QMenu::separator {"
" height: 1px;"
" background: #3c3c3c;"
" margin: 4px 8px;"
"}"
"QMenu::item:disabled {"
" color: #585858;"
"}"
"QToolTip {"
" background-color: #252526;"
" color: #d4d4d4;"
" border: 1px solid #3c3c3c;"
"}"
));
// Global theme
applyGlobalTheme(rcx::ThemeManager::instance().current());
rcx::MainWindow window;

119
src/themes/theme.cpp Normal file
View File

@@ -0,0 +1,119 @@
#include "theme.h"
namespace rcx {
// ── Field table for DRY serialization ──
struct ColorField { const char* key; QColor Theme::*ptr; };
static const ColorField kFields[] = {
{"background", &Theme::background},
{"backgroundAlt", &Theme::backgroundAlt},
{"surface", &Theme::surface},
{"border", &Theme::border},
{"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},
};
QJsonObject Theme::toJson() const {
QJsonObject o;
o["name"] = name;
for (const auto& f : kFields)
o[f.key] = (this->*f.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());
}
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.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.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

55
src/themes/theme.h Normal file
View File

@@ -0,0 +1,55 @@
#pragma once
#include <QColor>
#include <QString>
#include <QJsonObject>
namespace rcx {
struct Theme {
QString name;
// ── Chrome ──
QColor background; // editor bg, margin bg, window
QColor backgroundAlt; // panels, tab selected, tooltips
QColor surface; // alternateBase
QColor border; // separators, menu borders
QColor button; // button bg
// ── Text ──
QColor text; // primary text, caret, identifiers
QColor textDim; // margin fg, status bar
QColor textMuted; // inactive tab, disabled menu
QColor textFaint; // margin dim, hex dim
// ── Interactive ──
QColor hover; // row hover, tab hover, menu hover
QColor selected; // row selection highlight
QColor selection; // text selection background
// ── Syntax ──
QColor syntaxKeyword;
QColor syntaxNumber;
QColor syntaxString;
QColor syntaxComment;
QColor syntaxPreproc;
QColor syntaxType; // custom types / GlobalClass
// ── Indicators ──
QColor indHoverSpan; // hover link text
QColor indCmdPill; // command row pill bg
QColor indDataChanged; // changed data values
QColor indHintGreen; // comment/hint text
// ── Markers ──
QColor markerPtr; // null pointer
QColor markerCycle; // cycle detection
QColor markerError; // error row bg
QJsonObject toJson() const;
static Theme fromJson(const QJsonObject& obj);
static Theme reclassDark();
static Theme warm();
};
} // namespace rcx

View File

@@ -0,0 +1,95 @@
#include "themeeditor.h"
#include <QFormLayout>
#include <QDialogButtonBox>
#include <QColorDialog>
#include <QLineEdit>
#include <QLabel>
namespace rcx {
ThemeEditor::ThemeEditor(const Theme& theme, QWidget* parent)
: QDialog(parent), m_theme(theme)
{
setWindowTitle("Edit Theme");
setMinimumWidth(320);
auto* form = new QFormLayout;
// Name field
auto* nameEdit = new QLineEdit(m_theme.name);
connect(nameEdit, &QLineEdit::textChanged, this, [this](const QString& t) {
m_theme.name = t;
});
form->addRow("Name", nameEdit);
// Color swatches
struct FieldDef { const char* label; QColor Theme::*ptr; };
const FieldDef fields[] = {
{"Background", &Theme::background},
{"Background Alt", &Theme::backgroundAlt},
{"Surface", &Theme::surface},
{"Border", &Theme::border},
{"Button", &Theme::button},
{"Text", &Theme::text},
{"Text Dim", &Theme::textDim},
{"Text Muted", &Theme::textMuted},
{"Text Faint", &Theme::textFaint},
{"Hover", &Theme::hover},
{"Selected", &Theme::selected},
{"Selection", &Theme::selection},
{"Keyword", &Theme::syntaxKeyword},
{"Number", &Theme::syntaxNumber},
{"String", &Theme::syntaxString},
{"Comment", &Theme::syntaxComment},
{"Preprocessor", &Theme::syntaxPreproc},
{"Type", &Theme::syntaxType},
{"Hover Span", &Theme::indHoverSpan},
{"Cmd Pill", &Theme::indCmdPill},
{"Data Changed", &Theme::indDataChanged},
{"Hint Green", &Theme::indHintGreen},
{"Pointer Marker", &Theme::markerPtr},
{"Cycle Marker", &Theme::markerCycle},
{"Error Marker", &Theme::markerError},
};
for (const auto& f : fields) {
auto* btn = new QPushButton;
btn->setFixedSize(60, 24);
btn->setCursor(Qt::PointingHandCursor);
SwatchEntry entry{f.label, f.ptr, btn};
m_swatches.append(entry);
updateSwatch(m_swatches.last());
int idx = m_swatches.size() - 1;
connect(btn, &QPushButton::clicked, this, [this, idx]() {
pickColor(m_swatches[idx]);
});
form->addRow(f.label, btn);
}
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto* layout = new QVBoxLayout(this);
layout->addLayout(form);
layout->addWidget(buttons);
}
void ThemeEditor::updateSwatch(SwatchEntry& entry) {
QColor c = m_theme.*entry.field;
entry.button->setStyleSheet(QStringLiteral(
"QPushButton { background: %1; border: 1px solid #555; border-radius: 3px; }")
.arg(c.name()));
entry.button->setToolTip(c.name());
}
void ThemeEditor::pickColor(SwatchEntry& entry) {
QColor c = QColorDialog::getColor(m_theme.*entry.field, this, entry.label);
if (c.isValid()) {
m_theme.*entry.field = c;
updateSwatch(entry);
}
}
} // namespace rcx

29
src/themes/themeeditor.h Normal file
View File

@@ -0,0 +1,29 @@
#pragma once
#include "theme.h"
#include <QDialog>
#include <QVector>
#include <QPushButton>
namespace rcx {
class ThemeEditor : public QDialog {
Q_OBJECT
public:
explicit ThemeEditor(const Theme& theme, QWidget* parent = nullptr);
Theme result() const { return m_theme; }
private:
Theme m_theme;
struct SwatchEntry {
const char* label;
QColor Theme::*field;
QPushButton* button;
};
QVector<SwatchEntry> m_swatches;
void updateSwatch(SwatchEntry& entry);
void pickColor(SwatchEntry& entry);
};
} // namespace rcx

119
src/themes/thememanager.cpp Normal file
View File

@@ -0,0 +1,119 @@
#include "thememanager.h"
#include <QSettings>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QStandardPaths>
namespace rcx {
ThemeManager& ThemeManager::instance() {
static ThemeManager s;
return s;
}
ThemeManager::ThemeManager() {
m_builtIn.append(Theme::reclassDark());
m_builtIn.append(Theme::warm());
loadUserThemes();
QSettings settings("ReclassX", "ReclassX");
QString saved = settings.value("theme", m_builtIn[0].name).toString();
auto all = themes();
for (int i = 0; i < all.size(); i++) {
if (all[i].name == saved) { m_currentIdx = i; break; }
}
}
QVector<Theme> ThemeManager::themes() const {
QVector<Theme> all = m_builtIn;
all.append(m_user);
return all;
}
const Theme& ThemeManager::current() const {
if (m_currentIdx < m_builtIn.size())
return m_builtIn[m_currentIdx];
int userIdx = m_currentIdx - m_builtIn.size();
if (userIdx >= 0 && userIdx < m_user.size())
return m_user[userIdx];
return m_builtIn[0];
}
void ThemeManager::setCurrent(int index) {
auto all = themes();
if (index < 0 || index >= all.size()) return;
m_currentIdx = index;
QSettings settings("ReclassX", "ReclassX");
settings.setValue("theme", all[index].name);
emit themeChanged(current());
}
void ThemeManager::addTheme(const Theme& theme) {
m_user.append(theme);
saveUserThemes();
}
void ThemeManager::updateTheme(int index, const Theme& theme) {
if (index < builtInCount()) {
// Can't overwrite built-in; save as user theme instead
m_user.append(theme);
} else {
int ui = index - builtInCount();
if (ui >= 0 && ui < m_user.size())
m_user[ui] = theme;
}
saveUserThemes();
if (index == m_currentIdx)
emit themeChanged(current());
}
void ThemeManager::removeTheme(int index) {
if (index < builtInCount()) return;
int ui = index - builtInCount();
if (ui < 0 || ui >= m_user.size()) return;
m_user.remove(ui);
if (m_currentIdx == index) {
m_currentIdx = 0;
emit themeChanged(current());
} else if (m_currentIdx > index) {
m_currentIdx--;
}
saveUserThemes();
}
QString ThemeManager::themesDir() const {
QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)
+ "/themes";
QDir().mkpath(dir);
return dir;
}
void ThemeManager::loadUserThemes() {
m_user.clear();
QDir dir(themesDir());
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()));
}
}
void ThemeManager::saveUserThemes() const {
QString dir = themesDir();
// Remove old files
QDir d(dir);
for (const QString& name : d.entryList({"*.json"}, QDir::Files))
d.remove(name);
// Write current 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));
}
}
} // namespace rcx

38
src/themes/thememanager.h Normal file
View File

@@ -0,0 +1,38 @@
#pragma once
#include "theme.h"
#include <QObject>
#include <QVector>
namespace rcx {
class ThemeManager : public QObject {
Q_OBJECT
public:
static ThemeManager& instance();
QVector<Theme> themes() const;
int currentIndex() const { return m_currentIdx; }
const Theme& current() const;
void setCurrent(int index);
void addTheme(const Theme& theme);
void updateTheme(int index, const Theme& theme);
void removeTheme(int index);
void loadUserThemes();
void saveUserThemes() const;
signals:
void themeChanged(const rcx::Theme& theme);
private:
ThemeManager();
QVector<Theme> m_builtIn;
QVector<Theme> m_user;
int m_currentIdx = 0;
int builtInCount() const { return m_builtIn.size(); }
QString themesDir() const;
};
} // namespace rcx

View File

@@ -14,6 +14,7 @@
#include <QIcon>
#include <QApplication>
#include <QScreen>
#include "themes/thememanager.h"
namespace rcx {
@@ -34,11 +35,12 @@ public:
const QModelIndex& index) const override {
painter->save();
// Background: editor design language greys
// Background: themed colors
const auto& t = ThemeManager::instance().current();
if (option.state & QStyle::State_Selected)
painter->fillRect(option.rect, QColor("#232323")); // M_SELECTED
painter->fillRect(option.rect, t.selected);
else if (option.state & QStyle::State_MouseOver)
painter->fillRect(option.rect, QColor("#2b2b2b")); // M_HOVER
painter->fillRect(option.rect, t.hover);
int x = option.rect.x();
int y = option.rect.y();
@@ -48,7 +50,7 @@ public:
int row = index.row();
if (m_filtered && row >= 0 && row < m_filtered->size()
&& (*m_filtered)[row].id == m_currentId) {
painter->setPen(QColor("#4ec9b0"));
painter->setPen(t.syntaxType);
QFont checkFont = m_font;
painter->setFont(checkFont);
painter->drawText(QRect(x, y, 18, h), Qt::AlignCenter,
@@ -93,17 +95,18 @@ TypeSelectorPopup::TypeSelectorPopup(QWidget* parent)
{
setAttribute(Qt::WA_DeleteOnClose, false);
// Dark palette (no CSS)
// Theme palette
const auto& theme = ThemeManager::instance().current();
QPalette pal;
pal.setColor(QPalette::Window, QColor("#252526"));
pal.setColor(QPalette::WindowText, QColor("#d4d4d4"));
pal.setColor(QPalette::Base, QColor("#1e1e1e"));
pal.setColor(QPalette::AlternateBase, QColor("#2a2d2e"));
pal.setColor(QPalette::Text, QColor("#d4d4d4"));
pal.setColor(QPalette::Button, QColor("#333333"));
pal.setColor(QPalette::ButtonText, QColor("#d4d4d4"));
pal.setColor(QPalette::Highlight, QColor("#2b2b2b"));
pal.setColor(QPalette::HighlightedText, QColor("#d4d4d4"));
pal.setColor(QPalette::Window, theme.backgroundAlt);
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.hover);
pal.setColor(QPalette::HighlightedText, theme.text);
setPalette(pal);
setAutoFillBackground(true);
@@ -130,7 +133,7 @@ TypeSelectorPopup::TypeSelectorPopup(QWidget* parent)
m_escLabel = new QLabel(QStringLiteral("Esc"));
QPalette dimPal = pal;
dimPal.setColor(QPalette::WindowText, QColor("#858585"));
dimPal.setColor(QPalette::WindowText, theme.textDim);
m_escLabel->setPalette(dimPal);
row->addWidget(m_escLabel);
@@ -159,7 +162,7 @@ TypeSelectorPopup::TypeSelectorPopup(QWidget* parent)
sep->setFrameShape(QFrame::HLine);
sep->setFrameShadow(QFrame::Plain);
QPalette sepPal = pal;
sepPal.setColor(QPalette::WindowText, QColor("#3c3c3c"));
sepPal.setColor(QPalette::WindowText, theme.border);
sep->setPalette(sepPal);
sep->setFixedHeight(1);
layout->addWidget(sep);

132
tests/test_theme.cpp Normal file
View File

@@ -0,0 +1,132 @@
#include <QtTest/QTest>
#include <QtTest/QSignalSpy>
#include <QJsonDocument>
#include <QJsonObject>
#include "themes/theme.h"
#include "themes/thememanager.h"
using namespace rcx;
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());
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"));
}
void selectionColorFixed() {
Theme dark = Theme::reclassDark();
QCOMPARE(dark.selection, QColor("#2b2b2b"));
QVERIFY(dark.selection != QColor("#264f78"));
}
void jsonRoundTrip() {
Theme orig = Theme::reclassDark();
QJsonObject json = orig.toJson();
Theme loaded = Theme::fromJson(json);
QCOMPARE(loaded.name, orig.name);
QCOMPARE(loaded.background, orig.background);
QCOMPARE(loaded.text, orig.text);
QCOMPARE(loaded.selection, orig.selection);
QCOMPARE(loaded.syntaxKeyword, orig.syntaxKeyword);
QCOMPARE(loaded.syntaxNumber, orig.syntaxNumber);
QCOMPARE(loaded.syntaxString, orig.syntaxString);
QCOMPARE(loaded.syntaxComment, orig.syntaxComment);
QCOMPARE(loaded.syntaxType, orig.syntaxType);
QCOMPARE(loaded.markerPtr, orig.markerPtr);
QCOMPARE(loaded.markerError, orig.markerError);
QCOMPARE(loaded.indHoverSpan, orig.indHoverSpan);
}
void jsonRoundTripWarm() {
Theme orig = Theme::warm();
QJsonObject json = orig.toJson();
Theme loaded = Theme::fromJson(json);
QCOMPARE(loaded.name, orig.name);
QCOMPARE(loaded.background, orig.background);
QCOMPARE(loaded.selection, orig.selection);
QCOMPARE(loaded.syntaxKeyword, orig.syntaxKeyword);
}
void fromJsonMissingFields() {
QJsonObject sparse;
sparse["name"] = "Sparse";
sparse["background"] = "#ff0000";
Theme t = Theme::fromJson(sparse);
QCOMPARE(t.name, "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);
}
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");
}
void themeManagerSwitch() {
auto& tm = ThemeManager::instance();
QSignalSpy spy(&tm, &ThemeManager::themeChanged);
int startIdx = tm.currentIndex();
int target = (startIdx == 0) ? 1 : 0;
tm.setCurrent(target);
QCOMPARE(spy.count(), 1);
QCOMPARE(tm.currentIndex(), target);
QCOMPARE(tm.current().name, tm.themes()[target].name);
// Restore
tm.setCurrent(startIdx);
}
void themeManagerCRUD() {
auto& tm = ThemeManager::instance();
int initialCount = tm.themes().size();
// Add
Theme custom = Theme::reclassDark();
custom.name = "Test Custom";
custom.background = QColor("#ff0000");
tm.addTheme(custom);
QCOMPARE(tm.themes().size(), initialCount + 1);
QCOMPARE(tm.themes().last().name, "Test Custom");
// Update
int idx = tm.themes().size() - 1;
Theme updated = custom;
updated.background = QColor("#00ff00");
tm.updateTheme(idx, updated);
QCOMPARE(tm.themes()[idx].background, QColor("#00ff00"));
// Remove
tm.removeTheme(idx);
QCOMPARE(tm.themes().size(), initialCount);
}
};
QTEST_MAIN(TestTheme)
#include "test_theme.moc"