diff --git a/CMakeLists.txt b/CMakeLists.txt index dd946ae..584faa4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/src/editor.cpp b/src/editor.cpp index 3bf51c6..caff396 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -14,15 +14,10 @@ #include #include #include +#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); diff --git a/src/editor.h b/src/editor.h index f9cb5ed..5312308 100644 --- a/src/editor.h +++ b/src/editor.h @@ -1,5 +1,6 @@ #pragma once #include "core.h" +#include "themes/theme.h" #include #include #include @@ -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; diff --git a/src/main.cpp b/src/main.cpp index eaa9403..00cdd02 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -41,6 +41,8 @@ #include #include #include +#include "themes/thememanager.h" +#include "themes/themeeditor.h" #ifdef _WIN32 #include @@ -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("" - "Build " __DATE__ " " __TIME__ "")); + 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); - 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; diff --git a/src/themes/theme.cpp b/src/themes/theme.cpp new file mode 100644 index 0000000..3704a14 --- /dev/null +++ b/src/themes/theme.cpp @@ -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 diff --git a/src/themes/theme.h b/src/themes/theme.h new file mode 100644 index 0000000..6eed657 --- /dev/null +++ b/src/themes/theme.h @@ -0,0 +1,55 @@ +#pragma once +#include +#include +#include + +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 diff --git a/src/themes/themeeditor.cpp b/src/themes/themeeditor.cpp new file mode 100644 index 0000000..3637e1e --- /dev/null +++ b/src/themes/themeeditor.cpp @@ -0,0 +1,95 @@ +#include "themeeditor.h" +#include +#include +#include +#include +#include + +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 diff --git a/src/themes/themeeditor.h b/src/themes/themeeditor.h new file mode 100644 index 0000000..98f2ca9 --- /dev/null +++ b/src/themes/themeeditor.h @@ -0,0 +1,29 @@ +#pragma once +#include "theme.h" +#include +#include +#include + +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 m_swatches; + + void updateSwatch(SwatchEntry& entry); + void pickColor(SwatchEntry& entry); +}; + +} // namespace rcx diff --git a/src/themes/thememanager.cpp b/src/themes/thememanager.cpp new file mode 100644 index 0000000..c493eba --- /dev/null +++ b/src/themes/thememanager.cpp @@ -0,0 +1,119 @@ +#include "thememanager.h" +#include +#include +#include +#include +#include + +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 ThemeManager::themes() const { + QVector 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 diff --git a/src/themes/thememanager.h b/src/themes/thememanager.h new file mode 100644 index 0000000..3050fa6 --- /dev/null +++ b/src/themes/thememanager.h @@ -0,0 +1,38 @@ +#pragma once +#include "theme.h" +#include +#include + +namespace rcx { + +class ThemeManager : public QObject { + Q_OBJECT +public: + static ThemeManager& instance(); + + QVector 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 m_builtIn; + QVector m_user; + int m_currentIdx = 0; + + int builtInCount() const { return m_builtIn.size(); } + QString themesDir() const; +}; + +} // namespace rcx diff --git a/src/typeselectorpopup.cpp b/src/typeselectorpopup.cpp index 0f570d6..28d6cc2 100644 --- a/src/typeselectorpopup.cpp +++ b/src/typeselectorpopup.cpp @@ -14,6 +14,7 @@ #include #include #include +#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); diff --git a/tests/test_theme.cpp b/tests/test_theme.cpp new file mode 100644 index 0000000..790a786 --- /dev/null +++ b/tests/test_theme.cpp @@ -0,0 +1,132 @@ +#include +#include +#include +#include +#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"