Use json file based themes instead of code to create them (theme editor + theme mode settings)

This commit is contained in:
luisangelsm
2026-03-02 21:23:39 +01:00
parent 87fada611d
commit 547e48cc04
42 changed files with 2776 additions and 1145 deletions

View File

@ -56,9 +56,13 @@ target_compile_definitions(YACReader PRIVATE YACREADER)
# Resources
qt_add_resources(yacreader_images_rcc "${CMAKE_CURRENT_SOURCE_DIR}/yacreader_images.qrc")
qt_add_resources(yacreader_files_rcc "${CMAKE_CURRENT_SOURCE_DIR}/yacreader_files.qrc")
qt_add_resources(yacreader_themes_rcc "${CMAKE_CURRENT_SOURCE_DIR}/themes/themes.qrc")
qt_add_resources(yacreader_common_images_rcc "${CMAKE_SOURCE_DIR}/common/themes/appearance_config_images.qrc")
target_sources(YACReader PRIVATE
${yacreader_images_rcc}
${yacreader_files_rcc}
${yacreader_themes_rcc}
${yacreader_common_images_rcc}
)
# Translations

View File

@ -7,7 +7,10 @@
#include "main_window_viewer.h"
#include "configuration.h"
#include "exit_check.h"
#include "appearance_configuration.h"
#include "theme_manager.h"
#include "theme_repository.h"
#include "yacreader_global.h"
#include "QsLog.h"
#include "QsLogDest.h"
@ -114,7 +117,11 @@ int main(int argc, char *argv[])
app.setApplicationName("YACReader");
app.setOrganizationName("YACReader");
ThemeManager::instance().initialize();
auto *appearanceConfig = new AppearanceConfiguration(
YACReader::getSettingsPath() + "/YACReader.ini", qApp);
auto *themeRepo = new ThemeRepository(
":/themes", YACReader::getSettingsPath() + "/themes/user");
ThemeManager::instance().initialize(appearanceConfig, themeRepo);
if (QIcon::hasThemeIcon("YACReader")) {
app.setWindowIcon(QIcon::fromTheme("YACReader"));

View File

@ -12,7 +12,10 @@
#include <QLabel>
#include <QColorDialog>
#include <QCheckBox>
#include <QMessageBox>
#include "theme_manager.h"
#include "theme_factory.h"
#include "appearance_tab_widget.h"
#include "yacreader_spin_slider_widget.h"
#include "yacreader_3d_flow_config_widget.h"
@ -203,9 +206,20 @@ OptionsDialog::OptionsDialog(QWidget *parent)
pageFlow->setLayout(layoutFlow);
pageImage->setLayout(layoutImageV);
// APPEARANCE ----------------------------------------
auto *pageAppearance = new AppearanceTabWidget(
ThemeManager::instance().getAppearanceConfiguration(),
[]() { return ThemeManager::instance().getCurrentTheme().sourceJson; },
[](const QJsonObject &json) { ThemeManager::instance().setTheme(makeTheme(json)); },
this);
// APPEARANCE END ------------------------------------
tabWidget->addTab(pageGeneral, tr("General"));
tabWidget->addTab(pageFlow, tr("Page Flow"));
tabWidget->addTab(pageImage, tr("Image adjustment"));
tabWidget->addTab(pageAppearance, tr("Appearance"));
layout->addWidget(tabWidget);

View File

@ -4,6 +4,8 @@
#include "yacreader_options_dialog.h"
#include "themable.h"
#include <QPointer>
class QDialog;
class QLabel;
class QLineEdit;

View File

@ -1000,7 +1000,7 @@ void Render::fillBuffer()
pageRenders[currentPageBufferedIndex + i]->start();
}
if ((currentIndex - i > 0) &&
if ((currentIndex - i >= 0) &&
buffer[currentPageBufferedIndex - i]->isNull() &&
i <= numLeftPages &&
pageRenders[currentPageBufferedIndex - i] == 0 &&

View File

@ -0,0 +1,51 @@
{
"meta": {
"id": "builtin/classic",
"displayName": "Default Classic",
"variant": "dark"
},
"toolbar": {
"iconColor": "#404040",
"iconDisabledColor": "#858585",
"iconCheckedColor": "#5a5a5a",
"backgroundColor": "#f3f3f3",
"separatorColor": "#cccccc",
"checkedButtonColor": "#cccccc",
"menuIndicatorColor": "#404040"
},
"viewer": {
"defaultBackgroundColor": "#282828",
"defaultTextColor": "#ffffff",
"infoBackgroundColor": "#bb000000",
"infoTextColor": "#ffffff"
},
"goToFlowWidget": {
"flowBackgroundColor": "#282828",
"flowTextColor": "#ffffff",
"toolbarBackgroundColor": "#99000000",
"sliderBorderColor": "#22ffffff",
"sliderGrooveColor": "#77000000",
"sliderHandleColor": "#55ffffff",
"editBorderColor": "#77000000",
"editBackgroundColor": "#55000000",
"editTextColor": "#ffffff",
"labelTextColor": "#ffffff",
"iconColor": "#ffffff"
},
"helpAboutDialog": {
"headingColor": "#302f2d",
"linkColor": "#c19441"
},
"whatsNewDialog": {
"backgroundColor": "#ffffff",
"headerTextColor": "#0a0a0a",
"versionTextColor": "#858585",
"contentTextColor": "#0a0a0a",
"linkColor": "#e8b800",
"closeButtonColor": "#444444",
"headerDecorationColor": "#e8b800"
},
"shortcutsIcons": {
"iconColor": "#404040"
}
}

View File

@ -0,0 +1,51 @@
{
"meta": {
"id": "builtin/dark",
"displayName": "Default Dark",
"variant": "dark"
},
"toolbar": {
"iconColor": "#cccccc",
"iconDisabledColor": "#444444",
"iconCheckedColor": "#dadada",
"backgroundColor": "#202020",
"separatorColor": "#444444",
"checkedButtonColor": "#3a3a3a",
"menuIndicatorColor": "#cccccc"
},
"viewer": {
"defaultBackgroundColor": "#282828",
"defaultTextColor": "#ffffff",
"infoBackgroundColor": "#bb000000",
"infoTextColor": "#b0b0b0"
},
"goToFlowWidget": {
"flowBackgroundColor": "#282828",
"flowTextColor": "#ffffff",
"toolbarBackgroundColor": "#99000000",
"sliderBorderColor": "#22ffffff",
"sliderGrooveColor": "#77000000",
"sliderHandleColor": "#55ffffff",
"editBorderColor": "#77000000",
"editBackgroundColor": "#55000000",
"editTextColor": "#ffffff",
"labelTextColor": "#ffffff",
"iconColor": "#cccccc"
},
"helpAboutDialog": {
"headingColor": "#e0e0e0",
"linkColor": "#d4a84b"
},
"whatsNewDialog": {
"backgroundColor": "#2a2a2a",
"headerTextColor": "#e0e0e0",
"versionTextColor": "#858585",
"contentTextColor": "#e0e0e0",
"linkColor": "#e8b800",
"closeButtonColor": "#dddddd",
"headerDecorationColor": "#e8b800"
},
"shortcutsIcons": {
"iconColor": "#d0d0d0"
}
}

View File

@ -0,0 +1,51 @@
{
"meta": {
"id": "builtin/light",
"displayName": "Default Light",
"variant": "light"
},
"toolbar": {
"iconColor": "#404040",
"iconDisabledColor": "#b0b0b0",
"iconCheckedColor": "#5a5a5a",
"backgroundColor": "#f3f3f3",
"separatorColor": "#cccccc",
"checkedButtonColor": "#cccccc",
"menuIndicatorColor": "#404040"
},
"viewer": {
"defaultBackgroundColor": "#f6f6f6",
"defaultTextColor": "#202020",
"infoBackgroundColor": "#bbffffff",
"infoTextColor": "#404040"
},
"goToFlowWidget": {
"flowBackgroundColor": "#f6f6f6",
"flowTextColor": "#202020",
"toolbarBackgroundColor": "#bbffffff",
"sliderBorderColor": "#22000000",
"sliderGrooveColor": "#33000000",
"sliderHandleColor": "#55000000",
"editBorderColor": "#33000000",
"editBackgroundColor": "#22000000",
"editTextColor": "#202020",
"labelTextColor": "#202020",
"iconColor": "#404040"
},
"helpAboutDialog": {
"headingColor": "#302f2d",
"linkColor": "#c19441"
},
"whatsNewDialog": {
"backgroundColor": "#ffffff",
"headerTextColor": "#0a0a0a",
"versionTextColor": "#858585",
"contentTextColor": "#0a0a0a",
"linkColor": "#e8b800",
"closeButtonColor": "#444444",
"headerDecorationColor": "#e8b800"
},
"shortcutsIcons": {
"iconColor": "#606060"
}
}

View File

@ -2,9 +2,11 @@
#define THEME_H
#include <QtGui>
#include <QJsonObject>
#include "help_about_dialog_theme.h"
#include "whats_new_dialog_theme.h"
#include "theme_meta.h"
struct ToolbarThemeTemplates {
QString toolbarQSS = "QToolBar { border: none; background: %1; }\n"
@ -142,6 +144,9 @@ struct DialogIconsTheme {
};
struct Theme {
ThemeMeta meta;
QJsonObject sourceJson;
ToolbarTheme toolbar;
ViewerTheme viewer;
GoToFlowWidgetTheme goToFlowWidget;

View File

@ -3,6 +3,7 @@
#include <QApplication>
#include "icon_utils.h"
#include "theme_meta.h"
struct ToolbarParams {
ToolbarThemeTemplates t;
@ -56,7 +57,7 @@ struct ShortcutsIconsParams {
};
struct ThemeParams {
QString themeName;
ThemeMeta meta;
ToolbarParams toolbarParams;
ViewerParams viewerParams;
@ -72,7 +73,7 @@ void setToolbarIconPair(QIcon &icon,
const QColor &iconColor,
const QColor &disabledColor,
const QColor &checkedColor,
const QString &themeName)
const QString &themeId)
{
QString path18 = basePath;
if (path18.endsWith(".svg"))
@ -81,14 +82,14 @@ void setToolbarIconPair(QIcon &icon,
path18.append("_18x18");
// Normal
const QString normalPath = recoloredSvgToThemeFile(basePath, iconColor, themeName);
const QString normalPath18 = recoloredSvgToThemeFile(path18, iconColor, themeName);
const QString normalPath = recoloredSvgToThemeFile(basePath, iconColor, themeId);
const QString normalPath18 = recoloredSvgToThemeFile(path18, iconColor, themeId);
// Disabled
const QString disabledPath = recoloredSvgToThemeFile(basePath, disabledColor, themeName, { .suffix = "_disabled" });
const QString disabledPath18 = recoloredSvgToThemeFile(path18, disabledColor, themeName, { .suffix = "_disabled" });
const QString disabledPath = recoloredSvgToThemeFile(basePath, disabledColor, themeId, { .suffix = "_disabled" });
const QString disabledPath18 = recoloredSvgToThemeFile(path18, disabledColor, themeId, { .suffix = "_disabled" });
// Checked (On state)
const QString checkedPath = recoloredSvgToThemeFile(basePath, checkedColor, themeName, { .suffix = "_checked" });
const QString checkedPath18 = recoloredSvgToThemeFile(path18, checkedColor, themeName, { .suffix = "_checked" });
const QString checkedPath = recoloredSvgToThemeFile(basePath, checkedColor, themeId, { .suffix = "_checked" });
const QString checkedPath18 = recoloredSvgToThemeFile(path18, checkedColor, themeId, { .suffix = "_checked" });
icon.addFile(normalPath, QSize(), QIcon::Normal, QIcon::Off);
icon.addFile(disabledPath, QSize(), QIcon::Disabled, QIcon::Off);
@ -105,11 +106,13 @@ Theme makeTheme(const ThemeParams &params)
{
Theme theme;
theme.meta = params.meta;
// Toolbar & actions
theme.toolbar.toolbarQSS = params.toolbarParams.t.toolbarQSS.arg(params.toolbarParams.backgroundColor.name(), params.toolbarParams.separatorColor.name(), params.toolbarParams.checkedButtonColor.name(), recoloredSvgToThemeFile(params.toolbarParams.t.menuArrowPath, params.toolbarParams.menuIndicatorColor, params.themeName));
theme.toolbar.toolbarQSS = params.toolbarParams.t.toolbarQSS.arg(params.toolbarParams.backgroundColor.name(), params.toolbarParams.separatorColor.name(), params.toolbarParams.checkedButtonColor.name(), recoloredSvgToThemeFile(params.toolbarParams.t.menuArrowPath, params.toolbarParams.menuIndicatorColor, params.meta.id));
auto setToolbarIconPairT = [&](QIcon &icon, QIcon &icon18, const QString &basePath) {
setToolbarIconPair(icon, icon18, basePath, params.toolbarParams.iconColor, params.toolbarParams.iconDisabledColor, params.toolbarParams.iconCheckedColor, params.themeName);
setToolbarIconPair(icon, icon18, basePath, params.toolbarParams.iconColor, params.toolbarParams.iconDisabledColor, params.toolbarParams.iconCheckedColor, params.meta.id);
};
setToolbarIconPairT(theme.toolbar.openAction, theme.toolbar.openAction18x18, ":/images/viewer_toolbar/open.svg");
@ -166,8 +169,8 @@ Theme makeTheme(const ThemeParams &params)
theme.goToFlowWidget.buttonQSS = gotoParams.t.buttonQSS;
theme.goToFlowWidget.labelQSS = gotoParams.t.labelQSS.arg(gotoParams.labelTextColor.name());
const QString centerIconPath = recoloredSvgToThemeFile(":/images/centerFlow.svg", gotoParams.iconColor, params.themeName);
const QString goToIconPath = recoloredSvgToThemeFile(":/images/gotoFlow.svg", gotoParams.iconColor, params.themeName);
const QString centerIconPath = recoloredSvgToThemeFile(":/images/centerFlow.svg", gotoParams.iconColor, params.meta.id);
const QString goToIconPath = recoloredSvgToThemeFile(":/images/gotoFlow.svg", gotoParams.iconColor, params.meta.id);
theme.goToFlowWidget.centerIcon = QIcon(centerIconPath);
theme.goToFlowWidget.goToIcon = QIcon(goToIconPath);
// end GoToFlowWidget
@ -183,14 +186,14 @@ Theme makeTheme(const ThemeParams &params)
theme.whatsNewDialog.versionTextColor = wn.versionTextColor;
theme.whatsNewDialog.contentTextColor = wn.contentTextColor;
theme.whatsNewDialog.linkColor = wn.linkColor;
theme.whatsNewDialog.closeButtonIcon = QPixmap(recoloredSvgToThemeFile(":/images/custom_dialog/custom_close_button.svg", wn.closeButtonColor, params.themeName));
theme.whatsNewDialog.headerDecoration = QPixmap(recoloredSvgToThemeFile(":/images/whats_new/whatsnew_header.svg", wn.headerDecorationColor, params.themeName));
theme.whatsNewDialog.closeButtonIcon = QPixmap(recoloredSvgToThemeFile(":/images/custom_dialog/custom_close_button.svg", wn.closeButtonColor, params.meta.id));
theme.whatsNewDialog.headerDecoration = QPixmap(recoloredSvgToThemeFile(":/images/whats_new/whatsnew_header.svg", wn.headerDecorationColor, params.meta.id));
// end WhatsNewDialog
// ShortcutsIcons
const auto &sci = params.shortcutsIconsParams;
auto makeShortcutsIcon = [&](const QString &basePath) {
const QString path = recoloredSvgToThemeFile(basePath, sci.iconColor, params.themeName);
const QString path = recoloredSvgToThemeFile(basePath, sci.iconColor, params.meta.id);
return QIcon(path);
};
@ -203,7 +206,7 @@ Theme makeTheme(const ThemeParams &params)
// FindFolder icon (used in OptionsDialog)
{
const QString path = recoloredSvgToThemeFile(":/images/find_folder.svg", params.toolbarParams.iconColor, params.themeName);
const QString path = recoloredSvgToThemeFile(":/images/find_folder.svg", params.toolbarParams.iconColor, params.meta.id);
const qreal dpr = qApp->devicePixelRatio();
theme.dialogIcons.findFolderIcon = QIcon(renderSvgToPixmap(path, 13, 13, dpr));
}
@ -211,204 +214,92 @@ Theme makeTheme(const ThemeParams &params)
return theme;
}
ThemeParams classicThemeParams();
ThemeParams lightThemeParams();
ThemeParams darkThemeParams();
// JSON helpers ---------------------------------------------------------------
Theme makeTheme(ThemeId themeId)
static QColor colorFromJson(const QJsonObject &obj, const QString &key, const QColor &fallback)
{
switch (themeId) {
case ThemeId::Classic:
return makeTheme(classicThemeParams());
case ThemeId::Light:
return makeTheme(lightThemeParams());
case ThemeId::Dark:
return makeTheme(darkThemeParams());
if (!obj.contains(key))
return fallback;
QColor c(obj[key].toString());
return c.isValid() ? c : fallback;
}
Theme makeTheme(const QJsonObject &json)
{
ThemeParams p;
if (json.contains("toolbar")) {
const auto t = json["toolbar"].toObject();
auto &tp = p.toolbarParams;
tp.iconColor = colorFromJson(t, "iconColor", tp.iconColor);
tp.iconDisabledColor = colorFromJson(t, "iconDisabledColor", tp.iconDisabledColor);
tp.iconCheckedColor = colorFromJson(t, "iconCheckedColor", tp.iconCheckedColor);
tp.backgroundColor = colorFromJson(t, "backgroundColor", tp.backgroundColor);
tp.separatorColor = colorFromJson(t, "separatorColor", tp.separatorColor);
tp.checkedButtonColor = colorFromJson(t, "checkedButtonColor", tp.checkedButtonColor);
tp.menuIndicatorColor = colorFromJson(t, "menuIndicatorColor", tp.menuIndicatorColor);
}
}
ThemeParams classicThemeParams()
{
ThemeParams params;
params.themeName = "classic";
ToolbarParams toolbarParams;
toolbarParams.iconColor = QColor(0x404040);
toolbarParams.iconDisabledColor = QColor(0x858585);
toolbarParams.iconCheckedColor = QColor(0x5A5A5A);
toolbarParams.backgroundColor = QColor(0xF3F3F3);
toolbarParams.separatorColor = QColor(0xCCCCCC);
toolbarParams.checkedButtonColor = QColor(0xCCCCCC);
toolbarParams.menuIndicatorColor = QColor(0x404040);
params.toolbarParams = toolbarParams;
ViewerParams viewerParams;
viewerParams.defaultBackgroundColor = QColor(0x282828);
viewerParams.defaultTextColor = Qt::white;
viewerParams.infoBackgroundColor = QColor::fromRgba(0xBB000000);
viewerParams.infoTextColor = Qt::white;
viewerParams.t = ViewerThemeTemplates();
params.viewerParams = viewerParams;
GoToFlowWidgetParams goToFlowWidgetParams;
goToFlowWidgetParams.flowBackgroundColor = QColor(0x282828);
goToFlowWidgetParams.flowTextColor = Qt::white;
goToFlowWidgetParams.toolbarBackgroundColor = QColor::fromRgba(0x99000000);
goToFlowWidgetParams.sliderBorderColor = QColor::fromRgba(0x22FFFFFF);
goToFlowWidgetParams.sliderGrooveColor = QColor::fromRgba(0x77000000);
goToFlowWidgetParams.sliderHandleColor = QColor::fromRgba(0x55FFFFFF);
goToFlowWidgetParams.editBorderColor = QColor::fromRgba(0x77000000);
goToFlowWidgetParams.editBackgroundColor = QColor::fromRgba(0x55000000);
goToFlowWidgetParams.editTextColor = Qt::white;
goToFlowWidgetParams.labelTextColor = Qt::white;
goToFlowWidgetParams.iconColor = Qt::white;
goToFlowWidgetParams.t = GoToFlowWidgetThemeTemplates();
params.goToFlowWidgetParams = goToFlowWidgetParams;
params.helpAboutDialogParams.headingColor = QColor(0x302f2d);
params.helpAboutDialogParams.linkColor = QColor(0xC19441);
params.whatsNewDialogParams.backgroundColor = QColor(0xFFFFFF);
params.whatsNewDialogParams.headerTextColor = QColor(0x0A0A0A);
params.whatsNewDialogParams.versionTextColor = QColor(0x858585);
params.whatsNewDialogParams.contentTextColor = QColor(0x0A0A0A);
params.whatsNewDialogParams.linkColor = QColor(0xE8B800);
params.whatsNewDialogParams.closeButtonColor = QColor(0x444444);
params.whatsNewDialogParams.headerDecorationColor = QColor(0xE8B800);
// ShortcutsIcons
ShortcutsIconsParams sci;
sci.iconColor = QColor(0x404040); // Dark icons for light background
params.shortcutsIconsParams = sci;
return params;
}
ThemeParams lightThemeParams()
{
ThemeParams params;
params.themeName = "light";
ToolbarParams toolbarParams;
toolbarParams.iconColor = QColor(0x404040);
toolbarParams.iconDisabledColor = QColor(0xB0B0B0);
toolbarParams.iconCheckedColor = QColor(0x5A5A5A);
toolbarParams.backgroundColor = QColor(0xF3F3F3);
toolbarParams.separatorColor = QColor(0xCCCCCC);
toolbarParams.checkedButtonColor = QColor(0xCCCCCC);
toolbarParams.menuIndicatorColor = QColor(0x404040);
params.toolbarParams = toolbarParams;
ViewerParams viewerParams;
viewerParams.defaultBackgroundColor = QColor(0xF6F6F6);
viewerParams.defaultTextColor = QColor(0x202020);
viewerParams.infoBackgroundColor = QColor::fromRgba(0xBBFFFFFF);
viewerParams.infoTextColor = QColor(0x404040);
viewerParams.t = ViewerThemeTemplates();
params.viewerParams = viewerParams;
GoToFlowWidgetParams goToFlowWidgetParams;
goToFlowWidgetParams.flowBackgroundColor = QColor(0xF6F6F6);
goToFlowWidgetParams.flowTextColor = QColor(0x202020);
goToFlowWidgetParams.toolbarBackgroundColor = QColor::fromRgba(0xBBFFFFFF);
goToFlowWidgetParams.sliderBorderColor = QColor::fromRgba(0x22000000);
goToFlowWidgetParams.sliderGrooveColor = QColor::fromRgba(0x33000000);
goToFlowWidgetParams.sliderHandleColor = QColor::fromRgba(0x55000000);
goToFlowWidgetParams.editBorderColor = QColor::fromRgba(0x33000000);
goToFlowWidgetParams.editBackgroundColor = QColor::fromRgba(0x22000000);
goToFlowWidgetParams.editTextColor = QColor(0x202020);
goToFlowWidgetParams.labelTextColor = QColor(0x202020);
goToFlowWidgetParams.iconColor = QColor(0x404040);
goToFlowWidgetParams.t = GoToFlowWidgetThemeTemplates();
params.goToFlowWidgetParams = goToFlowWidgetParams;
params.helpAboutDialogParams.headingColor = QColor(0x302f2d);
params.helpAboutDialogParams.linkColor = QColor(0xC19441);
params.whatsNewDialogParams.backgroundColor = QColor(0xFFFFFF);
params.whatsNewDialogParams.headerTextColor = QColor(0x0A0A0A);
params.whatsNewDialogParams.versionTextColor = QColor(0x858585);
params.whatsNewDialogParams.contentTextColor = QColor(0x0A0A0A);
params.whatsNewDialogParams.linkColor = QColor(0xE8B800);
params.whatsNewDialogParams.closeButtonColor = QColor(0x444444);
params.whatsNewDialogParams.headerDecorationColor = QColor(0xE8B800);
// ShortcutsIcons
ShortcutsIconsParams sci;
sci.iconColor = QColor(0x606060); // Dark icons for light background
params.shortcutsIconsParams = sci;
return params;
}
ThemeParams darkThemeParams()
{
ThemeParams params;
params.themeName = "dark";
ToolbarParams toolbarParams;
toolbarParams.iconColor = QColor(0xCCCCCC);
toolbarParams.iconDisabledColor = QColor(0x444444);
toolbarParams.iconCheckedColor = QColor(0xDADADA);
toolbarParams.backgroundColor = QColor(0x202020);
toolbarParams.separatorColor = QColor(0x444444);
toolbarParams.checkedButtonColor = QColor(0x3A3A3A);
toolbarParams.menuIndicatorColor = QColor(0xCCCCCC);
params.toolbarParams = toolbarParams;
ViewerParams viewerParams;
viewerParams.defaultBackgroundColor = QColor(40, 40, 40);
viewerParams.defaultTextColor = Qt::white;
viewerParams.infoBackgroundColor = QColor::fromRgba(0xBB000000);
viewerParams.infoTextColor = QColor(0xB0B0B0);
viewerParams.t = ViewerThemeTemplates();
params.viewerParams = viewerParams;
GoToFlowWidgetParams goToFlowWidgetParams;
goToFlowWidgetParams.flowBackgroundColor = QColor(40, 40, 40);
goToFlowWidgetParams.flowTextColor = Qt::white;
goToFlowWidgetParams.toolbarBackgroundColor = QColor::fromRgba(0x99000000);
goToFlowWidgetParams.sliderBorderColor = QColor::fromRgba(0x22FFFFFF);
goToFlowWidgetParams.sliderGrooveColor = QColor::fromRgba(0x77000000);
goToFlowWidgetParams.sliderHandleColor = QColor::fromRgba(0x55FFFFFF);
goToFlowWidgetParams.editBorderColor = QColor::fromRgba(0x77000000);
goToFlowWidgetParams.editBackgroundColor = QColor::fromRgba(0x55000000);
goToFlowWidgetParams.editTextColor = Qt::white;
goToFlowWidgetParams.labelTextColor = Qt::white;
goToFlowWidgetParams.iconColor = QColor(0xCCCCCC);
goToFlowWidgetParams.t = GoToFlowWidgetThemeTemplates();
params.goToFlowWidgetParams = goToFlowWidgetParams;
params.helpAboutDialogParams.headingColor = QColor(0xE0E0E0);
params.helpAboutDialogParams.linkColor = QColor(0xD4A84B);
params.whatsNewDialogParams.backgroundColor = QColor(0x2A2A2A);
params.whatsNewDialogParams.headerTextColor = QColor(0xE0E0E0);
params.whatsNewDialogParams.versionTextColor = QColor(0x858585);
params.whatsNewDialogParams.contentTextColor = QColor(0xE0E0E0);
params.whatsNewDialogParams.linkColor = QColor(0xE8B800);
params.whatsNewDialogParams.closeButtonColor = QColor(0xDDDDDD);
params.whatsNewDialogParams.headerDecorationColor = QColor(0xE8B800);
// ShortcutsIcons
ShortcutsIconsParams sci;
sci.iconColor = QColor(0xD0D0D0); // Light icons for dark background
params.shortcutsIconsParams = sci;
return params;
}
// TODO
ThemeParams paramsFromFile(const QString &filePath)
{
return {};
if (json.contains("viewer")) {
const auto v = json["viewer"].toObject();
auto &vp = p.viewerParams;
vp.defaultBackgroundColor = colorFromJson(v, "defaultBackgroundColor", vp.defaultBackgroundColor);
vp.defaultTextColor = colorFromJson(v, "defaultTextColor", vp.defaultTextColor);
vp.infoBackgroundColor = colorFromJson(v, "infoBackgroundColor", vp.infoBackgroundColor);
vp.infoTextColor = colorFromJson(v, "infoTextColor", vp.infoTextColor);
}
if (json.contains("goToFlowWidget")) {
const auto g = json["goToFlowWidget"].toObject();
auto &gp = p.goToFlowWidgetParams;
gp.flowBackgroundColor = colorFromJson(g, "flowBackgroundColor", gp.flowBackgroundColor);
gp.flowTextColor = colorFromJson(g, "flowTextColor", gp.flowTextColor);
gp.toolbarBackgroundColor = colorFromJson(g, "toolbarBackgroundColor", gp.toolbarBackgroundColor);
gp.sliderBorderColor = colorFromJson(g, "sliderBorderColor", gp.sliderBorderColor);
gp.sliderGrooveColor = colorFromJson(g, "sliderGrooveColor", gp.sliderGrooveColor);
gp.sliderHandleColor = colorFromJson(g, "sliderHandleColor", gp.sliderHandleColor);
gp.editBorderColor = colorFromJson(g, "editBorderColor", gp.editBorderColor);
gp.editBackgroundColor = colorFromJson(g, "editBackgroundColor", gp.editBackgroundColor);
gp.editTextColor = colorFromJson(g, "editTextColor", gp.editTextColor);
gp.labelTextColor = colorFromJson(g, "labelTextColor", gp.labelTextColor);
gp.iconColor = colorFromJson(g, "iconColor", gp.iconColor);
}
if (json.contains("helpAboutDialog")) {
const auto h = json["helpAboutDialog"].toObject();
p.helpAboutDialogParams.headingColor = colorFromJson(h, "headingColor", p.helpAboutDialogParams.headingColor);
p.helpAboutDialogParams.linkColor = colorFromJson(h, "linkColor", p.helpAboutDialogParams.linkColor);
}
if (json.contains("whatsNewDialog")) {
const auto w = json["whatsNewDialog"].toObject();
auto &wn = p.whatsNewDialogParams;
wn.backgroundColor = colorFromJson(w, "backgroundColor", wn.backgroundColor);
wn.headerTextColor = colorFromJson(w, "headerTextColor", wn.headerTextColor);
wn.versionTextColor = colorFromJson(w, "versionTextColor", wn.versionTextColor);
wn.contentTextColor = colorFromJson(w, "contentTextColor", wn.contentTextColor);
wn.linkColor = colorFromJson(w, "linkColor", wn.linkColor);
wn.closeButtonColor = colorFromJson(w, "closeButtonColor", wn.closeButtonColor);
wn.headerDecorationColor = colorFromJson(w, "headerDecorationColor",wn.headerDecorationColor);
}
if (json.contains("shortcutsIcons")) {
const auto s = json["shortcutsIcons"].toObject();
p.shortcutsIconsParams.iconColor = colorFromJson(s, "iconColor", p.shortcutsIconsParams.iconColor);
}
if (json.contains("meta")) {
const auto o = json["meta"].toObject();
p.meta.id = o["id"].toString(p.meta.id);
p.meta.displayName = o["displayName"].toString(p.meta.displayName);
const QString variantStr = o["variant"].toString();
if (variantStr == "light")
p.meta.variant = ThemeVariant::Light;
else if (variantStr == "dark")
p.meta.variant = ThemeVariant::Dark;
}
Theme theme = makeTheme(p);
theme.sourceJson = json;
return theme;
}

View File

@ -2,8 +2,9 @@
#define THEME_FACTORY_H
#include "theme.h"
#include "theme_id.h"
Theme makeTheme(ThemeId themeId);
#include <QJsonObject>
Theme makeTheme(const QJsonObject &json);
#endif // THEME_FACTORY_H

View File

@ -0,0 +1,7 @@
<RCC>
<qresource prefix="/themes">
<file alias="builtin_classic.json">builtin_classic.json</file>
<file alias="builtin_light.json">builtin_light.json</file>
<file alias="builtin_dark.json">builtin_dark.json</file>
</qresource>
</RCC>

View File

@ -203,10 +203,14 @@ target_compile_definitions(YACReaderLibrary PRIVATE
qt_add_resources(yacreaderlibrary_images_rcc "${CMAKE_CURRENT_SOURCE_DIR}/images.qrc")
qt_add_resources(yacreaderlibrary_files_rcc "${CMAKE_CURRENT_SOURCE_DIR}/files.qrc")
qt_add_resources(yacreaderlibrary_qml_rcc "${CMAKE_CURRENT_SOURCE_DIR}/qml.qrc")
qt_add_resources(yacreaderlibrary_themes_rcc "${CMAKE_CURRENT_SOURCE_DIR}/themes/themes.qrc")
qt_add_resources(yacreaderlibrary_common_images_rcc "${CMAKE_SOURCE_DIR}/common/themes/appearance_config_images.qrc")
target_sources(YACReaderLibrary PRIVATE
${yacreaderlibrary_images_rcc}
${yacreaderlibrary_files_rcc}
${yacreaderlibrary_qml_rcc}
${yacreaderlibrary_themes_rcc}
${yacreaderlibrary_common_images_rcc}
)
if(WIN32 OR (UNIX AND NOT APPLE))
qt_add_resources(yacreaderlibrary_images_win_rcc "${CMAKE_CURRENT_SOURCE_DIR}/images_win.qrc")

View File

@ -227,10 +227,18 @@ void InfoComicsView::applyTheme(const Theme &theme)
const auto &qv = theme.qmlView;
// Info panel colors
// Cache-bust the SVG file URLs so QML's image cache doesn't serve stale
// files when the theme is updated (the same file path is rewritten each time).
const QString bust = QString::number(QDateTime::currentMSecsSinceEpoch());
auto svgUrl = [&bust](const QString &path) {
QUrl url = QUrl::fromLocalFile(path);
url.setQuery(bust);
return url;
};
ctxt->setContextProperty("infoBackgroundColor", qv.infoBackgroundColor);
ctxt->setContextProperty("topShadow", QUrl::fromLocalFile(qv.topShadow));
ctxt->setContextProperty("infoShadow", QUrl::fromLocalFile(qv.infoShadow));
ctxt->setContextProperty("infoIndicator", QUrl::fromLocalFile(qv.infoIndicator));
ctxt->setContextProperty("topShadow", svgUrl(qv.topShadow));
ctxt->setContextProperty("infoShadow", svgUrl(qv.infoShadow));
ctxt->setContextProperty("infoIndicator", svgUrl(qv.infoIndicator));
ctxt->setContextProperty("infoTextColor", qv.infoTextColor);
ctxt->setContextProperty("infoTitleColor", qv.infoTitleColor);

View File

@ -21,7 +21,9 @@
#include "db_helper.h"
#include "yacreader_libraries.h"
#include "exit_check.h"
#include "appearance_configuration.h"
#include "theme_manager.h"
#include "theme_repository.h"
#ifdef Q_OS_MACOS
#include "trayhandler.h"
#endif
@ -140,7 +142,11 @@ int main(int argc, char **argv)
app.setApplicationVersion(VERSION);
// Theme initialization
ThemeManager::instance().initialize();
auto *appearanceConfig = new AppearanceConfiguration(
YACReader::getSettingsPath() + "/YACReaderLibrary.ini", qApp);
auto *themeRepo = new ThemeRepository(
":/themes", YACReader::getSettingsPath() + "/themes/user");
ThemeManager::instance().initialize(appearanceConfig, themeRepo);
// Set window icon according to Freedesktop icon specification
// This is mostly relevant for Linux and other Unix systems

View File

@ -4,6 +4,11 @@
#include "api_key_dialog.h"
#include "yacreader_global_gui.h"
#include "theme_manager.h"
#include "theme_factory.h"
#include "appearance_tab_widget.h"
#include <QMessageBox>
FlowType flowType = Strip;
@ -15,11 +20,14 @@ OptionsDialog::OptionsDialog(QWidget *parent)
auto comicFlowW = createFlowTab();
auto gridViewW = createGridTab();
auto appearanceW = createAppearanceTab();
auto tabWidget = new QTabWidget();
tabWidget->addTab(generalW, tr("General"));
tabWidget->addTab(librariesW, tr("Libraries"));
tabWidget->addTab(comicFlowW, tr("Comic Flow"));
tabWidget->addTab(gridViewW, tr("Grid view"));
tabWidget->addTab(appearanceW, tr("Appearance"));
auto buttons = new QHBoxLayout();
buttons->addStretch();
@ -406,3 +414,12 @@ QWidget *OptionsDialog::createGridTab()
return gridViewW;
}
QWidget *OptionsDialog::createAppearanceTab()
{
return new AppearanceTabWidget(
ThemeManager::instance().getAppearanceConfiguration(),
[]() { return ThemeManager::instance().getCurrentTheme().sourceJson; },
[](const QJsonObject &json) { ThemeManager::instance().setTheme(makeTheme(json)); },
this);
}

View File

@ -61,6 +61,7 @@ private:
QWidget *createLibrariesTab();
QWidget *createFlowTab();
QWidget *createGridTab();
QWidget *createAppearanceTab();
};
#endif

View File

@ -0,0 +1,238 @@
{
"meta": {
"id": "builtin/classic",
"displayName": "Default Classic",
"variant": "dark"
},
"defaultContentBackgroundColor": "#2a2a2a",
"comicFlow": {
"backgroundColor": "#000000",
"textColor": "#4c4c4c"
},
"comicVine": {
"contentTextColor": "#ffffff",
"contentBackgroundColor": "#2b2b2b",
"contentAltBackgroundColor": "#2b2b2b",
"dialogBackgroundColor": "#404040",
"tableBackgroundColor": "#2b2b2b",
"tableAltBackgroundColor": "#2e2e2e",
"tableBorderColor": "#242424",
"tableSelectedColor": "#555555",
"tableHeaderBackgroundColor": "#292929",
"tableHeaderGradientColor": "#292929",
"tableHeaderBorderColor": "#1f1f1f",
"tableHeaderTextColor": "#ebebeb",
"tableScrollHandleColor": "#dddddd",
"tableScrollBackgroundColor": "#404040",
"tableSectionBorderLight": "#fefefe",
"tableSectionBorderDark": "#dfdfdf",
"labelTextColor": "#ffffff",
"labelBackgroundColor": "#2b2b2b",
"hyperlinkColor": "#ffcc00",
"buttonBackgroundColor": "#2e2e2e",
"buttonTextColor": "#ffffff",
"buttonBorderColor": "#242424",
"radioUncheckedColor": "#e5e5e5",
"radioCheckedBackgroundColor": "#e5e5e5",
"radioCheckedIndicatorColor": "#5f5f5f",
"checkBoxTickColor": "#ffffff",
"toolButtonAccentColor": "#282828",
"downArrowColor": "#9f9f9f",
"upArrowColor": "#9f9f9f",
"busyIndicatorColor": "#ffffff",
"navIconColor": "#ffffff",
"rowIconColor": "#e5e5e5"
},
"helpAboutDialog": {
"headingColor": "#302f2d",
"linkColor": "#c19441"
},
"whatsNewDialog": {
"backgroundColor": "#2a2a2a",
"headerTextColor": "#e0e0e0",
"versionTextColor": "#858585",
"contentTextColor": "#e0e0e0",
"linkColor": "#e8b800",
"closeButtonColor": "#dddddd",
"headerDecorationColor": "#e8b800"
},
"emptyContainer": {
"backgroundColor": "#2a2a2a",
"titleTextColor": "#cccccc",
"textColor": "#cccccc",
"descriptionTextColor": "#aaaaaa",
"searchIconColor": "#4c4c4c"
},
"sidebar": {
"backgroundColor": "#454545",
"separatorColor": "#bdbfbf",
"sectionSeparatorColor": "#575757",
"uppercaseLabels": true,
"titleTextColor": "#bdbfbf",
"titleDropShadowColor": "#000000",
"busyIndicatorColor": "#ffffff"
},
"sidebarIcons": {
"iconColor": "#e0e0e0",
"shadowColor": "#000000",
"useSystemFolderIcons": false
},
"libraryItem": {
"textColor": "#dddfdf",
"libraryIconColor": "#dddfdf",
"libraryIconShadowColor": "#000000",
"selectedTextColor": "#ffffff",
"selectedBackgroundColor": "#2e2e2e",
"libraryIconSelectedColor": "#ffffff",
"libraryOptionsIconColor": "#ffffff"
},
"importWidget": {
"backgroundColor": "#2a2a2a",
"titleTextColor": "#cccccc",
"descriptionTextColor": "#aaaaaa",
"currentComicTextColor": "#aaaaaa",
"coversViewBackgroundColor": "#3a3a3a",
"coversLabelColor": "#aaaaaa",
"coversDecorationBgColor": "#3a3a3a",
"coversDecorationShadowColor": "#1a1a1a",
"modeIconColor": "#4a4a4a",
"iconColor": "#cccccc",
"iconCheckedColor": "#aaaaaa"
},
"serverConfigDialog": {
"backgroundColor": "#2a2a2a",
"titleTextColor": "#474747",
"qrMessageTextColor": "#a3a3a3",
"propagandaTextColor": "#4d4d4d",
"labelTextColor": "#575757",
"checkBoxTextColor": "#262626",
"qrBackgroundColor": "#2a2a2a",
"qrForegroundColor": "#ffffff",
"decorationColor": "#f7f7f7"
},
"mainToolbar": {
"backgroundColor": "#f0f0f0",
"folderNameColor": "#404040",
"dividerColor": "#b8bdc4",
"iconColor": "#404040",
"iconDisabledColor": "#b0b0b0"
},
"contentSplitter": {
"handleColor": "#b8b8b8",
"horizontalHandleHeight": 4,
"verticalHandleWidth": 4
},
"treeView": {
"textColor": "#dddfdf",
"selectionBackgroundColor": "#2e2e2e",
"scrollBackgroundColor": "#404040",
"scrollHandleColor": "#dddddd",
"selectedTextColor": "#ffffff",
"folderIndicatorColor": "#edc518",
"branchIndicatorColor": "#e0e0e0",
"branchIndicatorSelectedColor": "#ffffff",
"folderIconColor": "#e0e0e0",
"folderIconShadowColor": "#000000",
"folderIconSelectedColor": "#e0e0e0",
"folderIconSelectedShadowColor": "#000000",
"folderReadOverlayColor": "#464646",
"folderReadOverlaySelectedColor": "#464646"
},
"tableView": {
"alternateBackgroundColor": "#f2f2f2",
"backgroundColor": "#fafafa",
"headerBackgroundColor": "#f5f5f5",
"headerBorderColor": "#b8bdc4",
"headerGradientColor": "#d1d1d1",
"itemBorderBottomColor": "#dfdfdf",
"itemBorderTopColor": "#fefefe",
"itemBorderBottomWidth": 1,
"itemBorderTopWidth": 1,
"itemTextColor": "#252626",
"selectedColor": "#d4d4d4",
"selectedTextColor": "#252626",
"headerTextColor": "#313232",
"starRatingColor": "#e9be0f",
"starRatingSelectedColor": "#ffffff"
},
"qmlView": {
"backgroundColor": "#2a2a2a",
"cellColor": "#212121",
"cellColorWithBackground": "#99212121",
"selectedColor": "#121212",
"selectedBorderColor": "#ffcc00",
"borderColor": "#121212",
"titleColor": "#ffffff",
"textColor": "#a8a8a8",
"showDropShadow": true,
"infoBackgroundColor": "#2e2e2e",
"infoBorderColor": "#404040",
"infoShadowColor": "#000000",
"infoTextColor": "#b0b0b0",
"infoTitleColor": "#ffffff",
"ratingUnselectedColor": "#1c1c1c",
"ratingSelectedColor": "#ffffff",
"favUncheckedColor": "#1c1c1c",
"favCheckedColor": "#e84852",
"readTickUncheckedColor": "#1c1c1c",
"readTickCheckedColor": "#e84852",
"currentComicBackgroundColor": "#88000000",
"continueReadingBackgroundColor": "#88000000",
"continueReadingColor": "#ffffff",
"backgroundBlurOverlayColor": "#2a2a2a"
},
"comicsViewToolbar": {
"backgroundColor": "#f0f0f0",
"separatorColor": "#cccccc",
"checkedBackgroundColor": "#cccccc",
"iconColor": "#404040"
},
"searchLineEdit": {
"textColor": "#ababab",
"backgroundColor": "#404040",
"iconColor": "#f7f7f7"
},
"readingListIcons": {
"labelColors": {
"red": "#f67a7b",
"orange": "#f5c240",
"yellow": "#f2e446",
"green": "#ade738",
"cyan": "#a0fddb",
"blue": "#82c7ff",
"violet": "#8f95ff",
"purple": "#d692fc",
"pink": "#fd9cda",
"white": "#fcfcfc",
"light": "#cbcbcb",
"dark": "#b7b7b7"
},
"labelShadowColor": "#000000",
"labelShadowSelectedColor": "#000000",
"readingListMainColor": "#e7e7e7",
"readingListMainSelectedColor": "#e7e7e7",
"favoritesMainColor": "#e15055",
"favoritesMainSelectedColor": "#e15055",
"currentlyReadingMainColor": "#ffcc00",
"currentlyReadingMainSelectedColor": "#ffcc00",
"currentlyReadingOuterColor": "#000000",
"currentlyReadingOuterSelectedColor": "#000000",
"specialListShadowColor": "#000000",
"specialListShadowSelectedColor": "#000000",
"listMainColor": "#e7e7e7",
"listMainSelectedColor": "#e7e7e7",
"listShadowColor": "#000000",
"listShadowSelectedColor": "#000000",
"listDetailColor": "#464646",
"listDetailSelectedColor": "#464646"
},
"dialogIcons": {
"iconColor": "#f7f7f7"
},
"menuIcons": {
"iconColor": "#f7f7f7"
},
"shortcutsIcons": {
"iconColor": "#f7f7f7"
}
}

View File

@ -0,0 +1,238 @@
{
"meta": {
"id": "builtin/dark",
"displayName": "Default Dark",
"variant": "dark"
},
"defaultContentBackgroundColor": "#2a2a2a",
"comicFlow": {
"backgroundColor": "#111111",
"textColor": "#888888"
},
"comicVine": {
"contentTextColor": "#ffffff",
"contentBackgroundColor": "#2b2b2b",
"contentAltBackgroundColor": "#2e2e2e",
"dialogBackgroundColor": "#404040",
"tableBackgroundColor": "#2b2b2b",
"tableAltBackgroundColor": "#2e2e2e",
"tableBorderColor": "#242424",
"tableSelectedColor": "#555555",
"tableHeaderBackgroundColor": "#292929",
"tableHeaderGradientColor": "#292929",
"tableHeaderBorderColor": "#1f1f1f",
"tableHeaderTextColor": "#ebebeb",
"tableScrollHandleColor": "#dddddd",
"tableScrollBackgroundColor": "#404040",
"tableSectionBorderLight": "#fefefe",
"tableSectionBorderDark": "#dfdfdf",
"labelTextColor": "#ffffff",
"labelBackgroundColor": "#2b2b2b",
"hyperlinkColor": "#ffcc00",
"buttonBackgroundColor": "#2e2e2e",
"buttonTextColor": "#ffffff",
"buttonBorderColor": "#242424",
"radioUncheckedColor": "#e5e5e5",
"radioCheckedBackgroundColor": "#e5e5e5",
"radioCheckedIndicatorColor": "#5f5f5f",
"checkBoxTickColor": "#ffffff",
"toolButtonAccentColor": "#282828",
"downArrowColor": "#9f9f9f",
"upArrowColor": "#9f9f9f",
"busyIndicatorColor": "#ffffff",
"navIconColor": "#ffffff",
"rowIconColor": "#e5e5e5"
},
"helpAboutDialog": {
"headingColor": "#e0e0e0",
"linkColor": "#d4a84b"
},
"whatsNewDialog": {
"backgroundColor": "#2a2a2a",
"headerTextColor": "#e0e0e0",
"versionTextColor": "#858585",
"contentTextColor": "#e0e0e0",
"linkColor": "#e8b800",
"closeButtonColor": "#dddddd",
"headerDecorationColor": "#e8b800"
},
"emptyContainer": {
"backgroundColor": "#2a2a2a",
"titleTextColor": "#cccccc",
"textColor": "#cccccc",
"descriptionTextColor": "#aaaaaa",
"searchIconColor": "#4c4c4c"
},
"sidebar": {
"backgroundColor": "#454545",
"separatorColor": "#bdbfbf",
"sectionSeparatorColor": "#575757",
"uppercaseLabels": true,
"titleTextColor": "#bdbfbf",
"titleDropShadowColor": "#000000",
"busyIndicatorColor": "#ffffff"
},
"sidebarIcons": {
"iconColor": "#e0e0e0",
"shadowColor": "#000000",
"useSystemFolderIcons": false
},
"libraryItem": {
"textColor": "#dddfdf",
"libraryIconColor": "#dddfdf",
"libraryIconShadowColor": "#000000",
"selectedTextColor": "#ffffff",
"selectedBackgroundColor": "#2e2e2e",
"libraryIconSelectedColor": "#ffffff",
"libraryOptionsIconColor": "#ffffff"
},
"importWidget": {
"backgroundColor": "#2a2a2a",
"titleTextColor": "#cccccc",
"descriptionTextColor": "#aaaaaa",
"currentComicTextColor": "#aaaaaa",
"coversViewBackgroundColor": "#3a3a3a",
"coversLabelColor": "#aaaaaa",
"coversDecorationBgColor": "#3a3a3a",
"coversDecorationShadowColor": "#1a1a1a",
"modeIconColor": "#4a4a4a",
"iconColor": "#cccccc",
"iconCheckedColor": "#aaaaaa"
},
"serverConfigDialog": {
"backgroundColor": "#2a2a2a",
"titleTextColor": "#d0d0d0",
"qrMessageTextColor": "#a3a3a3",
"propagandaTextColor": "#b0b0b0",
"labelTextColor": "#c0c0c0",
"checkBoxTextColor": "#dddddd",
"qrBackgroundColor": "#2a2a2a",
"qrForegroundColor": "#ffffff",
"decorationColor": "#f7f7f7"
},
"mainToolbar": {
"backgroundColor": "#2a2a2a",
"folderNameColor": "#dddddd",
"dividerColor": "#555555",
"iconColor": "#dddddd",
"iconDisabledColor": "#666666"
},
"contentSplitter": {
"handleColor": "#1f1f1f",
"horizontalHandleHeight": 4,
"verticalHandleWidth": 4
},
"treeView": {
"textColor": "#dddfdf",
"selectionBackgroundColor": "#2e2e2e",
"scrollBackgroundColor": "#404040",
"scrollHandleColor": "#dddddd",
"selectedTextColor": "#ffffff",
"folderIndicatorColor": "#edc518",
"branchIndicatorColor": "#e0e0e0",
"branchIndicatorSelectedColor": "#ffffff",
"folderIconColor": "#e0e0e0",
"folderIconShadowColor": "#000000",
"folderIconSelectedColor": "#e0e0e0",
"folderIconSelectedShadowColor": "#000000",
"folderReadOverlayColor": "#222222",
"folderReadOverlaySelectedColor": "#222222"
},
"tableView": {
"alternateBackgroundColor": "#2e2e2e",
"backgroundColor": "#2a2a2a",
"headerBackgroundColor": "#2a2a2a",
"headerBorderColor": "#1f1f1f",
"headerGradientColor": "#252525",
"itemBorderBottomColor": "#1f1f1f",
"itemBorderTopColor": "#353535",
"itemBorderBottomWidth": 1,
"itemBorderTopWidth": 1,
"itemTextColor": "#dddddd",
"selectedColor": "#555555",
"selectedTextColor": "#ffffff",
"headerTextColor": "#dddddd",
"starRatingColor": "#e9be0f",
"starRatingSelectedColor": "#ffffff"
},
"qmlView": {
"backgroundColor": "#2a2a2a",
"cellColor": "#212121",
"cellColorWithBackground": "#99212121",
"selectedColor": "#121212",
"selectedBorderColor": "#ffcc00",
"borderColor": "#121212",
"titleColor": "#ffffff",
"textColor": "#a8a8a8",
"showDropShadow": true,
"infoBackgroundColor": "#2e2e2e",
"infoBorderColor": "#404040",
"infoShadowColor": "#000000",
"infoTextColor": "#b0b0b0",
"infoTitleColor": "#ffffff",
"ratingUnselectedColor": "#1c1c1c",
"ratingSelectedColor": "#ffffff",
"favUncheckedColor": "#1c1c1c",
"favCheckedColor": "#e84852",
"readTickUncheckedColor": "#1c1c1c",
"readTickCheckedColor": "#e84852",
"currentComicBackgroundColor": "#88000000",
"continueReadingBackgroundColor": "#88000000",
"continueReadingColor": "#ffffff",
"backgroundBlurOverlayColor": "#2a2a2a"
},
"comicsViewToolbar": {
"backgroundColor": "#2a2a2a",
"separatorColor": "#444444",
"checkedBackgroundColor": "#555555",
"iconColor": "#dddddd"
},
"searchLineEdit": {
"textColor": "#ababab",
"backgroundColor": "#404040",
"iconColor": "#f7f7f7"
},
"readingListIcons": {
"labelColors": {
"red": "#f67a7b",
"orange": "#f5c240",
"yellow": "#f2e446",
"green": "#ade738",
"cyan": "#a0fddb",
"blue": "#82c7ff",
"violet": "#8f95ff",
"purple": "#d692fc",
"pink": "#fd9cda",
"white": "#fcfcfc",
"light": "#cbcbcb",
"dark": "#b7b7b7"
},
"labelShadowColor": "#000000",
"labelShadowSelectedColor": "#000000",
"readingListMainColor": "#e7e7e7",
"readingListMainSelectedColor": "#e7e7e7",
"favoritesMainColor": "#e15055",
"favoritesMainSelectedColor": "#e15055",
"currentlyReadingMainColor": "#ffcc00",
"currentlyReadingMainSelectedColor": "#ffcc00",
"currentlyReadingOuterColor": "#000000",
"currentlyReadingOuterSelectedColor": "#000000",
"specialListShadowColor": "#000000",
"specialListShadowSelectedColor": "#000000",
"listMainColor": "#e7e7e7",
"listMainSelectedColor": "#e7e7e7",
"listShadowColor": "#000000",
"listShadowSelectedColor": "#000000",
"listDetailColor": "#464646",
"listDetailSelectedColor": "#464646"
},
"dialogIcons": {
"iconColor": "#f7f7f7"
},
"menuIcons": {
"iconColor": "#f7f7f7"
},
"shortcutsIcons": {
"iconColor": "#f7f7f7"
}
}

View File

@ -0,0 +1,238 @@
{
"meta": {
"id": "builtin/light",
"displayName": "Default Light",
"variant": "light"
},
"defaultContentBackgroundColor": "#ffffff",
"comicFlow": {
"backgroundColor": "#dcdcdc",
"textColor": "#303030"
},
"comicVine": {
"contentTextColor": "#000000",
"contentBackgroundColor": "#ececec",
"contentAltBackgroundColor": "#e0e0e0",
"dialogBackgroundColor": "#fbfbfb",
"tableBackgroundColor": "#f4f4f4",
"tableAltBackgroundColor": "#fafafa",
"tableBorderColor": "#cccccc",
"tableSelectedColor": "#dddddd",
"tableHeaderBackgroundColor": "#e0e0e0",
"tableHeaderGradientColor": "#e0e0e0",
"tableHeaderBorderColor": "#c0c0c0",
"tableHeaderTextColor": "#333333",
"tableScrollHandleColor": "#888888",
"tableScrollBackgroundColor": "#d0d0d0",
"tableSectionBorderLight": "#ffffff",
"tableSectionBorderDark": "#cccccc",
"labelTextColor": "#000000",
"labelBackgroundColor": "#ececec",
"hyperlinkColor": "#ffcc00",
"buttonBackgroundColor": "#e0e0e0",
"buttonTextColor": "#000000",
"buttonBorderColor": "#cccccc",
"radioUncheckedColor": "#e0e0e0",
"radioCheckedBackgroundColor": "#e0e0e0",
"radioCheckedIndicatorColor": "#222222",
"checkBoxTickColor": "#000000",
"toolButtonAccentColor": "#a0a0a0",
"downArrowColor": "#222222",
"upArrowColor": "#222222",
"busyIndicatorColor": "#000000",
"navIconColor": "#222222",
"rowIconColor": "#222222"
},
"helpAboutDialog": {
"headingColor": "#302f2d",
"linkColor": "#c19441"
},
"whatsNewDialog": {
"backgroundColor": "#ffffff",
"headerTextColor": "#0a0a0a",
"versionTextColor": "#858585",
"contentTextColor": "#0a0a0a",
"linkColor": "#e8b800",
"closeButtonColor": "#444444",
"headerDecorationColor": "#e8b800"
},
"emptyContainer": {
"backgroundColor": "#ffffff",
"titleTextColor": "#888888",
"textColor": "#495252",
"descriptionTextColor": "#565959",
"searchIconColor": "#cccccc"
},
"sidebar": {
"backgroundColor": "#fbfbfb",
"separatorColor": "#808080",
"sectionSeparatorColor": "#e0e0e0",
"uppercaseLabels": true,
"titleTextColor": "#4a494a",
"titleDropShadowColor": "#ffffff",
"busyIndicatorColor": "#808080"
},
"sidebarIcons": {
"iconColor": "#4f4e4f",
"shadowColor": "#fbfbfb",
"useSystemFolderIcons": false
},
"libraryItem": {
"textColor": "#000000",
"libraryIconColor": "#606060",
"libraryIconShadowColor": "#ffffff",
"selectedTextColor": "#ffffff",
"selectedBackgroundColor": "#333133",
"libraryIconSelectedColor": "#ffffff",
"libraryOptionsIconColor": "#ffffff"
},
"importWidget": {
"backgroundColor": "#fafafa",
"titleTextColor": "#495252",
"descriptionTextColor": "#565959",
"currentComicTextColor": "#565959",
"coversViewBackgroundColor": "#e6e6e6",
"coversLabelColor": "#565959",
"coversDecorationBgColor": "#e6e6e6",
"coversDecorationShadowColor": "#a1a1a1",
"modeIconColor": "#e6e6e6",
"iconColor": "#495252",
"iconCheckedColor": "#565959"
},
"serverConfigDialog": {
"backgroundColor": "#ffffff",
"titleTextColor": "#474747",
"qrMessageTextColor": "#a3a3a3",
"propagandaTextColor": "#4d4d4d",
"labelTextColor": "#575757",
"checkBoxTextColor": "#262626",
"qrBackgroundColor": "#ffffff",
"qrForegroundColor": "#606060",
"decorationColor": "#606060"
},
"mainToolbar": {
"backgroundColor": "#f0f0f0",
"folderNameColor": "#333133",
"dividerColor": "#b8bdc4",
"iconColor": "#333133",
"iconDisabledColor": "#b0b0b0"
},
"contentSplitter": {
"handleColor": "#f0f0f0",
"horizontalHandleHeight": 4,
"verticalHandleWidth": 4
},
"treeView": {
"textColor": "#000000",
"selectionBackgroundColor": "#333133",
"scrollBackgroundColor": "#e0e0e0",
"scrollHandleColor": "#888888",
"selectedTextColor": "#ffffff",
"folderIndicatorColor": "#555f7f",
"branchIndicatorColor": "#606060",
"branchIndicatorSelectedColor": "#ffffff",
"folderIconColor": "#606060",
"folderIconShadowColor": "#ffffff",
"folderIconSelectedColor": "#ffffff",
"folderIconSelectedShadowColor": "#161616",
"folderReadOverlayColor": "#ffffff",
"folderReadOverlaySelectedColor": "#161616"
},
"tableView": {
"alternateBackgroundColor": "#f2f2f2",
"backgroundColor": "#fafafa",
"headerBackgroundColor": "#f5f5f5",
"headerBorderColor": "#b8bdc4",
"headerGradientColor": "#f5f5f5",
"itemBorderBottomColor": "#dfdfdf",
"itemBorderTopColor": "#fefefe",
"itemBorderBottomWidth": 0,
"itemBorderTopWidth": 0,
"itemTextColor": "#252626",
"selectedColor": "#595959",
"selectedTextColor": "#ffffff",
"headerTextColor": "#313232",
"starRatingColor": "#e9be0f",
"starRatingSelectedColor": "#ffffff"
},
"qmlView": {
"backgroundColor": "#f6f6f6",
"cellColor": "#ffffff",
"cellColorWithBackground": "#99ffffff",
"selectedColor": "#ffffff",
"selectedBorderColor": "#ffcc00",
"borderColor": "#dbdbdb",
"titleColor": "#121212",
"textColor": "#636363",
"showDropShadow": true,
"infoBackgroundColor": "#ffffff",
"infoBorderColor": "#808080",
"infoShadowColor": "#444444",
"infoTextColor": "#404040",
"infoTitleColor": "#2e2e2e",
"ratingUnselectedColor": "#dedede",
"ratingSelectedColor": "#2b2b2b",
"favUncheckedColor": "#dedede",
"favCheckedColor": "#e84852",
"readTickUncheckedColor": "#dedede",
"readTickCheckedColor": "#e84852",
"currentComicBackgroundColor": "#88ffffff",
"continueReadingBackgroundColor": "#e8e8e8",
"continueReadingColor": "#000000",
"backgroundBlurOverlayColor": "#9e9e9e"
},
"comicsViewToolbar": {
"backgroundColor": "#f0f0f0",
"separatorColor": "#cccccc",
"checkedBackgroundColor": "#cccccc",
"iconColor": "#404040"
},
"searchLineEdit": {
"textColor": "#ffffff",
"backgroundColor": "#333133",
"iconColor": "#efefef"
},
"readingListIcons": {
"labelColors": {
"red": "#f67a7b",
"orange": "#f5c240",
"yellow": "#f2e446",
"green": "#ade738",
"cyan": "#a0fddb",
"blue": "#82c7ff",
"violet": "#8f95ff",
"purple": "#d692fc",
"pink": "#fd9cda",
"white": "#fcfcfc",
"light": "#cbcbcb",
"dark": "#b7b7b7"
},
"labelShadowColor": "#8f8f8f",
"labelShadowSelectedColor": "#161616",
"readingListMainColor": "#808080",
"readingListMainSelectedColor": "#808080",
"favoritesMainColor": "#e15055",
"favoritesMainSelectedColor": "#e15055",
"currentlyReadingMainColor": "#ffcc00",
"currentlyReadingMainSelectedColor": "#ffcc00",
"currentlyReadingOuterColor": "#000000",
"currentlyReadingOuterSelectedColor": "#000000",
"specialListShadowColor": "#8f8f8f",
"specialListShadowSelectedColor": "#161616",
"listMainColor": "#808080",
"listMainSelectedColor": "#ffffff",
"listShadowColor": "#8f8f8f",
"listShadowSelectedColor": "#161616",
"listDetailColor": "#ffffff",
"listDetailSelectedColor": "#161616"
},
"dialogIcons": {
"iconColor": "#606060"
},
"menuIcons": {
"iconColor": "#606060"
},
"shortcutsIcons": {
"iconColor": "#606060"
}
}

View File

@ -2,10 +2,12 @@
#define THEME_H
#include <QtGui>
#include <QJsonObject>
#include "yacreader_icon.h"
#include "help_about_dialog_theme.h"
#include "whats_new_dialog_theme.h"
#include "theme_meta.h"
struct ComicVineThemeTemplates {
QString defaultLabelQSS = "QLabel {color:%1; font-size:12px;font-family:Arial;}";
@ -452,6 +454,9 @@ struct ComicVineTheme {
};
struct Theme {
ThemeMeta meta;
QJsonObject sourceJson;
QColor defaultContentBackgroundColor;
ComicFlowColors comicFlow;

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,9 @@
#define THEME_FACTORY_H
#include "theme.h"
#include "theme_id.h"
Theme makeTheme(ThemeId themeId);
#include <QJsonObject>
Theme makeTheme(const QJsonObject &json);
#endif // THEME_FACTORY_H

View File

@ -0,0 +1,7 @@
<RCC>
<qresource prefix="/themes">
<file alias="builtin_classic.json">builtin_classic.json</file>
<file alias="builtin_light.json">builtin_light.json</file>
<file alias="builtin_dark.json">builtin_dark.json</file>
</qresource>
</RCC>

View File

@ -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

View 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>

View 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();
}

View 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

View 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();
}

View 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

View 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 &params, 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);
}

View 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 &params, QWidget *parent = nullptr);
QJsonObject currentParams() const { return params; }
signals:
void themeJsonChanged(const QJsonObject &params);
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

View File

@ -1,10 +0,0 @@
#ifndef THEME_ID_H
#define THEME_ID_H
enum class ThemeId {
Classic,
Light,
Dark,
};
#endif // THEME_ID_H

View File

@ -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);
}

View File

@ -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

View 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

View 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";
}

View 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

View File

@ -0,0 +1,9 @@
#ifndef THEME_VARIANT_H
#define THEME_VARIANT_H
enum class ThemeVariant {
Light,
Dark,
};
#endif // THEME_VARIANT_H

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93 58">
<defs>
<style>
.cls-1 {
fill: #404040;
}
.cls-2 {
fill: #292929;
}
</style>
</defs>
<rect class="cls-2" width="93" height="58" rx="5" ry="5"/>
<rect class="cls-1" x="4" y="4" width="19" height="50" rx="3.12" ry="3.12"/>
<rect class="cls-1" x="26" y="4" width="63" height="16" rx="3.12" ry="3.12"/>
<g>
<rect class="cls-1" x="26" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="39" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="52" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="65" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="78" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="26" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="39" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="52" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="65" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="78" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93 58">
<defs>
<style>
.cls-1 {
fill: #d7d7d9;
}
.cls-2 {
fill: #f7f7f7;
}
</style>
</defs>
<rect class="cls-2" x="0" width="93" height="58" rx="5" ry="5"/>
<rect class="cls-1" x="4" y="4" width="19" height="50" rx="3.12" ry="3.12"/>
<rect class="cls-1" x="26" y="4" width="63" height="16" rx="3.12" ry="3.12"/>
<g>
<rect class="cls-1" x="26" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="39" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="52" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="65" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="78" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="26" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="39" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="52" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="65" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-1" x="78" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93 58">
<defs>
<style>
.cls-1 {
fill: none;
}
.cls-2 {
fill: #d7d7d9;
}
.cls-3 {
fill: #404040;
}
.cls-4 {
fill: #292929;
}
.cls-5 {
fill: #f7f7f7;
}
</style>
</defs>
<rect class="cls-5" width="93" height="58" rx="5" ry="5"/>
<rect class="cls-2" x="4" y="4" width="19" height="50" rx="3.12" ry="3.12"/>
<rect class="cls-2" x="26" y="4" width="63" height="16" rx="3.12" ry="3.12"/>
<g>
<rect class="cls-2" x="26" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-2" x="39" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-2" x="52" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-2" x="65" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-2" x="78" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-2" x="26" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-2" x="39" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-2" x="52" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-2" x="65" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-2" x="78" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
</g>
<polyline class="cls-1" points="93 0 93 58 0 58"/>
<path class="cls-4" d="M91.16,1.15L1.84,56.85c.86.71,1.95,1.15,3.16,1.15h83c2.76,0,5-2.24,5-5V5c0-1.56-.73-2.93-1.84-3.85Z"/>
<path class="cls-3" d="M85.88,20c1.73,0,3.12-1.4,3.12-3.12V7.12c0-1.52-1.08-2.78-2.51-3.06l-25.56,15.94h24.94Z"/>
<path class="cls-3" d="M6.51,53.94c.2.04.4.06.61.06h12.75c1.73,0,3.12-1.4,3.12-3.12v-7.22l-16.49,10.28Z"/>
<path class="cls-3" d="M33.67,37h.2c1.3,0,2.41-.8,2.88-1.93l-3.09,1.93Z"/>
<path class="cls-3" d="M39,33.68v.2c0,1.73,1.4,3.12,3.12,3.12h4.75c1.73,0,3.12-1.4,3.12-3.12v-7.06l-11,6.86Z"/>
<path class="cls-3" d="M52,33.88c0,1.73,1.4,3.12,3.12,3.12h4.75c1.73,0,3.12-1.4,3.12-3.12v-9.75c0-1.73-1.4-3.12-3.12-3.12h-.55l-7.33,4.57v8.31Z"/>
<rect class="cls-3" x="65" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-3" x="78" y="21" width="11" height="16" rx="3.13" ry="3.13"/>
<path class="cls-3" d="M26,50.88c0,1.73,1.4,3.12,3.12,3.12h4.75c1.73,0,3.12-1.4,3.12-3.12v-9.75c0-1.73-1.4-3.12-3.12-3.12h-1.81l-6.07,3.78v9.09Z"/>
<rect class="cls-3" x="39" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-3" x="52" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-3" x="65" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
<rect class="cls-3" x="78" y="38" width="11" height="16" rx="3.13" ry="3.13"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB