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

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