mirror of
https://github.com/YACReader/yacreader
synced 2026-04-12 15:49:53 -04:00
Use json file based themes instead of code to create them (theme editor + theme mode settings)
This commit is contained in:
@ -111,17 +111,27 @@ add_library(common_gui STATIC
|
||||
# themes infrastructure (does NOT depend on app-specific theme.h)
|
||||
themes/icon_utils.h
|
||||
themes/icon_utils.cpp
|
||||
themes/theme_id.h
|
||||
themes/appearance_configuration.h
|
||||
themes/appearance_configuration.cpp
|
||||
themes/theme_variant.h
|
||||
themes/themable.h
|
||||
themes/yacreader_icon.h
|
||||
themes/shared/help_about_dialog_theme.h
|
||||
themes/shared/whats_new_dialog_theme.h
|
||||
themes/theme_editor_dialog.h
|
||||
themes/theme_editor_dialog.cpp
|
||||
themes/theme_meta.h
|
||||
themes/theme_repository.h
|
||||
themes/theme_repository.cpp
|
||||
themes/appearance_tab_widget.h
|
||||
themes/appearance_tab_widget.cpp
|
||||
)
|
||||
target_include_directories(common_gui PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/themes
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/themes/shared
|
||||
)
|
||||
|
||||
target_link_libraries(common_gui PUBLIC
|
||||
Qt::Core
|
||||
Qt::Core5Compat
|
||||
|
||||
7
common/themes/appearance_config_images.qrc
Normal file
7
common/themes/appearance_config_images.qrc
Normal file
@ -0,0 +1,7 @@
|
||||
<RCC>
|
||||
<qresource prefix="/images/appearance_config">
|
||||
<file>../../images/appearance_config/theme-mode-system.svg</file>
|
||||
<file>../../images/appearance_config/theme-mode-light.svg</file>
|
||||
<file>../../images/appearance_config/theme-mode-dark.svg</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
96
common/themes/appearance_configuration.cpp
Normal file
96
common/themes/appearance_configuration.cpp
Normal file
@ -0,0 +1,96 @@
|
||||
#include "appearance_configuration.h"
|
||||
|
||||
#include <QSettings>
|
||||
|
||||
static constexpr auto kGroup = "Appearance";
|
||||
static constexpr auto kMode = "ThemeMode";
|
||||
static constexpr auto kLightId = "LightThemeId";
|
||||
static constexpr auto kDarkId = "DarkThemeId";
|
||||
static constexpr auto kFixedId = "FixedThemeId";
|
||||
|
||||
static QString themeModeToString(ThemeMode mode)
|
||||
{
|
||||
switch (mode) {
|
||||
case ThemeMode::FollowSystem:
|
||||
return "FollowSystem";
|
||||
case ThemeMode::Light:
|
||||
return "Light";
|
||||
case ThemeMode::Dark:
|
||||
return "Dark";
|
||||
case ThemeMode::ForcedTheme:
|
||||
return "ForcedTheme";
|
||||
}
|
||||
return "FollowSystem";
|
||||
}
|
||||
|
||||
static ThemeMode themeModeFromString(const QString &s)
|
||||
{
|
||||
if (s == "Light")
|
||||
return ThemeMode::Light;
|
||||
if (s == "Dark")
|
||||
return ThemeMode::Dark;
|
||||
if (s == "ForcedTheme")
|
||||
return ThemeMode::ForcedTheme;
|
||||
return ThemeMode::FollowSystem;
|
||||
}
|
||||
|
||||
AppearanceConfiguration::AppearanceConfiguration(const QString &settingsFilePath, QObject *parent)
|
||||
: QObject(parent), path(settingsFilePath)
|
||||
{
|
||||
load();
|
||||
}
|
||||
|
||||
void AppearanceConfiguration::load()
|
||||
{
|
||||
QSettings s(path, QSettings::IniFormat);
|
||||
s.beginGroup(kGroup);
|
||||
sel.mode = themeModeFromString(s.value(kMode, "FollowSystem").toString());
|
||||
sel.lightThemeId = s.value(kLightId, sel.lightThemeId).toString();
|
||||
sel.darkThemeId = s.value(kDarkId, sel.darkThemeId).toString();
|
||||
sel.fixedThemeId = s.value(kFixedId, sel.fixedThemeId).toString();
|
||||
s.endGroup();
|
||||
}
|
||||
|
||||
void AppearanceConfiguration::write(const QString &key, const QString &value)
|
||||
{
|
||||
QSettings s(path, QSettings::IniFormat);
|
||||
s.beginGroup(kGroup);
|
||||
s.setValue(key, value);
|
||||
s.endGroup();
|
||||
}
|
||||
|
||||
void AppearanceConfiguration::setMode(ThemeMode mode)
|
||||
{
|
||||
if (sel.mode == mode)
|
||||
return;
|
||||
sel.mode = mode;
|
||||
write(kMode, themeModeToString(mode));
|
||||
emit selectionChanged();
|
||||
}
|
||||
|
||||
void AppearanceConfiguration::setLightThemeId(const QString &id)
|
||||
{
|
||||
if (sel.lightThemeId == id)
|
||||
return;
|
||||
sel.lightThemeId = id;
|
||||
write(kLightId, id);
|
||||
emit selectionChanged();
|
||||
}
|
||||
|
||||
void AppearanceConfiguration::setDarkThemeId(const QString &id)
|
||||
{
|
||||
if (sel.darkThemeId == id)
|
||||
return;
|
||||
sel.darkThemeId = id;
|
||||
write(kDarkId, id);
|
||||
emit selectionChanged();
|
||||
}
|
||||
|
||||
void AppearanceConfiguration::setFixedThemeId(const QString &id)
|
||||
{
|
||||
if (sel.fixedThemeId == id)
|
||||
return;
|
||||
sel.fixedThemeId = id;
|
||||
write(kFixedId, id);
|
||||
emit selectionChanged();
|
||||
}
|
||||
48
common/themes/appearance_configuration.h
Normal file
48
common/themes/appearance_configuration.h
Normal file
@ -0,0 +1,48 @@
|
||||
#ifndef APPEARANCE_CONFIGURATION_H
|
||||
#define APPEARANCE_CONFIGURATION_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
enum class ThemeMode {
|
||||
FollowSystem,
|
||||
Light,
|
||||
Dark,
|
||||
ForcedTheme,
|
||||
};
|
||||
|
||||
struct ThemeSelection {
|
||||
ThemeMode mode = ThemeMode::FollowSystem;
|
||||
QString lightThemeId = "builtin/light";
|
||||
QString darkThemeId = "builtin/dark";
|
||||
QString fixedThemeId = "builtin/classic";
|
||||
};
|
||||
|
||||
// Persists theme selection settings to a QSettings INI file under the
|
||||
// [Appearance] group. All access is on-demand (no persistent QSettings handle)
|
||||
// so the caller does not need to manage a QSettings lifetime.
|
||||
class AppearanceConfiguration : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AppearanceConfiguration(const QString &settingsFilePath, QObject *parent = nullptr);
|
||||
|
||||
ThemeSelection selection() const { return sel; }
|
||||
|
||||
void setMode(ThemeMode mode);
|
||||
void setLightThemeId(const QString &id);
|
||||
void setDarkThemeId(const QString &id);
|
||||
void setFixedThemeId(const QString &id);
|
||||
|
||||
signals:
|
||||
void selectionChanged();
|
||||
|
||||
private:
|
||||
QString path;
|
||||
ThemeSelection sel;
|
||||
|
||||
void load();
|
||||
void write(const QString &key, const QString &value);
|
||||
};
|
||||
|
||||
#endif // APPEARANCE_CONFIGURATION_H
|
||||
98
common/themes/appearance_tab_widget.cpp
Normal file
98
common/themes/appearance_tab_widget.cpp
Normal file
@ -0,0 +1,98 @@
|
||||
#include "appearance_tab_widget.h"
|
||||
|
||||
#include "appearance_configuration.h"
|
||||
#include "theme_editor_dialog.h"
|
||||
|
||||
#include <QButtonGroup>
|
||||
#include <QGroupBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
AppearanceTabWidget::AppearanceTabWidget(
|
||||
AppearanceConfiguration *config,
|
||||
std::function<QJsonObject()> currentThemeJson,
|
||||
std::function<void(const QJsonObject &)> applyTheme,
|
||||
QWidget *parent)
|
||||
: QWidget(parent), config(config), currentThemeJson(std::move(currentThemeJson)), applyTheme(std::move(applyTheme))
|
||||
{
|
||||
// Color scheme selector
|
||||
auto *modeBox = new QGroupBox(tr("Color scheme"), this);
|
||||
auto *modeLayout = new QHBoxLayout();
|
||||
|
||||
auto *sysBtn = new QToolButton();
|
||||
auto *lightBtn = new QToolButton();
|
||||
auto *darkBtn = new QToolButton();
|
||||
|
||||
sysBtn->setText(tr("System"));
|
||||
lightBtn->setText(tr("Light"));
|
||||
darkBtn->setText(tr("Dark"));
|
||||
|
||||
sysBtn->setIcon(QIcon(":/images/appearance_config/theme-mode-system.svg"));
|
||||
lightBtn->setIcon(QIcon(":/images/appearance_config/theme-mode-light.svg"));
|
||||
darkBtn->setIcon(QIcon(":/images/appearance_config/theme-mode-dark.svg"));
|
||||
|
||||
for (auto *btn : { sysBtn, lightBtn, darkBtn }) {
|
||||
btn->setCheckable(true);
|
||||
btn->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
|
||||
btn->setIconSize(QSize(93, 58));
|
||||
btn->setMinimumSize(115, 90);
|
||||
}
|
||||
|
||||
auto *modeGroup = new QButtonGroup(this);
|
||||
modeGroup->addButton(sysBtn, static_cast<int>(ThemeMode::FollowSystem));
|
||||
modeGroup->addButton(lightBtn, static_cast<int>(ThemeMode::Light));
|
||||
modeGroup->addButton(darkBtn, static_cast<int>(ThemeMode::Dark));
|
||||
modeGroup->setExclusive(true);
|
||||
|
||||
if (this->config) {
|
||||
const auto mode = this->config->selection().mode;
|
||||
if (auto *btn = modeGroup->button(static_cast<int>(mode)))
|
||||
btn->setChecked(true);
|
||||
}
|
||||
|
||||
connect(modeGroup, &QButtonGroup::idClicked, this, [this](int id) {
|
||||
if (this->config)
|
||||
this->config->setMode(static_cast<ThemeMode>(id));
|
||||
});
|
||||
|
||||
modeLayout->addStretch();
|
||||
modeLayout->addWidget(sysBtn);
|
||||
modeLayout->addWidget(lightBtn);
|
||||
modeLayout->addWidget(darkBtn);
|
||||
modeLayout->addStretch();
|
||||
modeBox->setLayout(modeLayout);
|
||||
|
||||
// Theme editor
|
||||
auto *themeEditorBox = new QGroupBox(tr("Theme editor"), this);
|
||||
auto *themeEditorLayout = new QVBoxLayout();
|
||||
auto *openBtn = new QPushButton(tr("Open Theme Editor..."));
|
||||
themeEditorLayout->addWidget(openBtn);
|
||||
themeEditorBox->setLayout(themeEditorLayout);
|
||||
|
||||
connect(openBtn, &QPushButton::clicked, this, [this]() {
|
||||
if (!themeEditor) {
|
||||
QJsonObject json = this->currentThemeJson();
|
||||
if (json.isEmpty()) {
|
||||
QMessageBox::critical(this,
|
||||
tr("Theme editor error"),
|
||||
tr("The current theme JSON could not be loaded."));
|
||||
return;
|
||||
}
|
||||
themeEditor = new ThemeEditorDialog(json, this);
|
||||
themeEditor->setAttribute(Qt::WA_DeleteOnClose);
|
||||
connect(themeEditor, &ThemeEditorDialog::themeJsonChanged, this,
|
||||
[this](const QJsonObject &json) { this->applyTheme(json); });
|
||||
}
|
||||
themeEditor->show();
|
||||
themeEditor->raise();
|
||||
themeEditor->activateWindow();
|
||||
});
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->addWidget(modeBox);
|
||||
layout->addWidget(themeEditorBox);
|
||||
layout->addStretch();
|
||||
}
|
||||
29
common/themes/appearance_tab_widget.h
Normal file
29
common/themes/appearance_tab_widget.h
Normal file
@ -0,0 +1,29 @@
|
||||
#ifndef APPEARANCE_TAB_WIDGET_H
|
||||
#define APPEARANCE_TAB_WIDGET_H
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QPointer>
|
||||
#include <QWidget>
|
||||
#include <functional>
|
||||
|
||||
class AppearanceConfiguration;
|
||||
class ThemeEditorDialog;
|
||||
|
||||
class AppearanceTabWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AppearanceTabWidget(
|
||||
AppearanceConfiguration *config,
|
||||
std::function<QJsonObject()> currentThemeJson,
|
||||
std::function<void(const QJsonObject &)> applyTheme,
|
||||
QWidget *parent = nullptr);
|
||||
|
||||
private:
|
||||
AppearanceConfiguration *config;
|
||||
std::function<QJsonObject()> currentThemeJson;
|
||||
std::function<void(const QJsonObject &)> applyTheme;
|
||||
QPointer<ThemeEditorDialog> themeEditor;
|
||||
};
|
||||
|
||||
#endif // APPEARANCE_TAB_WIDGET_H
|
||||
477
common/themes/theme_editor_dialog.cpp
Normal file
477
common/themes/theme_editor_dialog.cpp
Normal file
@ -0,0 +1,477 @@
|
||||
#include "theme_editor_dialog.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <QComboBox>
|
||||
#include <QFormLayout>
|
||||
#include <QGroupBox>
|
||||
#include <QLabel>
|
||||
#include <QTreeWidget>
|
||||
#include <QTreeWidgetItem>
|
||||
#include <QColorDialog>
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QPushButton>
|
||||
#include <QHeaderView>
|
||||
#include <QPixmap>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QApplication>
|
||||
#include <QFileDialog>
|
||||
#include <QFile>
|
||||
#include <QInputDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QUuid>
|
||||
|
||||
// Role used to store the JSON path (QStringList) on each leaf item.
|
||||
static const int PathRole = Qt::UserRole;
|
||||
// Role used to distinguish color items from others.
|
||||
static const int IsColorRole = Qt::UserRole + 1;
|
||||
// Role used to distinguish boolean items.
|
||||
static const int IsBoolRole = Qt::UserRole + 2;
|
||||
// Role used to distinguish numeric items.
|
||||
static const int IsNumberRole = Qt::UserRole + 3;
|
||||
|
||||
static bool isColorString(const QString &s)
|
||||
{
|
||||
// Accepts #RGB, #RRGGBB, #AARRGGBB
|
||||
if (!s.startsWith('#'))
|
||||
return false;
|
||||
const int len = s.length();
|
||||
return len == 4 || len == 7 || len == 9;
|
||||
}
|
||||
|
||||
ThemeEditorDialog::ThemeEditorDialog(const QJsonObject ¶ms, QWidget *parent)
|
||||
: QDialog(parent), params(params)
|
||||
{
|
||||
setWindowTitle(tr("Theme Editor"));
|
||||
resize(520, 700);
|
||||
|
||||
// --- top toolbar ---
|
||||
auto *expandBtn = new QPushButton(tr("+"), this);
|
||||
auto *collapseBtn = new QPushButton(tr("-"), this);
|
||||
auto *identifyBtn = new QPushButton(tr("i"), this);
|
||||
expandBtn->setFixedWidth(28);
|
||||
collapseBtn->setFixedWidth(28);
|
||||
identifyBtn->setFixedWidth(28);
|
||||
expandBtn->setToolTip(tr("Expand all"));
|
||||
collapseBtn->setToolTip(tr("Collapse all"));
|
||||
identifyBtn->setToolTip(tr("Hold to flash the selected value in the UI (magenta / toggled / 0↔10). Releases restore the original."));
|
||||
// NoFocus so clicking the button doesn't steal the tree's current item
|
||||
identifyBtn->setFocusPolicy(Qt::NoFocus);
|
||||
|
||||
searchEdit = new QLineEdit(this);
|
||||
searchEdit->setPlaceholderText(tr("Search…"));
|
||||
searchEdit->setClearButtonEnabled(true);
|
||||
|
||||
auto *toolbar = new QHBoxLayout();
|
||||
toolbar->addWidget(expandBtn);
|
||||
toolbar->addWidget(collapseBtn);
|
||||
toolbar->addWidget(identifyBtn);
|
||||
toolbar->addStretch();
|
||||
toolbar->addWidget(searchEdit);
|
||||
|
||||
connect(identifyBtn, &QPushButton::pressed, this, &ThemeEditorDialog::identifyPressed);
|
||||
connect(identifyBtn, &QPushButton::released, this, &ThemeEditorDialog::identifyReleased);
|
||||
|
||||
// --- meta section ---
|
||||
idLabel = new QLabel(this);
|
||||
idLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
nameEdit = new QLineEdit(this);
|
||||
variantCombo = new QComboBox(this);
|
||||
variantCombo->addItem(tr("Light"), "light");
|
||||
variantCombo->addItem(tr("Dark"), "dark");
|
||||
|
||||
auto *metaForm = new QFormLayout();
|
||||
metaForm->addRow(tr("ID:"), idLabel);
|
||||
metaForm->addRow(tr("Display name:"), nameEdit);
|
||||
metaForm->addRow(tr("Variant:"), variantCombo);
|
||||
|
||||
auto *metaBox = new QGroupBox(tr("Theme info"), this);
|
||||
metaBox->setLayout(metaForm);
|
||||
|
||||
syncMetaFromParams();
|
||||
|
||||
connect(nameEdit, &QLineEdit::textEdited, this, [this](const QString &text) {
|
||||
auto meta = this->params["meta"].toObject();
|
||||
meta["displayName"] = text;
|
||||
this->params["meta"] = meta;
|
||||
emit themeJsonChanged(this->params);
|
||||
});
|
||||
connect(variantCombo, &QComboBox::currentIndexChanged, this, [this](int index) {
|
||||
auto meta = this->params["meta"].toObject();
|
||||
meta["variant"] = variantCombo->itemData(index).toString();
|
||||
this->params["meta"] = meta;
|
||||
emit themeJsonChanged(this->params);
|
||||
});
|
||||
|
||||
// --- tree ---
|
||||
tree = new QTreeWidget(this);
|
||||
tree->setColumnCount(2);
|
||||
tree->setHeaderLabels({ tr("Parameter"), tr("Value") });
|
||||
tree->header()->setSectionResizeMode(0, QHeaderView::Stretch);
|
||||
tree->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents);
|
||||
tree->setRootIsDecorated(true);
|
||||
tree->setUniformRowHeights(true);
|
||||
tree->setAlternatingRowColors(true);
|
||||
|
||||
populate(nullptr, params, {});
|
||||
tree->expandAll();
|
||||
|
||||
connect(expandBtn, &QPushButton::clicked, tree, &QTreeWidget::expandAll);
|
||||
connect(collapseBtn, &QPushButton::clicked, tree, &QTreeWidget::collapseAll);
|
||||
connect(searchEdit, &QLineEdit::textChanged, this, &ThemeEditorDialog::filterTree);
|
||||
|
||||
connect(tree, &QTreeWidget::itemDoubleClicked, this, [this](QTreeWidgetItem *item, int) {
|
||||
if (item->data(0, IsColorRole).toBool())
|
||||
editColorItem(item);
|
||||
else if (item->data(0, IsBoolRole).toBool())
|
||||
toggleBoolItem(item);
|
||||
else if (item->data(0, IsNumberRole).toBool())
|
||||
editNumberItem(item);
|
||||
});
|
||||
|
||||
// --- bottom buttons ---
|
||||
auto *saveBtn = new QPushButton(tr("Save to file..."), this);
|
||||
auto *loadBtn = new QPushButton(tr("Load from file..."), this);
|
||||
auto *closeBtn = new QPushButton(tr("Close"), this);
|
||||
connect(saveBtn, &QPushButton::clicked, this, &ThemeEditorDialog::saveToFile);
|
||||
connect(loadBtn, &QPushButton::clicked, this, &ThemeEditorDialog::loadFromFile);
|
||||
connect(closeBtn, &QPushButton::clicked, this, &QDialog::close);
|
||||
auto *buttons = new QHBoxLayout();
|
||||
buttons->addWidget(saveBtn);
|
||||
buttons->addWidget(loadBtn);
|
||||
buttons->addStretch();
|
||||
buttons->addWidget(closeBtn);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->addLayout(toolbar);
|
||||
layout->addWidget(metaBox);
|
||||
layout->addWidget(tree);
|
||||
layout->addLayout(buttons);
|
||||
|
||||
setLayout(layout);
|
||||
}
|
||||
|
||||
void ThemeEditorDialog::populate(QTreeWidgetItem *parent, const QJsonObject &obj, const QStringList &path)
|
||||
{
|
||||
for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) {
|
||||
const QString key = it.key();
|
||||
|
||||
// "meta" is handled by the dedicated UI above the tree
|
||||
if (path.isEmpty() && key == "meta")
|
||||
continue;
|
||||
const QJsonValue val = it.value();
|
||||
const QStringList childPath = path + QStringList(key);
|
||||
|
||||
if (val.isObject()) {
|
||||
// Group row
|
||||
QTreeWidgetItem *group = parent ? new QTreeWidgetItem(parent)
|
||||
: new QTreeWidgetItem(tree);
|
||||
QFont bold = group->font(0);
|
||||
bold.setBold(true);
|
||||
group->setFont(0, bold);
|
||||
group->setText(0, key);
|
||||
group->setFlags(group->flags() & ~Qt::ItemIsSelectable);
|
||||
populate(group, val.toObject(), childPath);
|
||||
} else {
|
||||
// Leaf row
|
||||
QTreeWidgetItem *item = parent ? new QTreeWidgetItem(parent)
|
||||
: new QTreeWidgetItem(tree);
|
||||
item->setText(0, key);
|
||||
item->setData(0, PathRole, childPath);
|
||||
|
||||
const QString strVal = val.toString();
|
||||
if (val.isString() && isColorString(strVal)) {
|
||||
const QColor color(strVal);
|
||||
item->setIcon(1, colorIcon(color));
|
||||
item->setText(1, strVal);
|
||||
item->setData(0, IsColorRole, true);
|
||||
item->setToolTip(1, tr("Double-click to edit color"));
|
||||
} else if (val.isBool()) {
|
||||
item->setText(1, val.toBool() ? tr("true") : tr("false"));
|
||||
item->setData(0, IsColorRole, false);
|
||||
item->setData(0, IsBoolRole, true);
|
||||
item->setToolTip(1, tr("Double-click to toggle"));
|
||||
} else if (val.isDouble()) {
|
||||
item->setText(1, QString::number(val.toDouble()));
|
||||
item->setData(0, IsNumberRole, true);
|
||||
item->setToolTip(1, tr("Double-click to edit value"));
|
||||
} else {
|
||||
item->setText(1, strVal);
|
||||
item->setData(0, IsColorRole, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ThemeEditorDialog::editColorItem(QTreeWidgetItem *item)
|
||||
{
|
||||
const QColor current(item->text(1));
|
||||
QColorDialog dialog(current, this);
|
||||
dialog.setOption(QColorDialog::ShowAlphaChannel, true);
|
||||
dialog.setWindowTitle(tr("Edit: %1").arg(item->text(0)));
|
||||
|
||||
// Live update as user drags the picker
|
||||
connect(&dialog, &QColorDialog::currentColorChanged, this, [this, item](const QColor &color) {
|
||||
applyColorToItem(item, color);
|
||||
emit themeJsonChanged(params);
|
||||
});
|
||||
|
||||
if (dialog.exec() == QDialog::Accepted) {
|
||||
applyColorToItem(item, dialog.selectedColor());
|
||||
} else {
|
||||
// Revert to original if cancelled
|
||||
applyColorToItem(item, current);
|
||||
}
|
||||
emit themeJsonChanged(params);
|
||||
}
|
||||
|
||||
void ThemeEditorDialog::applyColorToItem(QTreeWidgetItem *item, const QColor &color)
|
||||
{
|
||||
const QString hexStr = color.alpha() < 255
|
||||
? color.name(QColor::HexArgb)
|
||||
: color.name(QColor::HexRgb);
|
||||
item->setText(1, hexStr);
|
||||
item->setIcon(1, colorIcon(color));
|
||||
|
||||
const QStringList path = item->data(0, PathRole).toStringList();
|
||||
setJsonPath(params, path, hexStr);
|
||||
}
|
||||
|
||||
void ThemeEditorDialog::toggleBoolItem(QTreeWidgetItem *item)
|
||||
{
|
||||
const bool newValue = item->text(1) != tr("true");
|
||||
item->setText(1, newValue ? tr("true") : tr("false"));
|
||||
const QStringList path = item->data(0, PathRole).toStringList();
|
||||
setJsonPath(params, path, newValue);
|
||||
emit themeJsonChanged(params);
|
||||
}
|
||||
|
||||
void ThemeEditorDialog::editNumberItem(QTreeWidgetItem *item)
|
||||
{
|
||||
const double current = item->text(1).toDouble();
|
||||
// Use integer dialog when the stored value has no fractional part
|
||||
const bool isInt = (current == std::floor(current));
|
||||
bool ok = false;
|
||||
double newValue;
|
||||
if (isInt) {
|
||||
const int result = QInputDialog::getInt(
|
||||
this, tr("Edit: %1").arg(item->text(0)), item->text(0),
|
||||
static_cast<int>(current), INT_MIN, INT_MAX, 1, &ok);
|
||||
newValue = result;
|
||||
} else {
|
||||
newValue = QInputDialog::getDouble(
|
||||
this, tr("Edit: %1").arg(item->text(0)), item->text(0),
|
||||
current, -1e9, 1e9, 4, &ok);
|
||||
}
|
||||
if (!ok)
|
||||
return;
|
||||
item->setText(1, isInt ? QString::number(static_cast<int>(newValue)) : QString::number(newValue));
|
||||
const QStringList path = item->data(0, PathRole).toStringList();
|
||||
setJsonPath(params, path, newValue);
|
||||
emit themeJsonChanged(params);
|
||||
}
|
||||
|
||||
// Returns true if the item or any of its descendants should be visible.
|
||||
static bool applyFilter(QTreeWidgetItem *item, const QString &query)
|
||||
{
|
||||
if (query.isEmpty()) {
|
||||
item->setHidden(false);
|
||||
for (int i = 0; i < item->childCount(); ++i)
|
||||
applyFilter(item->child(i), query);
|
||||
return true;
|
||||
}
|
||||
|
||||
const bool selfMatch = item->text(0).contains(query, Qt::CaseInsensitive);
|
||||
|
||||
if (item->childCount() == 0) {
|
||||
// Leaf: match on key name or value text
|
||||
const bool match = selfMatch || item->text(1).contains(query, Qt::CaseInsensitive);
|
||||
item->setHidden(!match);
|
||||
return match;
|
||||
}
|
||||
|
||||
// Group: if the group name itself matches, show all children
|
||||
bool anyChildVisible = false;
|
||||
for (int i = 0; i < item->childCount(); ++i) {
|
||||
if (selfMatch) {
|
||||
item->child(i)->setHidden(false);
|
||||
anyChildVisible = true;
|
||||
} else {
|
||||
if (applyFilter(item->child(i), query))
|
||||
anyChildVisible = true;
|
||||
}
|
||||
}
|
||||
item->setHidden(!anyChildVisible);
|
||||
return anyChildVisible;
|
||||
}
|
||||
|
||||
void ThemeEditorDialog::filterTree(const QString &query)
|
||||
{
|
||||
for (int i = 0; i < tree->topLevelItemCount(); ++i)
|
||||
applyFilter(tree->topLevelItem(i), query);
|
||||
|
||||
// Keep visible results expanded so they're reachable
|
||||
if (!query.isEmpty())
|
||||
tree->expandAll();
|
||||
}
|
||||
|
||||
QIcon ThemeEditorDialog::colorIcon(const QColor &color)
|
||||
{
|
||||
const int size = qApp->style()->pixelMetric(QStyle::PM_SmallIconSize);
|
||||
QPixmap pix(size, size);
|
||||
pix.fill(color);
|
||||
return QIcon(pix);
|
||||
}
|
||||
|
||||
void ThemeEditorDialog::setJsonPath(QJsonObject &root, const QStringList &path, const QJsonValue &value)
|
||||
{
|
||||
if (path.isEmpty())
|
||||
return;
|
||||
if (path.size() == 1) {
|
||||
root[path[0]] = value;
|
||||
return;
|
||||
}
|
||||
QJsonObject sub = root[path[0]].toObject();
|
||||
setJsonPath(sub, path.mid(1), value);
|
||||
root[path[0]] = sub;
|
||||
}
|
||||
|
||||
void ThemeEditorDialog::syncMetaToParams()
|
||||
{
|
||||
auto meta = params["meta"].toObject();
|
||||
meta["displayName"] = nameEdit->text();
|
||||
meta["variant"] = variantCombo->currentData().toString();
|
||||
params["meta"] = meta;
|
||||
}
|
||||
|
||||
void ThemeEditorDialog::syncMetaFromParams()
|
||||
{
|
||||
const auto meta = params["meta"].toObject();
|
||||
idLabel->setText(meta["id"].toString());
|
||||
nameEdit->setText(meta["displayName"].toString());
|
||||
const QString variant = meta["variant"].toString("dark");
|
||||
variantCombo->setCurrentIndex(variant == "light" ? 0 : 1);
|
||||
}
|
||||
|
||||
void ThemeEditorDialog::saveToFile()
|
||||
{
|
||||
// Assign a user-scoped UUID if the current id is builtin or empty
|
||||
auto meta = params["meta"].toObject();
|
||||
const QString currentId = meta["id"].toString();
|
||||
if (currentId.isEmpty() || currentId.startsWith("builtin/")) {
|
||||
const QString newId = "user/" + QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
meta["id"] = newId;
|
||||
params["meta"] = meta;
|
||||
idLabel->setText(newId);
|
||||
}
|
||||
|
||||
const QString path = QFileDialog::getSaveFileName(
|
||||
this, tr("Save theme"), QString(), tr("JSON files (*.json);;All files (*)"));
|
||||
if (path.isEmpty())
|
||||
return;
|
||||
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
QMessageBox::warning(this, tr("Save failed"), tr("Could not open file for writing:\n%1").arg(path));
|
||||
return;
|
||||
}
|
||||
file.write(QJsonDocument(params).toJson(QJsonDocument::Indented));
|
||||
}
|
||||
|
||||
void ThemeEditorDialog::loadFromFile()
|
||||
{
|
||||
const QString path = QFileDialog::getOpenFileName(
|
||||
this, tr("Load theme"), QString(), tr("JSON files (*.json);;All files (*)"));
|
||||
if (path.isEmpty())
|
||||
return;
|
||||
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
QMessageBox::warning(this, tr("Load failed"), tr("Could not open file:\n%1").arg(path));
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonParseError err;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &err);
|
||||
if (doc.isNull()) {
|
||||
QMessageBox::warning(this, tr("Load failed"), tr("Invalid JSON:\n%1").arg(err.errorString()));
|
||||
return;
|
||||
}
|
||||
if (!doc.isObject()) {
|
||||
QMessageBox::warning(this, tr("Load failed"), tr("Expected a JSON object."));
|
||||
return;
|
||||
}
|
||||
|
||||
params = doc.object();
|
||||
syncMetaFromParams();
|
||||
tree->clear();
|
||||
populate(nullptr, params, {});
|
||||
tree->expandAll();
|
||||
emit themeJsonChanged(params);
|
||||
}
|
||||
|
||||
void ThemeEditorDialog::identifyPressed()
|
||||
{
|
||||
QTreeWidgetItem *item = tree->currentItem();
|
||||
if (!item)
|
||||
return;
|
||||
|
||||
const QStringList path = item->data(0, PathRole).toStringList();
|
||||
if (path.isEmpty())
|
||||
return; // group row, not a leaf
|
||||
|
||||
if (item->data(0, IsColorRole).toBool()) {
|
||||
identifySnapshot = QJsonValue(item->text(1));
|
||||
identifyItem = item;
|
||||
identifyPath = path;
|
||||
applyColorToItem(item, QColor(0xFA00FA));
|
||||
} else if (item->data(0, IsBoolRole).toBool()) {
|
||||
const bool current = (item->text(1) == tr("true"));
|
||||
identifySnapshot = QJsonValue(current);
|
||||
identifyItem = item;
|
||||
identifyPath = path;
|
||||
const bool flipped = !current;
|
||||
item->setText(1, flipped ? tr("true") : tr("false"));
|
||||
setJsonPath(params, path, flipped);
|
||||
} else if (item->data(0, IsNumberRole).toBool()) {
|
||||
const double current = item->text(1).toDouble();
|
||||
identifySnapshot = QJsonValue(current);
|
||||
identifyItem = item;
|
||||
identifyPath = path;
|
||||
const bool isInt = (current == std::floor(current));
|
||||
const double highlight = (current > 0.0) ? 0.0 : 10.0;
|
||||
item->setText(1, isInt ? QString::number(static_cast<int>(highlight)) : QString::number(highlight));
|
||||
setJsonPath(params, path, highlight);
|
||||
} else {
|
||||
return; // non-editable leaf (plain string), nothing to flash
|
||||
}
|
||||
emit themeJsonChanged(params);
|
||||
}
|
||||
|
||||
void ThemeEditorDialog::identifyReleased()
|
||||
{
|
||||
if (!identifyItem)
|
||||
return;
|
||||
|
||||
QTreeWidgetItem *item = identifyItem;
|
||||
const QStringList path = identifyPath;
|
||||
identifyItem = nullptr;
|
||||
identifyPath.clear();
|
||||
|
||||
if (item->data(0, IsColorRole).toBool()) {
|
||||
applyColorToItem(item, QColor(identifySnapshot.toString()));
|
||||
} else if (item->data(0, IsBoolRole).toBool()) {
|
||||
const bool restored = identifySnapshot.toBool();
|
||||
item->setText(1, restored ? tr("true") : tr("false"));
|
||||
setJsonPath(params, path, restored);
|
||||
} else if (item->data(0, IsNumberRole).toBool()) {
|
||||
const double restored = identifySnapshot.toDouble();
|
||||
const bool isInt = (restored == std::floor(restored));
|
||||
item->setText(1, isInt ? QString::number(static_cast<int>(restored)) : QString::number(restored));
|
||||
setJsonPath(params, path, restored);
|
||||
}
|
||||
identifySnapshot = QJsonValue();
|
||||
emit themeJsonChanged(params);
|
||||
}
|
||||
63
common/themes/theme_editor_dialog.h
Normal file
63
common/themes/theme_editor_dialog.h
Normal file
@ -0,0 +1,63 @@
|
||||
#ifndef THEME_EDITOR_DIALOG_H
|
||||
#define THEME_EDITOR_DIALOG_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QJsonObject>
|
||||
|
||||
class QComboBox;
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QTreeWidget;
|
||||
class QTreeWidgetItem;
|
||||
|
||||
// Generic theme parameter editor.
|
||||
// Works entirely on QJsonObject — has no knowledge of app-specific ThemeParams.
|
||||
// Connect to themeJsonChanged to receive live updates as the user edits colors.
|
||||
class ThemeEditorDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ThemeEditorDialog(const QJsonObject ¶ms, QWidget *parent = nullptr);
|
||||
|
||||
QJsonObject currentParams() const { return params; }
|
||||
|
||||
signals:
|
||||
void themeJsonChanged(const QJsonObject ¶ms);
|
||||
|
||||
private:
|
||||
void populate(QTreeWidgetItem *parent, const QJsonObject &obj, const QStringList &path);
|
||||
void editColorItem(QTreeWidgetItem *item);
|
||||
void applyColorToItem(QTreeWidgetItem *item, const QColor &color);
|
||||
void toggleBoolItem(QTreeWidgetItem *item);
|
||||
void editNumberItem(QTreeWidgetItem *item);
|
||||
void filterTree(const QString &query);
|
||||
void saveToFile();
|
||||
void loadFromFile();
|
||||
|
||||
// Identify feature: hold the (i) button to temporarily flash the UI element
|
||||
// that uses the current item's value. The original value is restored on release.
|
||||
void identifyPressed();
|
||||
void identifyReleased();
|
||||
|
||||
static QIcon colorIcon(const QColor &color);
|
||||
static void setJsonPath(QJsonObject &root, const QStringList &path, const QJsonValue &value);
|
||||
|
||||
QTreeWidget *tree;
|
||||
QLineEdit *searchEdit;
|
||||
QJsonObject params;
|
||||
|
||||
// Meta UI
|
||||
QLabel *idLabel;
|
||||
QLineEdit *nameEdit;
|
||||
QComboBox *variantCombo;
|
||||
|
||||
void syncMetaToParams();
|
||||
void syncMetaFromParams();
|
||||
|
||||
// Identify state (null item = inactive)
|
||||
QTreeWidgetItem *identifyItem = nullptr;
|
||||
QStringList identifyPath;
|
||||
QJsonValue identifySnapshot; // original value saved on press, restored on release
|
||||
};
|
||||
|
||||
#endif // THEME_EDITOR_DIALOG_H
|
||||
@ -1,10 +0,0 @@
|
||||
#ifndef THEME_ID_H
|
||||
#define THEME_ID_H
|
||||
|
||||
enum class ThemeId {
|
||||
Classic,
|
||||
Light,
|
||||
Dark,
|
||||
};
|
||||
|
||||
#endif // THEME_ID_H
|
||||
@ -1,14 +1,13 @@
|
||||
#include "theme_manager.h"
|
||||
|
||||
#include "appearance_configuration.h"
|
||||
#include "theme.h"
|
||||
#include "theme_factory.h"
|
||||
#include "theme_repository.h"
|
||||
|
||||
#include <QGuiApplication>
|
||||
#include <QPalette>
|
||||
#include <QStyleHints>
|
||||
|
||||
// TODO: add API to force color scheme //styleHints->setColorScheme(Qt::ColorScheme::Dark);
|
||||
|
||||
ThemeManager::ThemeManager()
|
||||
{
|
||||
}
|
||||
@ -19,36 +18,106 @@ ThemeManager &ThemeManager::instance()
|
||||
return instance;
|
||||
}
|
||||
|
||||
void ThemeManager::initialize()
|
||||
void ThemeManager::initialize(AppearanceConfiguration *config, ThemeRepository *repository)
|
||||
{
|
||||
this->config = config;
|
||||
this->repository = repository;
|
||||
|
||||
auto *styleHints = qGuiApp->styleHints();
|
||||
|
||||
auto colorScheme = styleHints->colorScheme();
|
||||
// Re-resolve when OS color scheme changes (relevant for FollowSystem mode)
|
||||
connect(styleHints, &QStyleHints::colorSchemeChanged, this, &ThemeManager::resolveTheme, Qt::QueuedConnection);
|
||||
|
||||
// TODO: settings are needed to decide what theme to use
|
||||
auto applyColorScheme = [this](Qt::ColorScheme scheme) {
|
||||
setTheme(scheme == Qt::ColorScheme::Dark ? ThemeId::Dark : ThemeId::Light);
|
||||
};
|
||||
// Re-resolve when the user changes any theme setting
|
||||
connect(config, &AppearanceConfiguration::selectionChanged, this, &ThemeManager::resolveTheme);
|
||||
|
||||
applyColorScheme(colorScheme);
|
||||
|
||||
connect(styleHints, &QStyleHints::colorSchemeChanged, this, applyColorScheme, Qt::QueuedConnection);
|
||||
resolveTheme();
|
||||
}
|
||||
|
||||
void ThemeManager::setTheme(ThemeId themeId)
|
||||
void ThemeManager::setTheme(const Theme &theme)
|
||||
{
|
||||
if (this->themeId == themeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this->themeId = themeId;
|
||||
|
||||
updateCurrentTheme();
|
||||
|
||||
currentTheme = theme;
|
||||
emit themeChanged();
|
||||
}
|
||||
|
||||
void ThemeManager::updateCurrentTheme()
|
||||
Theme ThemeManager::themeFromId(const QString &id, ThemeVariant fallbackVariant)
|
||||
{
|
||||
currentTheme = makeTheme(themeId);
|
||||
// Try the repository first (handles both builtin and user themes via JSON)
|
||||
if (repository && repository->contains(id)) {
|
||||
QJsonObject json = repository->loadThemeJson(id);
|
||||
if (!json.isEmpty())
|
||||
return makeTheme(json);
|
||||
}
|
||||
|
||||
// Fallback to the builtin that matches the current dark/light intent.
|
||||
const QString fallbackId = (fallbackVariant == ThemeVariant::Dark)
|
||||
? QStringLiteral("builtin/dark")
|
||||
: QStringLiteral("builtin/light");
|
||||
if (repository && repository->contains(fallbackId)) {
|
||||
QJsonObject json = repository->loadThemeJson(fallbackId);
|
||||
if (!json.isEmpty())
|
||||
return makeTheme(json);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
void ThemeManager::resolveTheme()
|
||||
{
|
||||
if (!config)
|
||||
return;
|
||||
|
||||
const auto &sel = config->selection();
|
||||
|
||||
QString id;
|
||||
ThemeVariant fallbackVariant;
|
||||
switch (sel.mode) {
|
||||
case ThemeMode::FollowSystem: {
|
||||
const bool isDark = (qGuiApp->styleHints()->colorScheme() == Qt::ColorScheme::Dark);
|
||||
id = isDark ? sel.darkThemeId : sel.lightThemeId;
|
||||
fallbackVariant = isDark ? ThemeVariant::Dark : ThemeVariant::Light;
|
||||
break;
|
||||
}
|
||||
case ThemeMode::Light:
|
||||
id = sel.lightThemeId;
|
||||
fallbackVariant = ThemeVariant::Light;
|
||||
break;
|
||||
case ThemeMode::Dark:
|
||||
id = sel.darkThemeId;
|
||||
fallbackVariant = ThemeVariant::Dark;
|
||||
break;
|
||||
case ThemeMode::ForcedTheme:
|
||||
id = sel.fixedThemeId;
|
||||
fallbackVariant = (qGuiApp->styleHints()->colorScheme() == Qt::ColorScheme::Dark)
|
||||
? ThemeVariant::Dark
|
||||
: ThemeVariant::Light;
|
||||
break;
|
||||
}
|
||||
|
||||
const Theme theme = themeFromId(id, fallbackVariant);
|
||||
|
||||
// Sync Qt's application-level color scheme so native widgets (menus, scrollbars,
|
||||
// standard dialogs) use the correct palette before themeChanged() is emitted.
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
|
||||
Qt::ColorScheme scheme;
|
||||
switch (sel.mode) {
|
||||
case ThemeMode::FollowSystem:
|
||||
scheme = Qt::ColorScheme::Unknown; // delegate to OS
|
||||
break;
|
||||
case ThemeMode::Light:
|
||||
scheme = Qt::ColorScheme::Light;
|
||||
break;
|
||||
case ThemeMode::Dark:
|
||||
scheme = Qt::ColorScheme::Dark;
|
||||
break;
|
||||
case ThemeMode::ForcedTheme:
|
||||
scheme = (theme.meta.variant == ThemeVariant::Dark)
|
||||
? Qt::ColorScheme::Dark
|
||||
: Qt::ColorScheme::Light;
|
||||
break;
|
||||
}
|
||||
qGuiApp->styleHints()->setColorScheme(scheme);
|
||||
#endif
|
||||
|
||||
setTheme(theme);
|
||||
}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
#ifndef THEME_MANAGER_H
|
||||
#define THEME_MANAGER_H
|
||||
|
||||
#include "appearance_configuration.h"
|
||||
#include "theme.h"
|
||||
#include "theme_id.h"
|
||||
|
||||
#include <QtCore>
|
||||
|
||||
class ThemeRepository;
|
||||
|
||||
class ThemeManager : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
@ -17,21 +19,27 @@ public:
|
||||
ThemeManager(ThemeManager &&) = delete;
|
||||
ThemeManager &operator=(ThemeManager &&) = delete;
|
||||
|
||||
void initialize();
|
||||
|
||||
void setTheme(ThemeId themeId);
|
||||
void initialize(AppearanceConfiguration *config, ThemeRepository *repository);
|
||||
|
||||
void setTheme(const Theme &theme);
|
||||
const Theme &getCurrentTheme() const { return currentTheme; }
|
||||
|
||||
AppearanceConfiguration *getAppearanceConfiguration() const { return config; }
|
||||
|
||||
signals:
|
||||
void themeChanged();
|
||||
|
||||
private:
|
||||
explicit ThemeManager();
|
||||
ThemeId themeId = ThemeId::Classic;
|
||||
|
||||
AppearanceConfiguration *config = nullptr;
|
||||
ThemeRepository *repository = nullptr;
|
||||
Theme currentTheme;
|
||||
|
||||
void updateCurrentTheme();
|
||||
Theme themeFromId(const QString &id, ThemeVariant fallbackVariant);
|
||||
|
||||
private slots:
|
||||
void resolveTheme();
|
||||
};
|
||||
|
||||
#endif // THEME_MANAGER_H
|
||||
|
||||
14
common/themes/theme_meta.h
Normal file
14
common/themes/theme_meta.h
Normal file
@ -0,0 +1,14 @@
|
||||
#ifndef THEME_META_H
|
||||
#define THEME_META_H
|
||||
|
||||
#include "theme_variant.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
struct ThemeMeta {
|
||||
QString id;
|
||||
QString displayName;
|
||||
ThemeVariant variant;
|
||||
};
|
||||
|
||||
#endif // THEME_META_H
|
||||
206
common/themes/theme_repository.cpp
Normal file
206
common/themes/theme_repository.cpp
Normal file
@ -0,0 +1,206 @@
|
||||
#include "theme_repository.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QUuid>
|
||||
|
||||
ThemeRepository::ThemeRepository(const QString &qrcPrefix, const QString &userThemesDir)
|
||||
: qrcPrefix(qrcPrefix), userThemesDir(userThemesDir)
|
||||
{
|
||||
scanBuiltins();
|
||||
scanUserThemes();
|
||||
}
|
||||
|
||||
QList<ThemeListEntry> ThemeRepository::availableThemes() const
|
||||
{
|
||||
QList<ThemeListEntry> result;
|
||||
result.reserve(builtins.size() + userThemes.size());
|
||||
|
||||
for (const auto &b : builtins)
|
||||
result.append({ b.meta.id, b.meta.displayName, b.meta.variant, true });
|
||||
|
||||
for (const auto &u : userThemes)
|
||||
result.append({ u.meta.id, u.meta.displayName, u.meta.variant, false });
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool ThemeRepository::contains(const QString &themeId) const
|
||||
{
|
||||
for (const auto &b : builtins)
|
||||
if (b.id == themeId)
|
||||
return true;
|
||||
|
||||
for (const auto &u : userThemes)
|
||||
if (u.id == themeId)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject ThemeRepository::loadThemeJson(const QString &themeId) const
|
||||
{
|
||||
for (const auto &b : builtins)
|
||||
if (b.id == themeId)
|
||||
return readJsonFile(b.resourcePath);
|
||||
|
||||
for (const auto &u : userThemes)
|
||||
if (u.id == themeId)
|
||||
return readJsonFile(u.filePath);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
QString ThemeRepository::saveUserTheme(QJsonObject themeJson)
|
||||
{
|
||||
QDir().mkpath(userThemesDir);
|
||||
|
||||
auto metaObj = themeJson["meta"].toObject();
|
||||
QString id = metaObj["id"].toString();
|
||||
|
||||
if (id.isEmpty() || id.startsWith("builtin/")) {
|
||||
const QString uuid = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
id = "user/" + uuid;
|
||||
metaObj["id"] = id;
|
||||
themeJson["meta"] = metaObj;
|
||||
}
|
||||
|
||||
// Extract uuid from "user/<uuid>"
|
||||
const QString uuid = id.mid(5); // skip "user/"
|
||||
const QString filePath = filePathForUserTheme(uuid);
|
||||
|
||||
QFile file(filePath);
|
||||
if (file.open(QIODevice::WriteOnly)) {
|
||||
file.write(QJsonDocument(themeJson).toJson(QJsonDocument::Indented));
|
||||
file.close();
|
||||
}
|
||||
|
||||
// Update cache
|
||||
refresh();
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
bool ThemeRepository::deleteUserTheme(const QString &themeId)
|
||||
{
|
||||
if (themeId.startsWith("builtin/"))
|
||||
return false;
|
||||
|
||||
for (const auto &u : userThemes) {
|
||||
if (u.id == themeId) {
|
||||
const bool removed = QFile::remove(u.filePath);
|
||||
if (removed)
|
||||
refresh();
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
QString ThemeRepository::importThemeFromFile(const QString &filePath)
|
||||
{
|
||||
QJsonObject json = readJsonFile(filePath);
|
||||
if (json.isEmpty())
|
||||
return {};
|
||||
|
||||
// Force a new user id regardless of what the file contains
|
||||
auto metaObj = json["meta"].toObject();
|
||||
const QString uuid = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
const QString id = "user/" + uuid;
|
||||
metaObj["id"] = id;
|
||||
json["meta"] = metaObj;
|
||||
|
||||
return saveUserTheme(json);
|
||||
}
|
||||
|
||||
void ThemeRepository::refresh()
|
||||
{
|
||||
scanUserThemes();
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
void ThemeRepository::scanBuiltins()
|
||||
{
|
||||
builtins.clear();
|
||||
|
||||
static const QStringList builtinNames = { "classic", "light", "dark" };
|
||||
|
||||
for (const auto &name : builtinNames) {
|
||||
const QString resourcePath = qrcPrefix + "/builtin_" + name + ".json";
|
||||
const QJsonObject json = readJsonFile(resourcePath);
|
||||
if (json.isEmpty())
|
||||
continue;
|
||||
|
||||
BuiltinEntry entry;
|
||||
entry.id = "builtin/" + name;
|
||||
entry.resourcePath = resourcePath;
|
||||
entry.meta = extractMeta(json);
|
||||
// Ensure the id matches the canonical form
|
||||
entry.meta.id = entry.id;
|
||||
builtins.append(entry);
|
||||
}
|
||||
}
|
||||
|
||||
void ThemeRepository::scanUserThemes()
|
||||
{
|
||||
userThemes.clear();
|
||||
|
||||
QDir dir(userThemesDir);
|
||||
if (!dir.exists())
|
||||
return;
|
||||
|
||||
const auto entries = dir.entryList({ "*.json" }, QDir::Files);
|
||||
for (const auto &fileName : entries) {
|
||||
const QString filePath = dir.absoluteFilePath(fileName);
|
||||
const QJsonObject json = readJsonFile(filePath);
|
||||
if (json.isEmpty())
|
||||
continue;
|
||||
|
||||
ThemeMeta meta = extractMeta(json);
|
||||
if (meta.id.isEmpty()) {
|
||||
// Derive id from filename (strip .json extension)
|
||||
const QString baseName = fileName.chopped(5); // remove ".json"
|
||||
meta.id = "user/" + baseName;
|
||||
}
|
||||
|
||||
UserEntry entry;
|
||||
entry.id = meta.id;
|
||||
entry.filePath = filePath;
|
||||
entry.meta = meta;
|
||||
userThemes.append(entry);
|
||||
}
|
||||
}
|
||||
|
||||
ThemeMeta ThemeRepository::extractMeta(const QJsonObject &json)
|
||||
{
|
||||
const auto meta = json["meta"].toObject();
|
||||
return ThemeMeta {
|
||||
meta["id"].toString(),
|
||||
meta["displayName"].toString(),
|
||||
(meta["variant"].toString() == "light") ? ThemeVariant::Light : ThemeVariant::Dark
|
||||
};
|
||||
}
|
||||
|
||||
QJsonObject ThemeRepository::readJsonFile(const QString &path)
|
||||
{
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return {};
|
||||
|
||||
const QByteArray data = file.readAll();
|
||||
QJsonParseError error;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(data, &error);
|
||||
if (error.error != QJsonParseError::NoError)
|
||||
return {};
|
||||
|
||||
return doc.object();
|
||||
}
|
||||
|
||||
QString ThemeRepository::filePathForUserTheme(const QString &uuid) const
|
||||
{
|
||||
return userThemesDir + "/" + uuid + ".json";
|
||||
}
|
||||
57
common/themes/theme_repository.h
Normal file
57
common/themes/theme_repository.h
Normal file
@ -0,0 +1,57 @@
|
||||
#ifndef THEME_REPOSITORY_H
|
||||
#define THEME_REPOSITORY_H
|
||||
|
||||
#include "theme_meta.h"
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
struct ThemeListEntry {
|
||||
QString id;
|
||||
QString displayName;
|
||||
ThemeVariant variant;
|
||||
bool isBuiltin;
|
||||
};
|
||||
|
||||
class ThemeRepository
|
||||
{
|
||||
public:
|
||||
explicit ThemeRepository(const QString &qrcPrefix, const QString &userThemesDir);
|
||||
|
||||
QList<ThemeListEntry> availableThemes() const;
|
||||
bool contains(const QString &themeId) const;
|
||||
QJsonObject loadThemeJson(const QString &themeId) const;
|
||||
|
||||
QString saveUserTheme(QJsonObject themeJson);
|
||||
bool deleteUserTheme(const QString &themeId);
|
||||
QString importThemeFromFile(const QString &filePath);
|
||||
|
||||
void refresh();
|
||||
|
||||
private:
|
||||
QString qrcPrefix;
|
||||
QString userThemesDir;
|
||||
|
||||
struct BuiltinEntry {
|
||||
QString id;
|
||||
QString resourcePath;
|
||||
ThemeMeta meta;
|
||||
};
|
||||
QList<BuiltinEntry> builtins;
|
||||
|
||||
struct UserEntry {
|
||||
QString id;
|
||||
QString filePath;
|
||||
ThemeMeta meta;
|
||||
};
|
||||
QList<UserEntry> userThemes;
|
||||
|
||||
void scanBuiltins();
|
||||
void scanUserThemes();
|
||||
static ThemeMeta extractMeta(const QJsonObject &json);
|
||||
static QJsonObject readJsonFile(const QString &path);
|
||||
QString filePathForUserTheme(const QString &uuid) const;
|
||||
};
|
||||
|
||||
#endif // THEME_REPOSITORY_H
|
||||
9
common/themes/theme_variant.h
Normal file
9
common/themes/theme_variant.h
Normal file
@ -0,0 +1,9 @@
|
||||
#ifndef THEME_VARIANT_H
|
||||
#define THEME_VARIANT_H
|
||||
|
||||
enum class ThemeVariant {
|
||||
Light,
|
||||
Dark,
|
||||
};
|
||||
|
||||
#endif // THEME_VARIANT_H
|
||||
Reference in New Issue
Block a user