mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
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:
119
src/themes/theme.cpp
Normal file
119
src/themes/theme.cpp
Normal 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
55
src/themes/theme.h
Normal 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
|
||||
95
src/themes/themeeditor.cpp
Normal file
95
src/themes/themeeditor.cpp
Normal 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
29
src/themes/themeeditor.h
Normal 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
119
src/themes/thememanager.cpp
Normal 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
38
src/themes/thememanager.h
Normal 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
|
||||
Reference in New Issue
Block a user