From 77aa85fbd3af12b319f8f0b8372f4ba186d10c80 Mon Sep 17 00:00:00 2001 From: Vladimir Golovnev Date: Thu, 16 Mar 2023 10:03:05 +0300 Subject: [PATCH] Provide UI Theme editor PR #18655. --- src/base/utils/fs.cpp | 6 + src/base/utils/io.cpp | 3 + src/gui/CMakeLists.txt | 6 + src/gui/color.h | 2 + src/gui/gui.pri | 6 + src/gui/optionsdialog.cpp | 13 ++ src/gui/optionsdialog.ui | 41 ++-- src/gui/uithemecommon.h | 181 +++++++++++++++++ src/gui/uithemedialog.cpp | 388 +++++++++++++++++++++++++++++++++++++ src/gui/uithemedialog.h | 69 +++++++ src/gui/uithemedialog.ui | 286 +++++++++++++++++++++++++++ src/gui/uithememanager.cpp | 348 +-------------------------------- src/gui/uithememanager.h | 17 +- src/gui/uithemesource.cpp | 280 ++++++++++++++++++++++++++ src/gui/uithemesource.h | 115 +++++++++++ 15 files changed, 1381 insertions(+), 380 deletions(-) create mode 100644 src/gui/uithemecommon.h create mode 100644 src/gui/uithemedialog.cpp create mode 100644 src/gui/uithemedialog.h create mode 100644 src/gui/uithemedialog.ui create mode 100644 src/gui/uithemesource.cpp create mode 100644 src/gui/uithemesource.h diff --git a/src/base/utils/fs.cpp b/src/base/utils/fs.cpp index ae0ed75ee..fe9717078 100644 --- a/src/base/utils/fs.cpp +++ b/src/base/utils/fs.cpp @@ -292,6 +292,12 @@ bool Utils::Fs::isNetworkFileSystem(const Path &path) bool Utils::Fs::copyFile(const Path &from, const Path &to) { + if (!from.exists()) + return false; + + if (!mkpath(to.parentPath())) + return false; + return QFile::copy(from.data(), to.data()); } diff --git a/src/base/utils/io.cpp b/src/base/utils/io.cpp index afb904930..b5c938efc 100644 --- a/src/base/utils/io.cpp +++ b/src/base/utils/io.cpp @@ -37,6 +37,7 @@ #include #include "base/path.h" +#include "base/utils/fs.h" Utils::IO::FileDeviceOutputIterator::FileDeviceOutputIterator(QFileDevice &device, const int bufferSize) : m_device {&device} @@ -70,6 +71,8 @@ Utils::IO::FileDeviceOutputIterator &Utils::IO::FileDeviceOutputIterator::operat nonstd::expected Utils::IO::saveToFile(const Path &path, const QByteArray &data) { + if (const Path parentPath = path.parentPath(); !parentPath.isEmpty()) + Utils::Fs::mkpath(parentPath); QSaveFile file {path.data()}; if (!file.open(QIODevice::WriteOnly) || (file.write(data) != data.size()) || !file.flush() || !file.commit()) return nonstd::make_unexpected(file.errorString()); diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index c98eb5d8a..443021a48 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -37,6 +37,7 @@ qt_wrap_ui(UI_HEADERS torrentcreatordialog.ui torrentoptionsdialog.ui trackerentriesdialog.ui + uithemedialog.ui watchedfolderoptionsdialog.ui ) @@ -121,7 +122,10 @@ add_library(qbt_gui STATIC transferlistwidget.h tristateaction.h tristatewidget.h + uithemecommon.h + uithemedialog.h uithememanager.h + uithemesource.h utils.h watchedfolderoptionsdialog.h watchedfoldersmodel.h @@ -205,7 +209,9 @@ add_library(qbt_gui STATIC transferlistwidget.cpp tristateaction.cpp tristatewidget.cpp + uithemedialog.cpp uithememanager.cpp + uithemesource.cpp utils.cpp watchedfolderoptionsdialog.cpp watchedfoldersmodel.cpp diff --git a/src/gui/color.h b/src/gui/color.h index 7438f91e0..9859e19fc 100644 --- a/src/gui/color.h +++ b/src/gui/color.h @@ -28,6 +28,8 @@ #pragma once +#include + namespace Color { /* diff --git a/src/gui/gui.pri b/src/gui/gui.pri index a07251a8a..f41193456 100644 --- a/src/gui/gui.pri +++ b/src/gui/gui.pri @@ -80,7 +80,10 @@ HEADERS += \ $$PWD/transferlistwidget.h \ $$PWD/tristateaction.h \ $$PWD/tristatewidget.h \ + $$PWD/uithemecommon.h \ + $$PWD/uithemedialog.h \ $$PWD/uithememanager.h \ + $$PWD/uithemesource.h \ $$PWD/utils.h \ $$PWD/watchedfolderoptionsdialog.h \ $$PWD/watchedfoldersmodel.h \ @@ -164,7 +167,9 @@ SOURCES += \ $$PWD/transferlistwidget.cpp \ $$PWD/tristateaction.cpp \ $$PWD/tristatewidget.cpp \ + $$PWD/uithemedialog.cpp \ $$PWD/uithememanager.cpp \ + $$PWD/uithemesource.cpp \ $$PWD/utils.cpp \ $$PWD/watchedfolderoptionsdialog.cpp \ $$PWD/watchedfoldersmodel.cpp @@ -198,6 +203,7 @@ FORMS += \ $$PWD/torrentcreatordialog.ui \ $$PWD/torrentoptionsdialog.ui \ $$PWD/trackerentriesdialog.ui \ + $$PWD/uithemedialog.ui \ $$PWD/watchedfolderoptionsdialog.ui RESOURCES += $$PWD/about.qrc diff --git a/src/gui/optionsdialog.cpp b/src/gui/optionsdialog.cpp index 96b0817d8..3f61856b9 100644 --- a/src/gui/optionsdialog.cpp +++ b/src/gui/optionsdialog.cpp @@ -63,6 +63,7 @@ #include "ipsubnetwhitelistoptionsdialog.h" #include "rss/automatedrssdownloader.h" #include "ui_optionsdialog.h" +#include "uithemedialog.h" #include "uithememanager.h" #include "utils.h" #include "watchedfolderoptionsdialog.h" @@ -323,6 +324,18 @@ void OptionsDialog::loadBehaviorTabOptions() connect(m_ui->checkUseCustomTheme, &QGroupBox::toggled, this, &ThisType::enableApplyButton); connect(m_ui->customThemeFilePath, &FileSystemPathEdit::selectedPathChanged, this, &ThisType::enableApplyButton); + m_ui->buttonCustomizeUITheme->setEnabled(!m_ui->checkUseCustomTheme->isChecked()); + connect(m_ui->checkUseCustomTheme, &QGroupBox::toggled, this, [this] + { + m_ui->buttonCustomizeUITheme->setEnabled(!m_ui->checkUseCustomTheme->isChecked()); + }); + connect(m_ui->buttonCustomizeUITheme, &QPushButton::clicked, this, [this] + { + auto dialog = new UIThemeDialog(this); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->open(); + }); + connect(m_ui->confirmDeletion, &QAbstractButton::toggled, this, &ThisType::enableApplyButton); connect(m_ui->checkAltRowColors, &QAbstractButton::toggled, this, &ThisType::enableApplyButton); connect(m_ui->checkHideZero, &QAbstractButton::toggled, m_ui->comboHideZero, &QWidget::setEnabled); diff --git a/src/gui/optionsdialog.ui b/src/gui/optionsdialog.ui index 9260af724..58e18f7ab 100644 --- a/src/gui/optionsdialog.ui +++ b/src/gui/optionsdialog.ui @@ -122,8 +122,8 @@ 0 0 - 501 - 893 + 504 + 1064 @@ -133,7 +133,7 @@ Interface - + @@ -210,6 +210,13 @@ + + + + Customize UI Theme... + + + @@ -778,8 +785,8 @@ 0 0 - 591 - 1138 + 539 + 1457 @@ -1565,8 +1572,8 @@ readme[0-9].txt: filter 'readme1.txt', 'readme2.txt' but not 'readme10.txt'. 0 0 - 501 - 745 + 377 + 756 @@ -1800,7 +1807,7 @@ readme[0-9].txt: filter 'readme1.txt', 'readme2.txt' but not 'readme10.txt'. - + @@ -1810,7 +1817,7 @@ readme[0-9].txt: filter 'readme1.txt', 'readme2.txt' but not 'readme10.txt'. - + @@ -2056,8 +2063,8 @@ readme[0-9].txt: filter 'readme1.txt', 'readme2.txt' but not 'readme10.txt'. 0 0 - 516 - 525 + 302 + 408 @@ -2393,8 +2400,8 @@ readme[0-9].txt: filter 'readme1.txt', 'readme2.txt' but not 'readme10.txt'. 0 0 - 513 - 679 + 413 + 693 @@ -2913,8 +2920,8 @@ Disable encryption: Only connect to peers without protocol encryption 0 0 - 516 - 525 + 336 + 391 @@ -3083,8 +3090,8 @@ Disable encryption: Only connect to peers without protocol encryption 0 0 - 501 - 636 + 382 + 1045 diff --git a/src/gui/uithemecommon.h b/src/gui/uithemecommon.h new file mode 100644 index 000000000..9f1a33192 --- /dev/null +++ b/src/gui/uithemecommon.h @@ -0,0 +1,181 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2023 Vladimir Golovnev + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, + * USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If + * you modify file(s), you may extend this exception to your version of the + * file(s), but you are not obligated to do so. If you do not wish to do so, + * delete this exception statement from your version. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "base/global.h" +#include "color.h" + +inline const QString CONFIG_FILE_NAME = u"config.json"_qs; +inline const QString STYLESHEET_FILE_NAME = u"stylesheet.qss"_qs; +inline const QString KEY_COLORS = u"colors"_qs; +inline const QString KEY_COLORS_LIGHT = u"colors.light"_qs; +inline const QString KEY_COLORS_DARK = u"colors.dark"_qs; + +struct UIThemeColor +{ + QColor light; + QColor dark; +}; + +inline QHash defaultUIThemeColors() +{ + const QPalette palette = QApplication::palette(); + return { + {u"Log.TimeStamp"_qs, {Color::Primer::Light::fgSubtle, Color::Primer::Dark::fgSubtle}}, + {u"Log.Normal"_qs, {palette.color(QPalette::Active, QPalette::WindowText), palette.color(QPalette::Active, QPalette::WindowText)}}, + {u"Log.Info"_qs, {Color::Primer::Light::accentFg, Color::Primer::Dark::accentFg}}, + {u"Log.Warning"_qs, {Color::Primer::Light::severeFg, Color::Primer::Dark::severeFg}}, + {u"Log.Critical"_qs, {Color::Primer::Light::dangerFg, Color::Primer::Dark::dangerFg}}, + {u"Log.BannedPeer"_qs, {Color::Primer::Light::dangerFg, Color::Primer::Dark::dangerFg}}, + + {u"RSS.ReadArticle"_qs, {palette.color(QPalette::Inactive, QPalette::WindowText), palette.color(QPalette::Inactive, QPalette::WindowText)}}, + {u"RSS.UnreadArticle"_qs, {palette.color(QPalette::Active, QPalette::Link), palette.color(QPalette::Active, QPalette::Link)}}, + + {u"TransferList.Downloading"_qs, {Color::Primer::Light::successFg, Color::Primer::Dark::successFg}}, + {u"TransferList.StalledDownloading"_qs, {Color::Primer::Light::successEmphasis, Color::Primer::Dark::successEmphasis}}, + {u"TransferList.DownloadingMetadata"_qs, {Color::Primer::Light::successFg, Color::Primer::Dark::successFg}}, + {u"TransferList.ForcedDownloadingMetadata"_qs, {Color::Primer::Light::successFg, Color::Primer::Dark::successFg}}, + {u"TransferList.ForcedDownloading"_qs, {Color::Primer::Light::successFg, Color::Primer::Dark::successFg}}, + {u"TransferList.Uploading"_qs, {Color::Primer::Light::accentFg, Color::Primer::Dark::accentFg}}, + {u"TransferList.StalledUploading"_qs, {Color::Primer::Light::accentEmphasis, Color::Primer::Dark::accentEmphasis}}, + {u"TransferList.ForcedUploading"_qs, {Color::Primer::Light::accentFg, Color::Primer::Dark::accentFg}}, + {u"TransferList.QueuedDownloading"_qs, {Color::Primer::Light::scaleYellow6, Color::Primer::Dark::scaleYellow6}}, + {u"TransferList.QueuedUploading"_qs, {Color::Primer::Light::scaleYellow6, Color::Primer::Dark::scaleYellow6}}, + {u"TransferList.CheckingDownloading"_qs, {Color::Primer::Light::successFg, Color::Primer::Dark::successFg}}, + {u"TransferList.CheckingUploading"_qs, {Color::Primer::Light::successFg, Color::Primer::Dark::successFg}}, + {u"TransferList.CheckingResumeData"_qs, {Color::Primer::Light::successFg, Color::Primer::Dark::successFg}}, + {u"TransferList.PausedDownloading"_qs, {Color::Primer::Light::fgMuted, Color::Primer::Dark::fgMuted}}, + {u"TransferList.PausedUploading"_qs, {Color::Primer::Light::doneFg, Color::Primer::Dark::doneFg}}, + {u"TransferList.Moving"_qs, {Color::Primer::Light::successFg, Color::Primer::Dark::successFg}}, + {u"TransferList.MissingFiles"_qs, {Color::Primer::Light::dangerFg, Color::Primer::Dark::dangerFg}}, + {u"TransferList.Error"_qs, {Color::Primer::Light::dangerFg, Color::Primer::Dark::dangerFg}} + }; +} + +inline QSet defaultUIThemeIcons() +{ + return { + u"application-exit"_qs, + u"application-rss"_qs, + u"application-url"_qs, + u"browser-cookies"_qs, + u"chart-line"_qs, + u"checked-completed"_qs, + u"configure"_qs, + u"connected"_qs, + u"dialog-warning"_qs, + u"directory"_qs, + u"disconnected"_qs, + u"download"_qs, + u"downloading"_qs, + u"edit-clear"_qs, + u"edit-copy"_qs, + u"edit-find"_qs, + u"edit-rename"_qs, + u"error"_qs, + u"fileicon"_qs, + u"filter-active"_qs, + u"filter-all"_qs, + u"filter-inactive"_qs, + u"filter-stalled"_qs, + u"firewalled"_qs, + u"folder-documents"_qs, + u"folder-new"_qs, + u"folder-remote"_qs, + u"force-recheck"_qs, + u"go-bottom"_qs, + u"go-down"_qs, + u"go-top"_qs, + u"go-up"_qs, + u"hash"_qs, + u"help-about"_qs, + u"help-contents"_qs, + u"insert-link"_qs, + u"ip-blocked"_qs, + u"list-add"_qs, + u"list-remove"_qs, + u"loading"_qs, + u"mail-inbox"_qs, + u"name"_qs, + u"network-connect"_qs, + u"network-server"_qs, + u"object-locked"_qs, + u"peers"_qs, + u"peers-add"_qs, + u"peers-remove"_qs, + u"plugins"_qs, + u"preferences-advanced"_qs, + u"preferences-bittorrent"_qs, + u"preferences-desktop"_qs, + u"preferences-webui"_qs, + u"qbittorrent-tray"_qs, + u"qbittorrent-tray-dark"_qs, + u"qbittorrent-tray-light"_qs, + u"queued"_qs, + u"ratio"_qs, + u"reannounce"_qs, + u"security-high"_qs, + u"security-low"_qs, + u"set-location"_qs, + u"slow"_qs, + u"slow_off"_qs, + u"speedometer"_qs, + u"stalledDL"_qs, + u"stalledUP"_qs, + u"stopped"_qs, + u"system-log-out"_qs, + u"tags"_qs, + u"task-complete"_qs, + u"task-reject"_qs, + u"torrent-creator"_qs, + u"torrent-magnet"_qs, + u"torrent-start"_qs, + u"torrent-start-forced"_qs, + u"torrent-stop"_qs, + u"tracker-error"_qs, + u"tracker-warning"_qs, + u"trackerless"_qs, + u"trackers"_qs, + u"upload"_qs, + u"view-categories"_qs, + u"view-preview"_qs, + u"view-refresh"_qs, + u"view-statistics"_qs, + u"wallet-open"_qs + }; +} diff --git a/src/gui/uithemedialog.cpp b/src/gui/uithemedialog.cpp new file mode 100644 index 000000000..d8bedb72a --- /dev/null +++ b/src/gui/uithemedialog.cpp @@ -0,0 +1,388 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2023 Vladimir Golovnev + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#include "uithemedialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "base/3rdparty/expected.hpp" +#include "base/global.h" +#include "base/logger.h" +#include "base/path.h" +#include "base/profile.h" +#include "base/utils/fs.h" +#include "base/utils/io.h" +#include "uithemecommon.h" +#include "utils.h" + +#include "ui_uithemedialog.h" + +namespace +{ + Path userConfigPath() + { + return specialFolderLocation(SpecialFolder::Config) / Path(u"themes/default"_qs); + } + + Path defaultIconPath(const QString &iconID, [[maybe_unused]] const ColorMode colorMode) + { + return Path(u":icons"_qs) / Path(iconID + u".svg"); + } +} + +class ColorWidget final : public QFrame +{ + Q_DISABLE_COPY_MOVE(ColorWidget) + +public: + explicit ColorWidget(const QColor ¤tColor, const QColor &defaultColor, QWidget *parent = nullptr) + : QFrame(parent) + , m_defaultColor {defaultColor} + { + setObjectName(u"colorWidget"_qs); + setFrameShape(QFrame::Box); + setFrameShadow(QFrame::Plain); + + setCurrentColor(currentColor); + } + + QColor currentColor() const + { + return m_currentColor; + } + +private: + void mouseDoubleClickEvent([[maybe_unused]] QMouseEvent *event) override + { + showColorDialog(); + } + + void contextMenuEvent([[maybe_unused]] QContextMenuEvent *event) override + { + QMenu *menu = new QMenu(this); + menu->setAttribute(Qt::WA_DeleteOnClose); + + menu->addAction(tr("Edit..."), this, &ColorWidget::showColorDialog); + menu->addAction(tr("Reset"), this, &ColorWidget::resetColor); + + menu->popup(QCursor::pos()); + } + + void setCurrentColor(const QColor &color) + { + if (m_currentColor == color) + return; + + m_currentColor = color; + applyColor(m_currentColor); + } + + void resetColor() + { + setCurrentColor(m_defaultColor); + } + + void applyColor(const QColor &color) + { + setStyleSheet(u"#colorWidget { background-color: %1; }"_qs.arg(color.name())); + } + + void showColorDialog() + { + auto dialog = new QColorDialog(m_currentColor, this); + dialog->setAttribute(Qt::WA_DeleteOnClose); + connect(dialog, &QDialog::accepted, this, [this, dialog] + { + setCurrentColor(dialog->currentColor()); + }); + + dialog->open(); + } + + const QColor m_defaultColor; + QColor m_currentColor; +}; + +class IconWidget final : public QLabel +{ + Q_DISABLE_COPY_MOVE(IconWidget) + +public: + explicit IconWidget(const Path ¤tPath, const Path &defaultPath, QWidget *parent = nullptr) + : QLabel(parent) + , m_defaultPath {defaultPath} + { + setObjectName(u"iconWidget"_qs); + setAlignment(Qt::AlignCenter); + + setCurrentPath(currentPath); + } + + Path currentPath() const + { + return m_currentPath; + } + +private: + void mouseDoubleClickEvent([[maybe_unused]] QMouseEvent *event) override + { + showFileDialog(); + } + + void contextMenuEvent([[maybe_unused]] QContextMenuEvent *event) override + { + QMenu *menu = new QMenu(this); + menu->setAttribute(Qt::WA_DeleteOnClose); + + menu->addAction(tr("Browse..."), this, &IconWidget::showFileDialog); + menu->addAction(tr("Reset"), this, &IconWidget::resetIcon); + + menu->popup(QCursor::pos()); + } + + void setCurrentPath(const Path &path) + { + if (m_currentPath == path) + return; + + m_currentPath = path; + showIcon(m_currentPath); + } + + void resetIcon() + { + setCurrentPath(m_defaultPath); + } + + void showIcon(const Path &iconPath) + { + const QIcon icon {iconPath.data()}; + setPixmap(icon.pixmap(Utils::Gui::smallIconSize())); + } + + void showFileDialog() + { + auto *dialog = new QFileDialog(this, tr("Select icon") + , QDir::homePath(), (tr("Supported image files") + u" (*.svg *.png)")); + dialog->setFileMode(QFileDialog::ExistingFile); + dialog->setAttribute(Qt::WA_DeleteOnClose); + connect(dialog, &QDialog::accepted, this, [this, dialog] + { + const Path iconPath {dialog->selectedFiles().value(0)}; + setCurrentPath(iconPath); + }); + + dialog->open(); + } + + const Path m_defaultPath; + Path m_currentPath; +}; + +UIThemeDialog::UIThemeDialog(QWidget *parent) + : QDialog(parent) + , m_ui {new Ui::UIThemeDialog} +{ + m_ui->setupUi(this); + + loadColors(); + loadIcons(); +} + +UIThemeDialog::~UIThemeDialog() +{ + delete m_ui; +} + +void UIThemeDialog::accept() +{ + QDialog::accept(); + + bool hasError = false; + if (!storeColors()) + hasError = true; + if (!storeIcons()) + hasError = true; + + if (hasError) + { + QMessageBox::critical(this, tr("UI Theme Configuration.") + , tr("The UI Theme changes could not be fully applied. The details can be found in the Log.")); + } +} + +void UIThemeDialog::loadColors() +{ + const QHash defaultColors = defaultUIThemeColors(); + const QList colorIDs = std::invoke([](auto &&list) { list.sort(); return list; }, defaultColors.keys()); + int row = 2; + for (const QString &id : colorIDs) + { + m_ui->colorsLayout->addWidget(new QLabel(id), row, 0); + + const UIThemeColor &defaultColor = defaultColors.value(id); + + auto *lightColorWidget = new ColorWidget(m_defaultThemeSource.getColor(id, ColorMode::Light), defaultColor.light, this); + m_lightColorWidgets.insert(id, lightColorWidget); + m_ui->colorsLayout->addWidget(lightColorWidget, row, 2); + + auto *darkColorWidget = new ColorWidget(m_defaultThemeSource.getColor(id, ColorMode::Dark), defaultColor.dark, this); + m_darkColorWidgets.insert(id, darkColorWidget); + m_ui->colorsLayout->addWidget(darkColorWidget, row, 4); + + ++row; + } +} + +void UIThemeDialog::loadIcons() +{ + const QSet defaultIcons = defaultUIThemeIcons(); + const QList iconIDs = std::invoke([](auto &&list) { list.sort(); return list; } + , QList(defaultIcons.cbegin(), defaultIcons.cend())); + int row = 2; + for (const QString &id : iconIDs) + { + m_ui->iconsLayout->addWidget(new QLabel(id), row, 0); + + auto *lightIconWidget = new IconWidget(m_defaultThemeSource.getIconPath(id, ColorMode::Light) + , defaultIconPath(id, ColorMode::Light), this); + m_lightIconWidgets.insert(id, lightIconWidget); + m_ui->iconsLayout->addWidget(lightIconWidget, row, 2); + + auto *darkIconWidget = new IconWidget(m_defaultThemeSource.getIconPath(id, ColorMode::Dark) + , defaultIconPath(id, ColorMode::Dark), this); + m_darkIconWidgets.insert(id, darkIconWidget); + m_ui->iconsLayout->addWidget(darkIconWidget, row, 4); + + ++row; + } +} + +bool UIThemeDialog::storeColors() +{ + QJsonObject userConfig; + userConfig.insert(u"version", 2); + + const QHash defaultColors = defaultUIThemeColors(); + const auto addColorOverrides = [this, &defaultColors, &userConfig](const ColorMode colorMode) + { + const QHash &colorWidgets = (colorMode == ColorMode::Light) + ? m_lightColorWidgets : m_darkColorWidgets; + + QJsonObject colors; + for (auto it = colorWidgets.cbegin(); it != colorWidgets.cend(); ++it) + { + const QString &colorID = it.key(); + const QColor &defaultColor = (colorMode == ColorMode::Light) + ? defaultColors.value(colorID).light : defaultColors.value(colorID).dark; + const QColor &color = it.value()->currentColor(); + if (color != defaultColor) + colors.insert(it.key(), color.name()); + } + + if (!colors.isEmpty()) + userConfig.insert(((colorMode == ColorMode::Light) ? KEY_COLORS_LIGHT : KEY_COLORS_DARK), colors); + }; + + addColorOverrides(ColorMode::Light); + addColorOverrides(ColorMode::Dark); + + const QByteArray configData = QJsonDocument(userConfig).toJson(); + const nonstd::expected result = Utils::IO::saveToFile((userConfigPath() / Path(CONFIG_FILE_NAME)), configData); + if (!result) + { + const QString error = tr("Couldn't save UI Theme configuration. Reason: %1").arg(result.error()); + LogMsg(error, Log::WARNING); + return false; + } + + return true; +} + +bool UIThemeDialog::storeIcons() +{ + bool hasError = false; + + const auto updateIcons = [this, &hasError](const ColorMode colorMode) + { + const QHash &iconWidgets = (colorMode == ColorMode::Light) + ? m_lightIconWidgets : m_darkIconWidgets; + const Path subdirPath = (colorMode == ColorMode::Light) + ? Path(u"icons/light"_qs) : Path(u"icons/dark"_qs); + + for (auto it = iconWidgets.cbegin(); it != iconWidgets.cend(); ++it) + { + const QString &id = it.key(); + const Path &path = it.value()->currentPath(); + if (path == m_defaultThemeSource.getIconPath(id, colorMode)) + continue; + + const Path &userIconPathBase = userConfigPath() / subdirPath / Path(id); + + if (const Path oldIconPath = userIconPathBase + u".svg" + ; path.exists() && !Utils::Fs::removeFile(oldIconPath)) + { + const QString error = tr("Couldn't remove icon file. File: %1.").arg(oldIconPath.toString()); + LogMsg(error, Log::WARNING); + hasError = true; + continue; + } + + if (const Path oldIconPath = userIconPathBase + u".png" + ; path.exists() && !Utils::Fs::removeFile(oldIconPath)) + { + const QString error = tr("Couldn't remove icon file. File: %1.").arg(oldIconPath.toString()); + LogMsg(error, Log::WARNING); + hasError = true; + continue; + } + + if (const Path targetPath = userIconPathBase + path.extension() + ; !Utils::Fs::copyFile(path, targetPath)) + { + const QString error = tr("Couldn't copy icon file. Source: %1. Destination: %2.") + .arg(path.toString(), targetPath.toString()); + LogMsg(error, Log::WARNING); + hasError = true; + } + } + }; + + updateIcons(ColorMode::Light); + updateIcons(ColorMode::Dark); + + return !hasError; +} diff --git a/src/gui/uithemedialog.h b/src/gui/uithemedialog.h new file mode 100644 index 000000000..b1cd1b385 --- /dev/null +++ b/src/gui/uithemedialog.h @@ -0,0 +1,69 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2023 Vladimir Golovnev + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#pragma once + +#include +#include +#include + +#include "uithemesource.h" + +namespace Ui +{ + class UIThemeDialog; +} + +class ColorWidget; +class IconWidget; + +class UIThemeDialog final : public QDialog +{ + Q_OBJECT + Q_DISABLE_COPY_MOVE(UIThemeDialog) + +public: + explicit UIThemeDialog(QWidget *parent = nullptr); + ~UIThemeDialog() override; + + void accept() override; + +private: + void loadColors(); + void loadIcons(); + bool storeColors(); + bool storeIcons(); + + Ui::UIThemeDialog *m_ui; + + DefaultThemeSource m_defaultThemeSource; + QHash m_lightColorWidgets; + QHash m_darkColorWidgets; + QHash m_lightIconWidgets; + QHash m_darkIconWidgets; +}; diff --git a/src/gui/uithemedialog.ui b/src/gui/uithemedialog.ui new file mode 100644 index 000000000..00247614b --- /dev/null +++ b/src/gui/uithemedialog.ui @@ -0,0 +1,286 @@ + + + UIThemeDialog + + + + 0 + 0 + 451 + 348 + + + + UI Theme Configuration + + + + + + + 0 + 0 + + + + 0 + + + true + + + + Colors + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + QFrame::NoFrame + + + QAbstractScrollArea::AdjustToContentsOnFirstShow + + + true + + + + + 0 + 0 + 427 + 271 + + + + + + + + + + true + + + + Color ID + + + + + + + + true + + + + Light Mode + + + + + + + + true + + + + Dark Mode + + + + + + + + + Qt::Vertical + + + + 20 + 203 + + + + + + + + + + + + + Icons + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + QFrame::NoFrame + + + QAbstractScrollArea::AdjustToContentsOnFirstShow + + + true + + + + + 0 + 0 + 427 + 271 + + + + + + + + + + true + + + + Icon ID + + + + + + + + true + + + + Light Mode + + + + + + + + true + + + + Dark Mode + + + + + + + + + Qt::Vertical + + + + 20 + 203 + + + + + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + UIThemeDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + UIThemeDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/gui/uithememanager.cpp b/src/gui/uithememanager.cpp index 77a237811..2bceb9ef2 100644 --- a/src/gui/uithememanager.cpp +++ b/src/gui/uithememanager.cpp @@ -30,370 +30,24 @@ #include "uithememanager.h" -#include -#include -#include -#include -#include #include #include #include -#include "base/algorithm.h" #include "base/global.h" #include "base/logger.h" #include "base/path.h" #include "base/preferences.h" -#include "base/profile.h" -#include "base/utils/fs.h" -#include "color.h" +#include "uithemecommon.h" namespace { - const QString CONFIG_FILE_NAME = u"config.json"_qs; - const QString STYLESHEET_FILE_NAME = u"stylesheet.qss"_qs; - bool isDarkTheme() { const QPalette palette = qApp->palette(); const QColor &color = palette.color(QPalette::Active, QPalette::Base); return (color.lightness() < 127); } - - QByteArray readFile(const Path &filePath) - { - QFile file {filePath.data()}; - if (!file.exists()) - return {}; - - if (file.open(QIODevice::ReadOnly | QIODevice::Text)) - return file.readAll(); - - LogMsg(UIThemeManager::tr("UITheme - Failed to open \"%1\". Reason: %2") - .arg(filePath.filename(), file.errorString()) - , Log::WARNING); - return {}; - } - - QJsonObject parseThemeConfig(const QByteArray &data) - { - if (data.isEmpty()) - return {}; - - QJsonParseError jsonError; - const QJsonDocument configJsonDoc = QJsonDocument::fromJson(data, &jsonError); - if (jsonError.error != QJsonParseError::NoError) - { - LogMsg(UIThemeManager::tr("Couldn't parse UI Theme configuration file. Reason: %1") - .arg(jsonError.errorString()), Log::WARNING); - return {}; - } - - if (!configJsonDoc.isObject()) - { - LogMsg(UIThemeManager::tr("UI Theme configuration file has invalid format. Reason: %1") - .arg(UIThemeManager::tr("Root JSON value is not an object")), Log::WARNING); - return {}; - } - - return configJsonDoc.object(); - } - - QHash colorsFromJSON(const QJsonObject &jsonObj) - { - QHash colors; - for (auto colorNode = jsonObj.constBegin(); colorNode != jsonObj.constEnd(); ++colorNode) - { - const QColor color {colorNode.value().toString()}; - if (!color.isValid()) - { - LogMsg(UIThemeManager::tr("Invalid color for ID \"%1\" is provided by theme") - .arg(colorNode.key()), Log::WARNING); - continue; - } - - colors.insert(colorNode.key(), color); - } - - return colors; - } - - Path findIcon(const QString &iconId, const Path &dir) - { - const Path pathSvg = dir / Path(iconId + u".svg"); - if (pathSvg.exists()) - return pathSvg; - - const Path pathPng = dir / Path(iconId + u".png"); - if (pathPng.exists()) - return pathPng; - - return {}; - } - - class DefaultThemeSource final : public UIThemeSource - { - public: - DefaultThemeSource() - { - loadColors(); - } - - QByteArray readStyleSheet() override - { - return {}; - } - - QColor getColor(const QString &colorId, const ColorMode colorMode) const override - { - if (colorMode == ColorMode::Dark) - { - if (const QColor color = m_darkModeColors.value(colorId) - ; color.isValid()) - { - return color; - } - } - - return m_colors.value(colorId); - } - - Path getIconPath(const QString &iconId, const ColorMode colorMode) const override - { - const Path iconsPath {u"icons"_qs}; - const Path darkModeIconsPath = iconsPath / Path(u"dark"_qs); - - if (colorMode == ColorMode::Dark) - { - if (const Path iconPath = findIcon(iconId, (m_userPath / darkModeIconsPath)) - ; !iconPath.isEmpty()) - { - return iconPath; - } - - if (const Path iconPath = findIcon(iconId, (m_defaultPath / darkModeIconsPath)) - ; !iconPath.isEmpty()) - { - return iconPath; - } - } - - if (const Path iconPath = findIcon(iconId, (m_userPath / iconsPath)) - ; !iconPath.isEmpty()) - { - return iconPath; - } - - return findIcon(iconId, (m_defaultPath / iconsPath)); - } - - private: - void loadColors() - { - m_colors = { - {u"Log.TimeStamp"_qs, Color::Primer::Light::fgSubtle}, - {u"Log.Normal"_qs, QApplication::palette().color(QPalette::Active, QPalette::WindowText)}, - {u"Log.Info"_qs, Color::Primer::Light::accentFg}, - {u"Log.Warning"_qs, Color::Primer::Light::severeFg}, - {u"Log.Critical"_qs, Color::Primer::Light::dangerFg}, - {u"Log.BannedPeer"_qs, Color::Primer::Light::dangerFg}, - - {u"RSS.ReadArticle"_qs, QApplication::palette().color(QPalette::Inactive, QPalette::WindowText)}, - {u"RSS.UnreadArticle"_qs, QApplication::palette().color(QPalette::Active, QPalette::Link)}, - - {u"TransferList.Downloading"_qs, Color::Primer::Light::successFg}, - {u"TransferList.StalledDownloading"_qs, Color::Primer::Light::successEmphasis}, - {u"TransferList.DownloadingMetadata"_qs, Color::Primer::Light::successFg}, - {u"TransferList.ForcedDownloadingMetadata"_qs, Color::Primer::Light::successFg}, - {u"TransferList.ForcedDownloading"_qs, Color::Primer::Light::successFg}, - {u"TransferList.Uploading"_qs, Color::Primer::Light::accentFg}, - {u"TransferList.StalledUploading"_qs, Color::Primer::Light::accentEmphasis}, - {u"TransferList.ForcedUploading"_qs, Color::Primer::Light::accentFg}, - {u"TransferList.QueuedDownloading"_qs, Color::Primer::Light::scaleYellow6}, - {u"TransferList.QueuedUploading"_qs, Color::Primer::Light::scaleYellow6}, - {u"TransferList.CheckingDownloading"_qs, Color::Primer::Light::successFg}, - {u"TransferList.CheckingUploading"_qs, Color::Primer::Light::successFg}, - {u"TransferList.CheckingResumeData"_qs, Color::Primer::Light::successFg}, - {u"TransferList.PausedDownloading"_qs, Color::Primer::Light::fgMuted}, - {u"TransferList.PausedUploading"_qs, Color::Primer::Light::doneFg}, - {u"TransferList.Moving"_qs, Color::Primer::Light::successFg}, - {u"TransferList.MissingFiles"_qs, Color::Primer::Light::dangerFg}, - {u"TransferList.Error"_qs, Color::Primer::Light::dangerFg} - }; - - m_darkModeColors = { - {u"Log.TimeStamp"_qs, Color::Primer::Dark::fgSubtle}, - {u"Log.Normal"_qs, QApplication::palette().color(QPalette::Active, QPalette::WindowText)}, - {u"Log.Info"_qs, Color::Primer::Dark::accentFg}, - {u"Log.Warning"_qs, Color::Primer::Dark::severeFg}, - {u"Log.Critical"_qs, Color::Primer::Dark::dangerFg}, - {u"Log.BannedPeer"_qs, Color::Primer::Dark::dangerFg}, - - {u"RSS.ReadArticle"_qs, QApplication::palette().color(QPalette::Inactive, QPalette::WindowText)}, - {u"RSS.UnreadArticle"_qs, QApplication::palette().color(QPalette::Active, QPalette::Link)}, - - {u"TransferList.Downloading"_qs, Color::Primer::Dark::successFg}, - {u"TransferList.StalledDownloading"_qs, Color::Primer::Dark::successEmphasis}, - {u"TransferList.DownloadingMetadata"_qs, Color::Primer::Dark::successFg}, - {u"TransferList.ForcedDownloadingMetadata"_qs, Color::Primer::Dark::successFg}, - {u"TransferList.ForcedDownloading"_qs, Color::Primer::Dark::successFg}, - {u"TransferList.Uploading"_qs, Color::Primer::Dark::accentFg}, - {u"TransferList.StalledUploading"_qs, Color::Primer::Dark::accentEmphasis}, - {u"TransferList.ForcedUploading"_qs, Color::Primer::Dark::accentFg}, - {u"TransferList.QueuedDownloading"_qs, Color::Primer::Dark::scaleYellow6}, - {u"TransferList.QueuedUploading"_qs, Color::Primer::Dark::scaleYellow6}, - {u"TransferList.CheckingDownloading"_qs, Color::Primer::Dark::successFg}, - {u"TransferList.CheckingUploading"_qs, Color::Primer::Dark::successFg}, - {u"TransferList.CheckingResumeData"_qs, Color::Primer::Dark::successFg}, - {u"TransferList.PausedDownloading"_qs, Color::Primer::Dark::fgMuted}, - {u"TransferList.PausedUploading"_qs, Color::Primer::Dark::doneFg}, - {u"TransferList.Moving"_qs, Color::Primer::Dark::successFg}, - {u"TransferList.MissingFiles"_qs, Color::Primer::Dark::dangerFg}, - {u"TransferList.Error"_qs, Color::Primer::Dark::dangerFg} - }; - - const QByteArray configData = readFile(m_userPath / Path(CONFIG_FILE_NAME)); - if (configData.isEmpty()) - return; - - const QJsonObject config = parseThemeConfig(configData); - - auto colorOverrides = colorsFromJSON(config.value(u"colors").toObject()); - // Overriding Palette colors is not allowed in the default theme - Algorithm::removeIf(colorOverrides, [](const QString &colorId, [[maybe_unused]] const QColor &color) - { - return colorId.startsWith(u"Palette."); - }); - m_colors.insert(colorOverrides); - - auto darkModeColorOverrides = colorsFromJSON(config.value(u"colors.dark").toObject()); - // Overriding Palette colors is not allowed in the default theme - Algorithm::removeIf(darkModeColorOverrides, [](const QString &colorId, [[maybe_unused]] const QColor &color) - { - return colorId.startsWith(u"Palette."); - }); - m_darkModeColors.insert(darkModeColorOverrides); - } - - const Path m_defaultPath {u":"_qs}; - const Path m_userPath = specialFolderLocation(SpecialFolder::Config) / Path(u"themes/default"_qs); - QHash m_colors; - QHash m_darkModeColors; - }; - - class CustomThemeSource : public UIThemeSource - { - public: - QColor getColor(const QString &colorId, const ColorMode colorMode) const override - { - if (colorMode == ColorMode::Dark) - { - if (const QColor color = m_darkModeColors.value(colorId) - ; color.isValid()) - { - return color; - } - } - - if (const QColor color = m_colors.value(colorId) - ; color.isValid()) - { - return color; - } - - return defaultThemeSource()->getColor(colorId, colorMode); - } - - Path getIconPath(const QString &iconId, const ColorMode colorMode) const override - { - const Path iconsPath {u"icons"_qs}; - const Path darkModeIconsPath = iconsPath / Path(u"dark"_qs); - - if (colorMode == ColorMode::Dark) - { - if (const Path iconPath = findIcon(iconId, (themeRootPath() / darkModeIconsPath)) - ; !iconPath.isEmpty()) - { - return iconPath; - } - } - - if (const Path iconPath = findIcon(iconId, (themeRootPath() / iconsPath)) - ; !iconPath.isEmpty()) - { - return iconPath; - } - - return defaultThemeSource()->getIconPath(iconId, colorMode); - } - - QByteArray readStyleSheet() override - { - return readFile(themeRootPath() / Path(STYLESHEET_FILE_NAME)); - } - - protected: - virtual Path themeRootPath() const = 0; - - DefaultThemeSource *defaultThemeSource() const - { - return m_defaultThemeSource.get(); - } - - private: - void loadColors() - { - const QByteArray configData = readFile(themeRootPath() / Path(CONFIG_FILE_NAME)); - if (configData.isEmpty()) - return; - - const QJsonObject config = parseThemeConfig(configData); - - m_colors.insert(colorsFromJSON(config.value(u"colors").toObject())); - m_darkModeColors.insert(colorsFromJSON(config.value(u"colors.dark").toObject())); - } - - const std::unique_ptr m_defaultThemeSource = std::make_unique(); - QHash m_colors; - QHash m_darkModeColors; - }; - - class QRCThemeSource final : public CustomThemeSource - { - private: - Path themeRootPath() const override - { - return Path(u":/uitheme"_qs); - } - }; - - class FolderThemeSource : public CustomThemeSource - { - public: - explicit FolderThemeSource(const Path &folderPath) - : m_folder {folderPath} - { - } - - QByteArray readStyleSheet() override - { - // Directory used by stylesheet to reference internal resources - // for example `icon: url(:/uitheme/file.svg)` will be expected to - // point to a file `file.svg` in root directory of CONFIG_FILE_NAME - const QString stylesheetResourcesDir = u":/uitheme"_qs; - - QByteArray styleSheetData = CustomThemeSource::readStyleSheet(); - return styleSheetData.replace(stylesheetResourcesDir.toUtf8(), themeRootPath().data().toUtf8()); - } - - private: - Path themeRootPath() const override - { - return m_folder; - } - - const Path m_folder; - }; } UIThemeManager *UIThemeManager::m_instance = nullptr; diff --git a/src/gui/uithememanager.h b/src/gui/uithememanager.h index d84d7a55f..3e182769d 100644 --- a/src/gui/uithememanager.h +++ b/src/gui/uithememanager.h @@ -39,22 +39,7 @@ #include #include "base/pathfwd.h" - -enum class ColorMode -{ - Light, - Dark -}; - -class UIThemeSource -{ -public: - virtual ~UIThemeSource() = default; - - virtual QColor getColor(const QString &colorId, const ColorMode colorMode) const = 0; - virtual Path getIconPath(const QString &iconId, const ColorMode colorMode) const = 0; - virtual QByteArray readStyleSheet() = 0; -}; +#include "uithemesource.h" class UIThemeManager final : public QObject { diff --git a/src/gui/uithemesource.cpp b/src/gui/uithemesource.cpp new file mode 100644 index 000000000..04b41d3d9 --- /dev/null +++ b/src/gui/uithemesource.cpp @@ -0,0 +1,280 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2023 Vladimir Golovnev + * Copyright (C) 2019, 2021 Prince Gupta + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, + * USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If + * you modify file(s), you may extend this exception to your version of the + * file(s), but you are not obligated to do so. If you do not wish to do so, + * delete this exception statement from your version. + */ + +#include "uithemesource.h" + +#include +#include +#include + +#include "base/global.h" +#include "base/logger.h" +#include "base/profile.h" + +namespace +{ + QByteArray readFile(const Path &filePath) + { + QFile file {filePath.data()}; + if (!file.exists()) + return {}; + + if (file.open(QIODevice::ReadOnly | QIODevice::Text)) + return file.readAll(); + + LogMsg(UIThemeSource::tr("UITheme - Failed to open \"%1\". Reason: %2") + .arg(filePath.filename(), file.errorString()) + , Log::WARNING); + return {}; + } + + QJsonObject parseThemeConfig(const QByteArray &data) + { + if (data.isEmpty()) + return {}; + + QJsonParseError jsonError; + const QJsonDocument configJsonDoc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error != QJsonParseError::NoError) + { + LogMsg(UIThemeSource::tr("Couldn't parse UI Theme configuration file. Reason: %1") + .arg(jsonError.errorString()), Log::WARNING); + return {}; + } + + if (!configJsonDoc.isObject()) + { + LogMsg(UIThemeSource::tr("UI Theme configuration file has invalid format. Reason: %1") + .arg(UIThemeSource::tr("Root JSON value is not an object")), Log::WARNING); + return {}; + } + + return configJsonDoc.object(); + } + + QHash colorsFromJSON(const QJsonObject &jsonObj) + { + QHash colors; + for (auto colorNode = jsonObj.constBegin(); colorNode != jsonObj.constEnd(); ++colorNode) + { + const QColor color {colorNode.value().toString()}; + if (!color.isValid()) + { + LogMsg(UIThemeSource::tr("Invalid color for ID \"%1\" is provided by theme") + .arg(colorNode.key()), Log::WARNING); + continue; + } + + colors.insert(colorNode.key(), color); + } + + return colors; + } + + Path findIcon(const QString &iconId, const Path &dir) + { + const Path pathSvg = dir / Path(iconId + u".svg"); + if (pathSvg.exists()) + return pathSvg; + + const Path pathPng = dir / Path(iconId + u".png"); + if (pathPng.exists()) + return pathPng; + + return {}; + } +} + +DefaultThemeSource::DefaultThemeSource() + : m_defaultPath {u":"_qs} + , m_userPath {specialFolderLocation(SpecialFolder::Config) / Path(u"themes/default"_qs)} + , m_colors {defaultUIThemeColors()} +{ + loadColors(); +} + +QByteArray DefaultThemeSource::readStyleSheet() +{ + return {}; +} + +QColor DefaultThemeSource::getColor(const QString &colorId, const ColorMode colorMode) const +{ + return (colorMode == ColorMode::Light) + ? m_colors.value(colorId).light : m_colors.value(colorId).dark; +} + +Path DefaultThemeSource::getIconPath(const QString &iconId, const ColorMode colorMode) const +{ + const Path iconsPath {u"icons"_qs}; + const Path lightModeIconsPath = iconsPath / Path(u"light"_qs); + const Path darkModeIconsPath = iconsPath / Path(u"dark"_qs); + + if (colorMode == ColorMode::Dark) + { + if (const Path iconPath = findIcon(iconId, (m_userPath / darkModeIconsPath)) + ; !iconPath.isEmpty()) + { + return iconPath; + } + + if (const Path iconPath = findIcon(iconId, (m_defaultPath / darkModeIconsPath)) + ; !iconPath.isEmpty()) + { + return iconPath; + } + } + else + { + if (const Path iconPath = findIcon(iconId, (m_userPath / lightModeIconsPath)) + ; !iconPath.isEmpty()) + { + return iconPath; + } + } + + return findIcon(iconId, (m_defaultPath / iconsPath)); +} + +void DefaultThemeSource::loadColors() +{ + const QByteArray configData = readFile(m_userPath / Path(CONFIG_FILE_NAME)); + if (configData.isEmpty()) + return; + + const QJsonObject config = parseThemeConfig(configData); + + QHash lightModeColorOverrides = colorsFromJSON(config.value(KEY_COLORS_LIGHT).toObject()); + for (auto overridesIt = lightModeColorOverrides.cbegin(); overridesIt != lightModeColorOverrides.cend(); ++overridesIt) + { + auto it = m_colors.find(overridesIt.key()); + if (it != m_colors.end()) + it.value().light = overridesIt.value(); + } + + QHash darkModeColorOverrides = colorsFromJSON(config.value(KEY_COLORS_DARK).toObject()); + for (auto overridesIt = darkModeColorOverrides.cbegin(); overridesIt != darkModeColorOverrides.cend(); ++overridesIt) + { + auto it = m_colors.find(overridesIt.key()); + if (it != m_colors.end()) + it.value().dark = overridesIt.value(); + } +} + +QColor CustomThemeSource::getColor(const QString &colorId, const ColorMode colorMode) const +{ + if (colorMode == ColorMode::Dark) + { + if (const QColor color = m_darkModeColors.value(colorId) + ; color.isValid()) + { + return color; + } + } + + if (const QColor color = m_colors.value(colorId) + ; color.isValid()) + { + return color; + } + + return defaultThemeSource()->getColor(colorId, colorMode); +} + +Path CustomThemeSource::getIconPath(const QString &iconId, const ColorMode colorMode) const +{ + const Path iconsPath {u"icons"_qs}; + const Path darkModeIconsPath = iconsPath / Path(u"dark"_qs); + + if (colorMode == ColorMode::Dark) + { + if (const Path iconPath = findIcon(iconId, (themeRootPath() / darkModeIconsPath)) + ; !iconPath.isEmpty()) + { + return iconPath; + } + } + + if (const Path iconPath = findIcon(iconId, (themeRootPath() / iconsPath)) + ; !iconPath.isEmpty()) + { + return iconPath; + } + + return defaultThemeSource()->getIconPath(iconId, colorMode); +} + +QByteArray CustomThemeSource::readStyleSheet() +{ + return readFile(themeRootPath() / Path(STYLESHEET_FILE_NAME)); +} + +DefaultThemeSource *CustomThemeSource::defaultThemeSource() const +{ + return m_defaultThemeSource.get(); +} + +void CustomThemeSource::loadColors() +{ + const QByteArray configData = readFile(themeRootPath() / Path(CONFIG_FILE_NAME)); + if (configData.isEmpty()) + return; + + const QJsonObject config = parseThemeConfig(configData); + + m_colors.insert(colorsFromJSON(config.value(KEY_COLORS).toObject())); + m_darkModeColors.insert(colorsFromJSON(config.value(KEY_COLORS_DARK).toObject())); +} + +Path QRCThemeSource::themeRootPath() const +{ + return Path(u":/uitheme"_qs); +} + +FolderThemeSource::FolderThemeSource(const Path &folderPath) + : m_folder {folderPath} +{ +} + +QByteArray FolderThemeSource::readStyleSheet() +{ + // Directory used by stylesheet to reference internal resources + // for example `icon: url(:/uitheme/file.svg)` will be expected to + // point to a file `file.svg` in root directory of CONFIG_FILE_NAME + const QString stylesheetResourcesDir = u":/uitheme"_qs; + + QByteArray styleSheetData = CustomThemeSource::readStyleSheet(); + return styleSheetData.replace(stylesheetResourcesDir.toUtf8(), themeRootPath().data().toUtf8()); +} + +Path FolderThemeSource::themeRootPath() const +{ + return m_folder; +} diff --git a/src/gui/uithemesource.h b/src/gui/uithemesource.h new file mode 100644 index 000000000..bfd89546c --- /dev/null +++ b/src/gui/uithemesource.h @@ -0,0 +1,115 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2023 Vladimir Golovnev + * Copyright (C) 2019 Prince Gupta + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, + * USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If + * you modify file(s), you may extend this exception to your version of the + * file(s), but you are not obligated to do so. If you do not wish to do so, + * delete this exception statement from your version. + */ + +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include "base/path.h" +#include "uithemecommon.h" + +enum class ColorMode +{ + Light, + Dark +}; + +class UIThemeSource +{ + Q_DECLARE_TR_FUNCTIONS(UIThemeSource) + +public: + virtual ~UIThemeSource() = default; + + virtual QColor getColor(const QString &colorId, const ColorMode colorMode) const = 0; + virtual Path getIconPath(const QString &iconId, const ColorMode colorMode) const = 0; + virtual QByteArray readStyleSheet() = 0; +}; + +class DefaultThemeSource final : public UIThemeSource +{ +public: + DefaultThemeSource(); + + QByteArray readStyleSheet() override; + QColor getColor(const QString &colorId, const ColorMode colorMode) const override; + Path getIconPath(const QString &iconId, const ColorMode colorMode) const override; + +private: + void loadColors(); + + const Path m_defaultPath; + const Path m_userPath; + QHash m_colors; +}; + +class CustomThemeSource : public UIThemeSource +{ +public: + QColor getColor(const QString &colorId, const ColorMode colorMode) const override; + Path getIconPath(const QString &iconId, const ColorMode colorMode) const override; + QByteArray readStyleSheet() override; + +protected: + virtual Path themeRootPath() const = 0; + DefaultThemeSource *defaultThemeSource() const; + +private: + void loadColors(); + + const std::unique_ptr m_defaultThemeSource = std::make_unique(); + QHash m_colors; + QHash m_darkModeColors; +}; + +class QRCThemeSource final : public CustomThemeSource +{ +private: + Path themeRootPath() const override; +}; + +class FolderThemeSource : public CustomThemeSource +{ +public: + explicit FolderThemeSource(const Path &folderPath); + + QByteArray readStyleSheet() override; + +private: + Path themeRootPath() const override; + + const Path m_folder; +};