From 8d3f41059f51f4536060405d63e62ab806d9354f Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Sun, 6 Apr 2025 22:36:06 +0200 Subject: [PATCH] Gui: Add ThemeTokenManager class to contain theme parameters This class aims to implement Design Token idea into FreeCAD themes. It allows themes to use generic variables with generic values so we could use one qss theme and change the style based on values from preference packs. --- src/Base/Tools.h | 27 + src/Gui/Application.cpp | 81 +- src/Gui/Application.h | 9 +- src/Gui/CMakeLists.txt | 8 + src/Gui/CommandStd.cpp | 7 +- src/Gui/Dialogs/DlgThemeEditor.cpp | 764 ++++++++++++++++++ src/Gui/Dialogs/DlgThemeEditor.h | 152 ++++ src/Gui/Dialogs/DlgThemeEditor.ui | 156 ++++ src/Gui/OverlayManager.cpp | 4 +- src/Gui/PreCompiled.h | 2 + src/Gui/PreferencePackManager.cpp | 8 +- src/Gui/PreferencePages/DlgSettingsUI.cpp | 29 +- src/Gui/PreferencePages/DlgSettingsUI.h | 1 + src/Gui/PreferencePages/DlgSettingsUI.ui | 61 +- src/Gui/StyleParameters/ParameterManager.cpp | 353 ++++++++ src/Gui/StyleParameters/ParameterManager.h | 457 +++++++++++ src/Gui/StyleParameters/Parser.cpp | 403 +++++++++ src/Gui/StyleParameters/Parser.h | 170 ++++ src/Gui/Tools.h | 110 ++- tests/src/Gui/CMakeLists.txt | 3 + .../StyleParameters/ParameterManagerTest.cpp | 326 ++++++++ tests/src/Gui/StyleParameters/ParserTest.cpp | 617 ++++++++++++++ .../StyleParametersApplicationTest.cpp | 94 +++ 23 files changed, 3749 insertions(+), 93 deletions(-) create mode 100644 src/Gui/Dialogs/DlgThemeEditor.cpp create mode 100644 src/Gui/Dialogs/DlgThemeEditor.h create mode 100644 src/Gui/Dialogs/DlgThemeEditor.ui create mode 100644 src/Gui/StyleParameters/ParameterManager.cpp create mode 100644 src/Gui/StyleParameters/ParameterManager.h create mode 100644 src/Gui/StyleParameters/Parser.cpp create mode 100644 src/Gui/StyleParameters/Parser.h create mode 100644 tests/src/Gui/StyleParameters/ParameterManagerTest.cpp create mode 100644 tests/src/Gui/StyleParameters/ParserTest.cpp create mode 100644 tests/src/Gui/StyleParameters/StyleParametersApplicationTest.cpp diff --git a/src/Base/Tools.h b/src/Base/Tools.h index 88982c0ae6..404cdae6ba 100644 --- a/src/Base/Tools.h +++ b/src/Base/Tools.h @@ -356,6 +356,33 @@ struct BaseExport ZipTools }; +/** + * Helper struct to define inline overloads for the visitor pattern in std::visit. + * + * It uses type deduction to infer the type from the expression and creates a dedicated type that + * essentially is callable using any overload supplied. + * + * @code + * using Base::Overloads; + * + * const auto visitor = Overloads + * { + * [](int i){ std::print("int = {}\n", i); }, + * [](std::string_view s){ std::println("string = “{}”", s); }, + * [](const Base&){ std::println("base"); }, + * }; + * @endcode + * + * @see https://en.cppreference.com/w/cpp/utility/variant/visit + * + * @tparam Ts Types for functions that will be used for overloads + */ +template +struct Overloads: Ts... +{ + using Ts::operator()...; +}; + } // namespace Base #endif // SRC_BASE_TOOLS_H_ diff --git a/src/Gui/Application.cpp b/src/Gui/Application.cpp index 2f7466b13f..2cd30d8b8a 100644 --- a/src/Gui/Application.cpp +++ b/src/Gui/Application.cpp @@ -137,9 +137,11 @@ #include "WorkbenchManipulator.h" #include "WidgetFactory.h" #include "3Dconnexion/navlib/NavlibInterface.h" +#include "Inventor/SoFCPlacementIndicatorKit.h" #include "QtWidgets.h" -#include +#include +#include #ifdef BUILD_TRACY_FRAME_PROFILER #include @@ -208,6 +210,8 @@ struct ApplicationP // Create the Theme Manager prefPackManager = new PreferencePackManager(); + // Create the Style Parameter Manager + styleParameterManager = new StyleParameters::ParameterManager(); } ~ApplicationP() @@ -221,8 +225,11 @@ struct ApplicationP /// Active document Gui::Document* activeDocument {nullptr}; Gui::Document* editDocument {nullptr}; + MacroManager* macroMngr; PreferencePackManager* prefPackManager; + StyleParameters::ParameterManager* styleParameterManager; + /// List of all registered views std::list passive; bool isClosing {false}; @@ -372,6 +379,31 @@ struct PyMethodDef FreeCADGui_methods[] = { } // namespace Gui +void Application::initStyleParameterManager() +{ + Base::registerServiceImplementation( + new StyleParameters::BuiltInParameterSource({.name = QT_TR_NOOP("Built-in Parameters")})); + + Base::registerServiceImplementation( + new StyleParameters::UserParameterSource( + App::GetApplication().GetParameterGroupByPath( + "User parameter:BaseApp/Preferences/Themes/Tokens"), + {.name = QT_TR_NOOP("Theme Parameters"), + .options = StyleParameters::ParameterSourceOption::UserEditable})); + + Base::registerServiceImplementation( + new StyleParameters::UserParameterSource( + App::GetApplication().GetParameterGroupByPath( + "User parameter:BaseApp/Preferences/Themes/UserTokens"), + {.name = QT_TR_NOOP("User Parameters"), + .options = StyleParameters::ParameterSource::UserEditable})); + + for (auto* source : Base::provideServiceImplementations()) { + d->styleParameterManager->addSource(source); + } + + Base::registerServiceImplementation(d->styleParameterManager); +} // clang-format off Application::Application(bool GUIenabled) { @@ -576,6 +608,8 @@ Application::Application(bool GUIenabled) d = new ApplicationP(GUIenabled); + initStyleParameterManager(); + // global access Instance = this; @@ -1955,6 +1989,11 @@ Gui::PreferencePackManager* Application::prefPackManager() return d->prefPackManager; } +Gui::StyleParameters::ParameterManager* Application::styleParameterManager() +{ + return d->styleParameterManager; +} + //************************************************************************** // Init, Destruct and singleton @@ -2314,7 +2353,7 @@ void Application::runApplication() setenv("COIN_EGL", "1", 1); } #endif - + // Make sure that we use '.' as decimal point. See also // http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=559846 // and issue #0002891 @@ -2498,36 +2537,22 @@ void Application::setStyleSheet(const QString& qssFile, bool tiledBackground) } } -QString Application::replaceVariablesInQss(QString qssText) +void Application::reloadStyleSheet() { - // First we fetch the colors from preferences, - ParameterGrp::handle hGrp = - App::GetApplication().GetParameterGroupByPath("User parameter:BaseApp/Preferences/Themes"); - unsigned long longAccentColor1 = hGrp->GetUnsigned("ThemeAccentColor1", 0); - unsigned long longAccentColor2 = hGrp->GetUnsigned("ThemeAccentColor2", 0); - unsigned long longAccentColor3 = hGrp->GetUnsigned("ThemeAccentColor3", 0); + const MainWindow* mw = getMainWindow(); - // convert them to hex. - // Note: the ulong contains alpha channels so 8 hex characters when we need 6 here. - QString accentColor1 = QStringLiteral("#%1") - .arg(longAccentColor1, 8, 16, QLatin1Char('0')) - .toUpper() - .mid(0, 7); - QString accentColor2 = QStringLiteral("#%1") - .arg(longAccentColor2, 8, 16, QLatin1Char('0')) - .toUpper() - .mid(0, 7); - QString accentColor3 = QStringLiteral("#%1") - .arg(longAccentColor3, 8, 16, QLatin1Char('0')) - .toUpper() - .mid(0, 7); + const QString qssFile = mw->property("fc_currentStyleSheet").toString(); + const bool tiledBackground = mw->property("fc_tiledBackground").toBool(); - qssText = qssText.replace(QStringLiteral("@ThemeAccentColor1"), accentColor1); - qssText = qssText.replace(QStringLiteral("@ThemeAccentColor2"), accentColor2); - qssText = qssText.replace(QStringLiteral("@ThemeAccentColor3"), accentColor3); + d->styleParameterManager->reload(); - // Base::Console().warning("%s\n", qssText.toStdString()); - return qssText; + setStyleSheet(qssFile, tiledBackground); + OverlayManager::instance()->refresh(nullptr, true); +} + +QString Application::replaceVariablesInQss(const QString& qssText) +{ + return QString::fromStdString(d->styleParameterManager->replacePlaceholders(qssText.toStdString())); } void Application::checkForDeprecatedSettings() diff --git a/src/Gui/Application.h b/src/Gui/Application.h index 596fc32dae..45af443579 100644 --- a/src/Gui/Application.h +++ b/src/Gui/Application.h @@ -30,6 +30,8 @@ #include +#include "StyleParameters/ParameterManager.h" + class QCloseEvent; class SoNode; class NavlibInterface; @@ -63,6 +65,9 @@ public: /// destruction ~Application(); + /// Initializes default configuration for Style Parameter Manager + void initStyleParameterManager(); + /** @name methods for support of files */ //@{ /// open a file @@ -221,7 +226,8 @@ public: //@{ /// Activate a stylesheet void setStyleSheet(const QString& qssFile, bool tiledBackground); - QString replaceVariablesInQss(QString qssText); + void reloadStyleSheet(); + QString replaceVariablesInQss(const QString& qssText); //@} /** @name User Commands */ @@ -235,6 +241,7 @@ public: //@} Gui::PreferencePackManager* prefPackManager(); + Gui::StyleParameters::ParameterManager* styleParameterManager(); /** @name Init, Destruct an Access methods */ //@{ diff --git a/src/Gui/CMakeLists.txt b/src/Gui/CMakeLists.txt index ab93d16cac..e4979d9d51 100644 --- a/src/Gui/CMakeLists.txt +++ b/src/Gui/CMakeLists.txt @@ -55,6 +55,7 @@ include_directories( ${CMAKE_CURRENT_SOURCE_DIR}/Quarter ${CMAKE_CURRENT_SOURCE_DIR}/PreferencePages ${CMAKE_CURRENT_SOURCE_DIR}/Selection + ${CMAKE_CURRENT_SOURCE_DIR}/StyleParameters ${CMAKE_CURRENT_BINARY_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/.. ${CMAKE_CURRENT_BINARY_DIR}/.. @@ -401,6 +402,7 @@ SET(Gui_UIC_SRCS Dialogs/DlgProjectUtility.ui Dialogs/DlgPropertyLink.ui Dialogs/DlgRevertToBackupConfig.ui + Dialogs/DlgThemeEditor.ui PreferencePages/DlgSettings3DView.ui PreferencePages/DlgSettingsCacheDirectory.ui Dialogs/DlgSettingsColorGradient.ui @@ -516,6 +518,7 @@ SET(Dialog_CPP_SRCS Dialogs/DlgPropertyLink.cpp Dialogs/DlgRevertToBackupConfigImp.cpp Dialogs/DlgExpressionInput.cpp + Dialogs/DlgThemeEditor.cpp TaskDlgRelocation.cpp Dialogs/DlgCheckableMessageBox.cpp TaskTransform.cpp @@ -558,6 +561,7 @@ SET(Dialog_HPP_SRCS Dialogs/DlgRevertToBackupConfigImp.h Dialogs/DlgCheckableMessageBox.h Dialogs/DlgExpressionInput.h + Dialogs/DlgThemeEditor.h TaskDlgRelocation.h TaskTransform.h Dialogs/DlgUndoRedo.h @@ -1326,6 +1330,8 @@ SET(FreeCADGui_CPP_SRCS StartupProcess.cpp TransactionObject.cpp ToolHandler.cpp + StyleParameters/Parser.cpp + StyleParameters/ParameterManager.cpp ) SET(FreeCADGui_SRCS Application.h @@ -1368,6 +1374,8 @@ SET(FreeCADGui_SRCS StartupProcess.h TransactionObject.h ToolHandler.h + StyleParameters/Parser.h + StyleParameters/ParameterManager.h ) SET(FreeCADGui_SRCS diff --git a/src/Gui/CommandStd.cpp b/src/Gui/CommandStd.cpp index 2837ccd190..d6e0087af7 100644 --- a/src/Gui/CommandStd.cpp +++ b/src/Gui/CommandStd.cpp @@ -964,12 +964,7 @@ StdCmdReloadStyleSheet::StdCmdReloadStyleSheet() void StdCmdReloadStyleSheet::activated(int ) { - auto mw = getMainWindow(); - - auto qssFile = mw->property("fc_currentStyleSheet").toString(); - auto tiledBackground = mw->property("fc_tiledBackground").toBool(); - - Gui::Application::Instance->setStyleSheet(qssFile, tiledBackground); + Application::Instance->reloadStyleSheet(); } namespace Gui { diff --git a/src/Gui/Dialogs/DlgThemeEditor.cpp b/src/Gui/Dialogs/DlgThemeEditor.cpp new file mode 100644 index 0000000000..eeabf70581 --- /dev/null +++ b/src/Gui/Dialogs/DlgThemeEditor.cpp @@ -0,0 +1,764 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kacper Donat * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD 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 * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#include "PreCompiled.h" + +#include "Tools.h" +#include "Application.h" +#include "OverlayManager.h" +#include "DlgThemeEditor.h" +#include "ui_DlgThemeEditor.h" +#include "BitmapFactory.h" + +#include +#include + +#ifndef _PreComp_ +# include +# include +# include +# include +# include +#endif + +QPixmap colorPreview(const QColor& color) +{ + constexpr qsizetype size = 16; + + QPixmap preview = Gui::BitmapFactory().empty({ size, size }); + + QPainter painter(&preview); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + painter.setBrush(color); + painter.drawEllipse(QRect { 0, 0, size, size }); + + return preview; +} + +QString typeOfTokenValue(const Gui::StyleParameters::Value& value) +{ + // clang-format off + return std::visit( + Base::Overloads { + [](const std::string&) { + return QWidget::tr("Generic"); + }, + [](const Gui::StyleParameters::Length&) { + return QWidget::tr("Length"); + }, + [](const QColor&) { + return QWidget::tr("Color"); + } + }, + value + ); + // clang-format on +} + +namespace Gui +{ +struct StyleParametersModel::Item +{ + Item() = default; + virtual ~Item() = default; + + FC_DEFAULT_COPY_MOVE(Item); + + virtual bool isHeader() const = 0; +}; + +struct StyleParametersModel::GroupItem: Item +{ + explicit GroupItem(QString title, ParameterSource* source) + : title(std::move(title)) + , canAddNewParameters(source && source->metadata.options.testFlag(UserEditable)) + , source(source) + {} + + bool isHeader() const override + { + return true; + } + + QString title; + bool canAddNewParameters {false}; + ParameterSource* source; + std::set deleted {}; +}; + +struct StyleParametersModel::ParameterItem: Item +{ + ParameterItem(QString name, StyleParameters::Parameter token) + : name(std::move(name)) + , token(std::move(token)) + {} + + bool isHeader() const override + { + return false; + } + + QString name; + StyleParameters::Parameter token; + QFlags flags = Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable; +}; + +class StyleParametersModel::Node +{ +public: + explicit Node(std::unique_ptr data, Node* parent = nullptr) + : _parent(parent) + , _data(std::move(data)) + {} + + void appendChild(std::unique_ptr child) + { + child->_parent = this; + _children.push_back(std::move(child)); + } + + void removeChild(const int row) + { + if (row >= 0 && row < static_cast(_children.size())) { + _children.erase(_children.begin() + row); + } + } + + Node* child(const int row) const + { + if (row < 0 || row >= static_cast(_children.size())) { + if (!_empty) { + _empty = std::make_unique(nullptr, const_cast(this)); + } + + return _empty.get(); + } + + return _children[row].get(); + } + + int childCount() const + { + return static_cast(_children.size()); + } + + int row() const + { + if (!_parent) { + return 0; + } + + const auto& siblings = _parent->_children; + for (size_t i = 0; i < siblings.size(); ++i) { + if (siblings[i].get() == this) { + return static_cast(i); + } + } + + return -1; + } + + Item* data() const + { + return _data.get(); + } + + template + T* data() const + { + return dynamic_cast(_data.get()); + } + + Node* parent() const + { + return _parent; + } + +private: + Node* _parent; + std::vector> _children {}; + + mutable std::unique_ptr _empty {}; + std::unique_ptr _data {}; +}; + +class DlgThemeEditor::Delegate: public QStyledItemDelegate +{ + Q_OBJECT + + QRegularExpression validNameRegExp { QStringLiteral("^[A-Z][a-zA-Z0-9]*$") }; + QRegularExpressionValidator* nameValidator; + +public: + explicit Delegate(QObject* parent = nullptr) + : QStyledItemDelegate(parent) + , nameValidator(new QRegularExpressionValidator(validNameRegExp, this)) + {} + + QWidget* createEditor(QWidget* parent, + [[maybe_unused]] const QStyleOptionViewItem& option, + const QModelIndex& index) const override + { + auto model = dynamic_cast(index.model()); + if (!model) { + return nullptr; + } + + if (model->item(index) + && index.column() == StyleParametersModel::ParameterExpression) { + return new QLineEdit(parent); + } + + if (index.column() == StyleParametersModel::ParameterName) { + auto editor = new QLineEdit(parent); + editor->setValidator(nameValidator); + return editor; + } + + return nullptr; + } + + void setEditorData(QWidget* editor, const QModelIndex& index) const override + { + if (auto* lineEdit = qobject_cast(editor)) { + lineEdit->setText(index.data(Qt::DisplayRole).toString()); + } + } + + void setModelData(QWidget* editor, + QAbstractItemModel* model, + const QModelIndex& index) const override + { + if (auto* lineEdit = qobject_cast(editor)) { + model->setData(index, lineEdit->text(), Qt::EditRole); + } + } + + void updateEditorGeometry(QWidget* editor, + const QStyleOptionViewItem& option, + [[maybe_unused]] const QModelIndex& index) const override + { + editor->setGeometry(option.rect); + } + + QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override + { + constexpr int height = 36; + + QSize base = QStyledItemDelegate::sizeHint(option, index); + return {base.width(), std::max(base.height(), height)}; + } + + void paintAddPlaceholder(QPainter* painter, const QStyleOptionViewItem& option) const + { + QStyle* style = option.widget ? option.widget->style() : QApplication::style(); + QRect rect = style->subElementRect(QStyle::SE_ItemViewItemText, &option, option.widget); + + QFont font = option.font; + font.setItalic(true); + + painter->setFont(font); + painter->drawText(rect, Qt::AlignLeft | Qt::AlignVCenter, tr("New parameter...")); + } + + void paint(QPainter* painter, + const QStyleOptionViewItem& option, + const QModelIndex& index) const override + { + auto model = dynamic_cast(index.model()); + + painter->save(); + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + + if (model->isAddPlaceholder(index)) { + if (index.column() == StyleParametersModel::ParameterName) { + paintAddPlaceholder(painter, opt); + } + } + else if (model->item(index)) { + constexpr int headerContrast = 120; + + const bool isLightTheme = option.palette.color(QPalette::Text).lightness() < 128; + + const QColor headerBackgroundColor = QtTools::valueOr( + option.widget->property("headerBackgroundColor"), + isLightTheme + ? option.palette.color(QPalette::AlternateBase).darker(headerContrast) + : option.palette.color(QPalette::AlternateBase).lighter(headerContrast) + ); + + painter->fillRect(option.rect, headerBackgroundColor); + QStyledItemDelegate::paint(painter, option, index); + } + else { + QStyledItemDelegate::paint(painter, option, index); + } + + painter->restore(); + } +}; + +void TokenTreeView::keyPressEvent(QKeyEvent* event) +{ + static constexpr auto expressionEditKeys = { Qt::Key_Return, Qt::Key_Enter, Qt::Key_Space }; + static constexpr auto nameEditKeys = { Qt::Key_F2 }; + static constexpr auto deleteKeys = { Qt::Key_Delete }; + + const auto isCorrectKey = [&event](auto key) { return event->key() == key; }; + + if (QModelIndex index = currentIndex(); index.isValid()) { + if (std::ranges::any_of(expressionEditKeys, isCorrectKey)) { + edit(index.siblingAtColumn(StyleParametersModel::ParameterExpression)); + return; + } + + if (std::ranges::any_of(nameEditKeys, isCorrectKey)) { + edit(index.siblingAtColumn(StyleParametersModel::ParameterName)); + return; + } + + if (std::ranges::any_of(deleteKeys, isCorrectKey)) { + requestRemove(currentIndex()); + return; + } + } + + QTreeView::keyPressEvent(event); +} + +StyleParametersModel::StyleParametersModel( + const std::list& sources, + QObject* parent) + : QAbstractItemModel(parent) + , ParameterSource({ .name = QT_TR_NOOP("All Theme Editor Parameters") }) + , sources(sources) + , manager(new StyleParameters::ParameterManager()) +{ + // The parameter model serves as the source, so the manager can compute all necessary things + manager->addSource(this); + + reset(); +} + +StyleParametersModel::~StyleParametersModel() = default; + +std::list StyleParametersModel::all() const +{ + std::map result; + + QtTools::walkTreeModel(this, [this, &result](const QModelIndex& index) { + if (auto parameterItem = item(index)) { + if (result.contains(parameterItem->token.name)) { + return; + } + + result[parameterItem->token.name] = parameterItem->token; + } + }); + + const auto values = result | std::ranges::views::values; + return std::list(values.begin(), values.end()); +} + +std::optional StyleParametersModel::get(const std::string& name) const +{ + std::optional result = std::nullopt; + + QtTools::walkTreeModel(this, [this, &name, &result](const QModelIndex& index) { + if (auto parameterItem = item(index)) { + if (parameterItem->token.name == name) { + result = parameterItem->token; + return true; + } + } + + return false; + }); + + return result; +} + +void StyleParametersModel::removeItem(const QModelIndex& index) +{ + if (auto parameterItem = item(index)) { + auto groupItem = item(index.parent()); + + if (!groupItem->source->metadata.options.testFlag(UserEditable)) { + return; + } + + groupItem->deleted.insert(parameterItem->token.name); + + beginRemoveRows(index.parent(), index.row(), index.row()); + node(index.parent())->removeChild(index.row()); + endRemoveRows(); + } +} + +void StyleParametersModel::reset() +{ + using enum StyleParameters::ParameterSourceOption; + + beginResetModel(); + root = std::make_unique(std::make_unique(tr("Root"), nullptr)); + + for (auto* source : sources) { + auto groupNode = std::make_unique( + std::make_unique(tr(source->metadata.name.c_str()), source)); + + for (const auto& parameter : source->all()) { + auto item = std::make_unique( + std::make_unique(QString::fromStdString(parameter.name), parameter)); + + if (source->metadata.options.testFlag(ReadOnly)) { + item->data()->flags = Qt::ItemIsEnabled | Qt::ItemIsSelectable; + } + + groupNode->appendChild(std::move(item)); + } + + root->appendChild(std::move(groupNode)); + } + + endResetModel(); +} + +void StyleParametersModel::flush() +{ + QtTools::walkTreeModel(this, [this](const QModelIndex& index) { + if (const auto& groupItem = item(index)) { + for (const auto& parameter : groupItem->deleted) { + groupItem->source->remove(parameter); + } + + groupItem->deleted.clear(); + } + + if (const auto& parameterItem = item(index)) { + const auto& groupItem = item(index.parent()); + + groupItem->source->define(parameterItem->token); + } + }); + + reset(); +} + +int StyleParametersModel::rowCount(const QModelIndex& index) const +{ + if (index.column() > 0) { + return 0; + } + + int childCount = node(index)->childCount(); + + if (const auto& groupItem = item(index)) { + return childCount + (groupItem->canAddNewParameters ? 1 : 0); + } + + return childCount; +} + +int StyleParametersModel::columnCount([[maybe_unused]] const QModelIndex& index) const +{ + return ColumnCount; +} + +QVariant StyleParametersModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation == Qt::Horizontal && role == Qt::DisplayRole) { + switch (section) { + case ParameterName: + return tr("Name"); + case ParameterExpression: + return tr("Expression"); + case ParameterPreview: + return tr("Preview"); + case ParameterType: + return tr("Type"); + default: + return {}; + } + } + + return {}; +} + +QVariant StyleParametersModel::data(const QModelIndex& index, int role) const +{ + if (auto parameterItem = item(index)) { + const auto& [name, token, _] = *parameterItem; + const auto& value = manager->resolve(name.toStdString()); + + if (role == Qt::DisplayRole) { + if (index.column() == ParameterName) { + return name; + } + if (index.column() == ParameterExpression) { + return QString::fromStdString(token.value); + } + if (index.column() == ParameterType) { + return typeOfTokenValue(value); + } + if (index.column() == ParameterPreview) { + return QString::fromStdString(value.toString()); + } + } + + if (role == Qt::DecorationRole) { + if (index.column() == ParameterPreview && std::holds_alternative(value)) { + return colorPreview(std::get(value)); + } + } + } + + if (auto groupItem = item(index)) { + if (role == Qt::DisplayRole && index.column() == ParameterName) { + return groupItem->title; + } + } + + return {}; +} + +bool StyleParametersModel::setData(const QModelIndex& index, + const QVariant& value, + [[maybe_unused]] int role) +{ + if (auto parameterItem = item(index)) { + auto groupItem = item(index.parent()); + + if (index.column() == ParameterName) { + QString newName = value.toString(); + + StyleParameters::Parameter newToken = parameterItem->token; + newToken.name = newName.toStdString(); + + // there is no rename operation, so we need to mark the previous token as deleted + groupItem->deleted.insert(parameterItem->token.name); + + parameterItem->name = newName; + parameterItem->token = newToken; + } + + if (index.column() == ParameterExpression) { + QString newValue = value.toString(); + + StyleParameters::Parameter newToken = parameterItem->token; + newToken.value = newValue.toStdString(); + + parameterItem->token = newToken; + } + } + + if (isAddPlaceholder(index)) { + if (index.column() == ParameterName) { + QString newName = value.toString(); + + if (newName.isEmpty()) { + return false; + } + + StyleParameters::Parameter token { .name = newName.toStdString(), .value = "" }; + + int start = rowCount(index.parent()); + + beginInsertRows(index.parent(), start, start + 1); + auto item = std::make_unique( + std::make_unique(newName, token)); + node(index.parent())->appendChild(std::move(item)); + endInsertRows(); + + // this must be queued to basically next frame so widget has a chance to update + QTimer::singleShot(0, [this, index]() { + this->newParameterAdded(index); + }); + } + } + + this->manager->reload(); + + QtTools::walkTreeModel(this, [this](const QModelIndex& index) { + const QModelIndex previewColumnIndex = index.siblingAtColumn(ParameterPreview); + + Q_EMIT dataChanged(previewColumnIndex, previewColumnIndex); + }); + + return true; +} + +Qt::ItemFlags StyleParametersModel::flags(const QModelIndex& index) const +{ + if (auto parameterItem = item(index)) { + if (index.column() == ParameterName || index.column() == ParameterExpression) { + return parameterItem->flags | QAbstractItemModel::flags(index); + } + } + + if (isAddPlaceholder(index)) { + if (index.column() == ParameterName) { + return Qt::ItemIsEnabled | Qt::ItemIsEditable | QAbstractItemModel::flags(index); + } + } + + return QAbstractItemModel::flags(index); +} + +QModelIndex StyleParametersModel::index(int row, int col, const QModelIndex& parent) const +{ + if (!hasIndex(row, col, parent)) { + return {}; + } + + if (auto child = node(parent)->child(row)) { + return createIndex(row, col, child); + } + + return {}; +} + +QModelIndex StyleParametersModel::parent(const QModelIndex& index) const +{ + if (!index.isValid()) { + return {}; + } + + auto node = static_cast(index.internalPointer()); + auto parent = node->parent(); + + if (!parent || parent == root.get()) { + return {}; + } + + return createIndex(parent->row(), 0, parent); +} + +bool StyleParametersModel::isAddPlaceholder(const QModelIndex& index) const +{ + return item(index) == nullptr; +} + +StyleParametersModel::Node* StyleParametersModel::node(const QModelIndex& index) const +{ + return index.isValid() ? static_cast(index.internalPointer()) : root.get(); +} + +StyleParametersModel::Item* StyleParametersModel::item(const QModelIndex& index) const +{ + return node(index)->data(); +} + +DlgThemeEditor::DlgThemeEditor(QWidget* parent) + : QDialog(parent) + , ui(new Ui::DlgThemeEditor) + , model(std::make_unique( + Base::provideServiceImplementations(), + this)) +{ + ui->setupUi(this); + + ui->tokensTreeView->setMouseTracking(true); + ui->tokensTreeView->setItemDelegate(new Delegate(ui->tokensTreeView)); + ui->tokensTreeView->setModel(model.get()); + + constexpr int typeColumnWidth = 80; + constexpr int nameColumnWidth = 200; + + struct ColumnDefinition // NOLINT(*-pro-type-member-init) + { + StyleParametersModel::Column column; + QHeaderView::ResizeMode mode; + qsizetype defaultWidth = 0; + }; + + static constexpr std::initializer_list columnSizingDefinitions = { + {StyleParametersModel::ParameterName, QHeaderView::ResizeMode::ResizeToContents}, + {StyleParametersModel::ParameterExpression, QHeaderView::ResizeMode::Stretch}, + {StyleParametersModel::ParameterPreview, QHeaderView::ResizeMode::Stretch}, + {StyleParametersModel::ParameterType, QHeaderView::ResizeMode::Fixed, typeColumnWidth}, + }; + + for (const auto& [column, mode, defaultWidth] : columnSizingDefinitions) { + ui->tokensTreeView->header()->setSectionResizeMode(column, mode); + + if (defaultWidth > 0) { + ui->tokensTreeView->header()->setDefaultSectionSize(defaultWidth); + } + } + + ui->tokensTreeView->setColumnWidth(StyleParametersModel::ParameterName, nameColumnWidth); + ui->tokensTreeView->expandAll(); + + connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + connect(ui->buttonBox, &QDialogButtonBox::clicked, this, &DlgThemeEditor::handleButtonClick); + + connect(ui->tokensTreeView, + &TokenTreeView::requestRemove, + model.get(), + qOverload(&StyleParametersModel::removeItem)); + + connect(model.get(), &StyleParametersModel::modelReset, ui->tokensTreeView, [this] { + ui->tokensTreeView->expandAll(); + }); + connect(model.get(), + &StyleParametersModel::newParameterAdded, + this, + [this](const QModelIndex& index) { + const auto newParameterExpressionIndex = + index.siblingAtColumn(StyleParametersModel::ParameterExpression); + + ui->tokensTreeView->scrollTo(newParameterExpressionIndex); + ui->tokensTreeView->setCurrentIndex(newParameterExpressionIndex); + ui->tokensTreeView->edit(newParameterExpressionIndex); + }); +} + +DlgThemeEditor::~DlgThemeEditor() = default; + +void DlgThemeEditor::handleButtonClick(QAbstractButton* button) +{ + auto role = ui->buttonBox->buttonRole(button); + + switch (role) { + case QDialogButtonBox::ApplyRole: + case QDialogButtonBox::AcceptRole: + model->flush(); + Application::Instance->reloadStyleSheet(); + break; + case QDialogButtonBox::ResetRole: + model->reset(); + break; + default: + // no-op + break; + } +} + +} // namespace Gui + +#include "DlgThemeEditor.moc" diff --git a/src/Gui/Dialogs/DlgThemeEditor.h b/src/Gui/Dialogs/DlgThemeEditor.h new file mode 100644 index 0000000000..6964742c6f --- /dev/null +++ b/src/Gui/Dialogs/DlgThemeEditor.h @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kacper Donat * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD 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 * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#ifndef DLGTHEMEEDITOR_H +#define DLGTHEMEEDITOR_H + + +#include "StyleParameters/ParameterManager.h" + +#include +#include +#include + +QT_BEGIN_NAMESPACE +class QAbstractButton; +QT_END_NAMESPACE + +namespace Gui { + +namespace StyleParameters +{ +class ParameterManager; +} + +QT_BEGIN_NAMESPACE +namespace Ui { class DlgThemeEditor; } +QT_END_NAMESPACE + +class GuiExport TokenTreeView : public QTreeView +{ + Q_OBJECT +public: + using QTreeView::QTreeView; + +protected: + void keyPressEvent(QKeyEvent* event) override; + +Q_SIGNALS: + void requestRemove(const QModelIndex& index); +}; + +class GuiExport StyleParametersModel: public QAbstractItemModel, public StyleParameters::ParameterSource +{ + Q_OBJECT + + class Node; + +public: + struct Item; + struct GroupItem; + struct ParameterItem; + + enum Column : std::uint8_t + { + ParameterName, + ParameterExpression, + ParameterType, + ParameterPreview, + ColumnCount + }; + + FC_DISABLE_COPY_MOVE(StyleParametersModel); + + explicit StyleParametersModel(const std::list& sources, + QObject* parent = nullptr); + + ~StyleParametersModel() override; + + std::list all() const override; + std::optional get(const std::string& name) const override; + + void reset(); + void flush(); + + int rowCount(const QModelIndex& index) const override; + int columnCount(const QModelIndex& index) const override; + + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + QVariant data(const QModelIndex& index, int role) const override; + bool setData(const QModelIndex& index, const QVariant& value, int role) override; + + Qt::ItemFlags flags(const QModelIndex& index) const override; + + QModelIndex index(int row, int col, const QModelIndex& parent) const override; + QModelIndex parent(const QModelIndex& index) const override; + + Node* node(const QModelIndex& index) const; + Item* item(const QModelIndex& index) const; + + template + T* item(const QModelIndex& index) const + { + return dynamic_cast(item(index)); + } + + bool isAddPlaceholder(const QModelIndex& index) const; + +public Q_SLOTS: + void removeItem(const QModelIndex& index); + +Q_SIGNALS: + void newParameterAdded(const QModelIndex& index); + +private: + std::list sources; + std::unique_ptr manager; + std::unique_ptr root; +}; + +class GuiExport DlgThemeEditor : public QDialog { + Q_OBJECT + + class Delegate; + +public: + FC_DISABLE_COPY_MOVE(DlgThemeEditor); + + explicit DlgThemeEditor(QWidget *parent = nullptr); + + ~DlgThemeEditor() override; + +public Q_SLOTS: + void handleButtonClick(QAbstractButton* button); + +private: + std::unique_ptr ui; + std::unique_ptr manager; + std::unique_ptr model; +}; +} // Gui + +#endif //DLGTHEMEEDITOR_H diff --git a/src/Gui/Dialogs/DlgThemeEditor.ui b/src/Gui/Dialogs/DlgThemeEditor.ui new file mode 100644 index 0000000000..dfc7802cf0 --- /dev/null +++ b/src/Gui/Dialogs/DlgThemeEditor.ui @@ -0,0 +1,156 @@ + + + Gui::DlgThemeEditor + + + + 0 + 0 + 1169 + 562 + + + + Theme Editor + + + + + + Preview + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter + + + + + + CheckBox + + + + + + + Qt::Orientation::Horizontal + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + RadioButton + + + + + + + + Item 1 + + + + + Item 2 + + + + + + + + PushButton + + + + + + + + Tab 1 + + + + + Tab 2 + + + + + + + + + + + + + + + + QDialogButtonBox::StandardButton::Apply|QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok|QDialogButtonBox::StandardButton::Reset + + + + + + + + + true + + + Qt::ScrollBarPolicy::ScrollBarAlwaysOff + + + QAbstractItemView::EditTrigger::DoubleClicked + + + true + + + QAbstractItemView::SelectionBehavior::SelectRows + + + false + + + true + + + + + + + + Gui::ColorButton + QPushButton +
Gui/Widgets.h
+
+ + Gui::TokenTreeView + QTreeView +
Gui/Dialogs/DlgThemeEditor.h
+
+
+ + +
diff --git a/src/Gui/OverlayManager.cpp b/src/Gui/OverlayManager.cpp index 8c1cb144e7..15fdc211c1 100644 --- a/src/Gui/OverlayManager.cpp +++ b/src/Gui/OverlayManager.cpp @@ -133,6 +133,8 @@ public: if (activeStyleSheet.isEmpty()) { activeStyleSheet = _default; } + + activeStyleSheet = Application::Instance->replaceVariablesInQss(activeStyleSheet); } ParameterGrp::handle handle; @@ -157,7 +159,7 @@ private: } else if (!overlayStyleSheet.isEmpty() && !QFile::exists(overlayStyleSheet)) { // User did choose one of predefined stylesheets, we need to qualify it with namespace - overlayStyleSheet = QStringLiteral("overlay:%1").arg(overlayStyleSheet); + overlayStyleSheet = QStringLiteral("overlay:%1").arg(overlayStyleSheet); } return overlayStyleSheet; diff --git a/src/Gui/PreCompiled.h b/src/Gui/PreCompiled.h index 718190076b..4da9bd1cda 100644 --- a/src/Gui/PreCompiled.h +++ b/src/Gui/PreCompiled.h @@ -69,8 +69,10 @@ #include #include #include +#include #include #include +#include #include #include #include diff --git a/src/Gui/PreferencePackManager.cpp b/src/Gui/PreferencePackManager.cpp index 2bac18b0e2..d573a06eeb 100644 --- a/src/Gui/PreferencePackManager.cpp +++ b/src/Gui/PreferencePackManager.cpp @@ -41,9 +41,12 @@ #include "DockWindowManager.h" #include "ToolBarManager.h" +#include #include -#include // For generating a timestamped filename +#include // For generating a timestamped filename +#include +#include using namespace Gui; @@ -359,6 +362,9 @@ bool PreferencePackManager::apply(const std::string& preferencePackName) const Gui::ToolBarManager* pToolbarMgr = Gui::ToolBarManager::getInstance(); pToolbarMgr->restoreState(); + // We need to reload stylesheet to apply any changed style parameters + Gui::Application::Instance->reloadStyleSheet(); + // TODO: Are there other things that have to be manually triggered? } return wasApplied; diff --git a/src/Gui/PreferencePages/DlgSettingsUI.cpp b/src/Gui/PreferencePages/DlgSettingsUI.cpp index 7774259798..e5d7974621 100644 --- a/src/Gui/PreferencePages/DlgSettingsUI.cpp +++ b/src/Gui/PreferencePages/DlgSettingsUI.cpp @@ -31,6 +31,10 @@ #include "DlgSettingsUI.h" #include "ui_DlgSettingsUI.h" +#include "Dialogs/DlgThemeEditor.h" + +#include + using namespace Gui::Dialog; @@ -45,6 +49,10 @@ DlgSettingsUI::DlgSettingsUI(QWidget* parent) , ui(new Ui_DlgSettingsUI) { ui->setupUi(this); + + connect(ui->themeEditorButton, &QPushButton::clicked, [this]() { + openThemeEditor(); + }); } /** @@ -114,13 +122,14 @@ void DlgSettingsUI::loadStyleSheet() populateStylesheets("OverlayActiveStyleSheet", "overlay", ui->OverlayStyleSheets, "Auto"); } -void DlgSettingsUI::populateStylesheets(const char *key, - const char *path, - PrefComboBox *combo, - const char *def, +void DlgSettingsUI::populateStylesheets(const char* key, + const char* path, + PrefComboBox* combo, + const char* def, QStringList filter) { - auto hGrp = App::GetApplication().GetParameterGroupByPath("User parameter:BaseApp/Preferences/MainWindow"); + auto hGrp = App::GetApplication().GetParameterGroupByPath( + "User parameter:BaseApp/Preferences/MainWindow"); // List all .qss/.css files QMap cssFiles; QDir dir; @@ -172,6 +181,12 @@ void DlgSettingsUI::populateStylesheets(const char *key, combo->onRestore(); } +void DlgSettingsUI::openThemeEditor() +{ + Gui::DlgThemeEditor editor; + editor.exec(); +} + /** * Sets the strings of the subwidgets using the current language. */ @@ -190,6 +205,10 @@ namespace { void applyStyleSheet(ParameterGrp *hGrp) { + if (auto parameterManager = Base::provideService()) { + parameterManager->reload(); + } + auto sheet = hGrp->GetASCII("StyleSheet"); bool tiledBG = hGrp->GetBool("TiledBackground", false); Gui::Application::Instance->setStyleSheet(QString::fromUtf8(sheet.c_str()), tiledBG); diff --git a/src/Gui/PreferencePages/DlgSettingsUI.h b/src/Gui/PreferencePages/DlgSettingsUI.h index 4dcfbea680..37030b5c4b 100644 --- a/src/Gui/PreferencePages/DlgSettingsUI.h +++ b/src/Gui/PreferencePages/DlgSettingsUI.h @@ -62,6 +62,7 @@ protected: const char *def, QStringList filter = QStringList()); + void openThemeEditor(); private: std::unique_ptr ui; }; diff --git a/src/Gui/PreferencePages/DlgSettingsUI.ui b/src/Gui/PreferencePages/DlgSettingsUI.ui index 5bf0459cd2..2f6f72d89e 100644 --- a/src/Gui/PreferencePages/DlgSettingsUI.ui +++ b/src/Gui/PreferencePages/DlgSettingsUI.ui @@ -31,7 +31,7 @@
- + @@ -53,7 +53,7 @@ This color might be used by your theme to let you customize it. - + 85 123 @@ -119,7 +119,7 @@ This color might be used by your theme to let you customize it. - + 85 123 @@ -145,7 +145,7 @@ This color might be used by your theme to let you customize it. - + 85 123 @@ -165,12 +165,12 @@ Style sheet how user interface will look like - - MainWindow - StyleSheet + + MainWindow + @@ -178,12 +178,12 @@ - - MainWindow - OverlayActiveStyleSheet + + MainWindow + @@ -191,6 +191,13 @@ + + + + Open Theme Editor + + +
@@ -230,12 +237,12 @@ 16 - - TreeView - IconSize + + TreeView + @@ -285,12 +292,12 @@ 0 - - TreeView - ItemSpacing + + TreeView + @@ -492,7 +499,7 @@ - Qt::Vertical + Qt::Orientation::Vertical @@ -510,26 +517,26 @@ QPushButton
Gui/Widgets.h
- - Gui::PrefColorButton - Gui::ColorButton -
Gui/PrefWidgets.h
-
- - Gui::PrefComboBox - QComboBox -
Gui/PrefWidgets.h
-
Gui::PrefSpinBox QSpinBox
Gui/PrefWidgets.h
+ + Gui::PrefColorButton + Gui::ColorButton +
Gui/PrefWidgets.h
+
Gui::PrefCheckBox QCheckBox
Gui/PrefWidgets.h
+ + Gui::PrefComboBox + QComboBox +
Gui/PrefWidgets.h
+
ThemeAccentColor1 diff --git a/src/Gui/StyleParameters/ParameterManager.cpp b/src/Gui/StyleParameters/ParameterManager.cpp new file mode 100644 index 0000000000..24c685b096 --- /dev/null +++ b/src/Gui/StyleParameters/ParameterManager.cpp @@ -0,0 +1,353 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kacper Donat * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD 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 * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#include "PreCompiled.h" + +#include "ParameterManager.h" +#include "Parser.h" + +#ifndef _PreComp_ +#include +#include +#include +#include +#include +#endif + +namespace Gui::StyleParameters +{ + +Length Length::operator+(const Length& rhs) const +{ + ensureEqualUnits(rhs); + return {value + rhs.value, unit}; +} + +Length Length::operator-(const Length& rhs) const +{ + ensureEqualUnits(rhs); + return {value - rhs.value, unit}; +} + +Length Length::operator-() const +{ + return {-value, unit}; +} + +Length Length::operator/(const Length& rhs) const +{ + if (rhs.value == 0) { + THROWM(Base::RuntimeError, "Division by zero"); + } + + if (rhs.unit.empty() || unit.empty()) { + return {value / rhs.value, unit}; + } + + ensureEqualUnits(rhs); + return {value / rhs.value, unit}; +} + +Length Length::operator*(const Length& rhs) const +{ + if (rhs.unit.empty() || unit.empty()) { + return {value * rhs.value, unit}; + } + + ensureEqualUnits(rhs); + return {value * rhs.value, unit}; +} + +void Length::ensureEqualUnits(const Length& rhs) const +{ + if (unit != rhs.unit) { + THROWM(Base::RuntimeError, + fmt::format("Units mismatch left expression is '{}', right expression is '{}'", + unit, + rhs.unit)); + } +} + +std::string Value::toString() const +{ + if (std::holds_alternative(*this)) { + auto [value, unit] = std::get(*this); + return fmt::format("{}{}", value, unit); + } + + if (std::holds_alternative(*this)) { + auto color = std::get(*this); + return fmt::format("#{:0>6x}", 0xFFFFFF & color.rgb()); // NOLINT(*-magic-numbers) + } + + return std::get(*this); +} + +ParameterSource::ParameterSource(const Metadata& metadata) + : metadata(metadata) +{} + +InMemoryParameterSource::InMemoryParameterSource(const std::list& parameters, + const Metadata& metadata) + : ParameterSource(metadata) +{ + for (const auto& parameter : parameters) { + InMemoryParameterSource::define(parameter); + } +} + +std::list InMemoryParameterSource::all() const +{ + auto values = parameters | std::ranges::views::values; + + return std::list(values.begin(), values.end()); +} + +std::optional InMemoryParameterSource::get(const std::string& name) const +{ + if (parameters.contains(name)) { + return parameters.at(name); + } + + return std::nullopt; +} + +void InMemoryParameterSource::define(const Parameter& parameter) +{ + parameters[parameter.name] = parameter; +} + +void InMemoryParameterSource::remove(const std::string& name) +{ + parameters.erase(name); +} + +BuiltInParameterSource::BuiltInParameterSource(const Metadata& metadata) + : ParameterSource(metadata) +{ + this->metadata.options |= ReadOnly; +} + +std::list BuiltInParameterSource::all() const +{ + std::list result; + + for (const auto& name : params | std::views::keys) { + result.push_back(*get(name)); + } + + return result; +} + +std::optional BuiltInParameterSource::get(const std::string& name) const +{ + if (params.contains(name)) { + unsigned long color = params.at(name)->GetUnsigned(name.c_str(), 0); + + return Parameter { + .name = name, + .value = fmt::format("#{:0>6x}", 0x00FFFFFF & (color >> 8)), // NOLINT(*-magic-numbers) + }; + } + + return std::nullopt; +} + +UserParameterSource::UserParameterSource(ParameterGrp::handle hGrp, const Metadata& metadata) + : ParameterSource(metadata) + , hGrp(hGrp) +{} + +std::list UserParameterSource::all() const +{ + std::list result; + + for (const auto& [token, value] : hGrp->GetASCIIMap()) { + result.push_back({ + .name = token, + .value = value, + }); + } + + return result; +} + +std::optional UserParameterSource::get(const std::string& name) const +{ + if (auto value = hGrp->GetASCII(name.c_str(), ""); !value.empty()) { + return Parameter { + .name = name, + .value = value, + }; + } + + return {}; +} + +void UserParameterSource::define(const Parameter& parameter) +{ + hGrp->SetASCII(parameter.name.c_str(), parameter.value); +} + +void UserParameterSource::remove(const std::string& name) +{ + hGrp->RemoveASCII(name.c_str()); +} + +ParameterManager::ParameterManager() = default; + +void ParameterManager::reload() +{ + _resolved.clear(); +} + +std::string ParameterManager::replacePlaceholders(const std::string& expression, + ResolveContext context) const +{ + static const QRegularExpression regex(QStringLiteral("@(\\w+)")); + + auto substituteWithCallback = + [](const QRegularExpression& regex, + const QString& input, + const std::function& callback) { + QRegularExpressionMatchIterator it = regex.globalMatch(input); + + QString result; + qsizetype lastIndex = 0; + + while (it.hasNext()) { + QRegularExpressionMatch match = it.next(); + + qsizetype start = match.capturedStart(); + qsizetype end = match.capturedEnd(); + + result += input.mid(lastIndex, start - lastIndex); + result += callback(match); + + lastIndex = end; + } + + // Append any remaining text after the last match + result += input.mid(lastIndex); + + return result; + }; + + // clang-format off + return substituteWithCallback( + regex, + QString::fromStdString(expression), + [&](const QRegularExpressionMatch& match) { + auto tokenName = match.captured(1).toStdString(); + + auto tokenValue = resolve(tokenName, context); + context.visited.erase(tokenName); + + return QString::fromStdString(tokenValue.toString()); + } + ).toStdString(); + // clang-format on +} + +std::list ParameterManager::parameters() const +{ + std::set result; + + // we need to traverse it in reverse order so more important tokens will take precedence + for (const ParameterSource* source : _sources | std::views::reverse) { + for (const Parameter& parameter : source->all()) { + result.insert(parameter); + } + } + + return std::list(result.begin(), result.end()); +} + +std::optional ParameterManager::expression(const std::string& name) const +{ + if (auto param = parameter(name)) { + return param->value; + } + + return {}; +} + +Value ParameterManager::resolve(const std::string& name, ResolveContext context) const +{ + std::optional maybeParameter = this->parameter(name); + + if (!maybeParameter) { + Base::Console().warning("Requested non-existent design token '%s'.", name); + return std::string {}; + } + + if (context.visited.contains(name)) { + Base::Console().warning("The design token '%s' contains circular-reference.", name); + return expression(name).value_or(std::string {}); + } + + const Parameter& token = *maybeParameter; + + if (!_resolved.contains(token.name)) { + context.visited.insert(token.name); + try { + _resolved[token.name] = evaluate(token.value, context); + } + catch (Base::Exception&) { + // in case of being unable to parse it, we need to treat it as a generic value + _resolved[token.name] = replacePlaceholders(token.value, context); + } + context.visited.erase(token.name); + } + + return _resolved[token.name]; +} + +Value ParameterManager::evaluate(const std::string& expression, ResolveContext context) const +{ + Parser parser(expression); + return parser.parse()->evaluate({.manager = this, .context = std::move(context)}); +} + +std::optional ParameterManager::parameter(const std::string& name) const +{ + for (const ParameterSource* source : _sources) { + if (const auto& parameter = source->get(name)) { + return parameter; + } + } + + return {}; +} + +void ParameterManager::addSource(ParameterSource* source) +{ + _sources.push_front(source); +} + +std::list ParameterManager::sources() const +{ + return _sources; +} + +} // namespace Gui::StyleParameters \ No newline at end of file diff --git a/src/Gui/StyleParameters/ParameterManager.h b/src/Gui/StyleParameters/ParameterManager.h new file mode 100644 index 0000000000..8736755c99 --- /dev/null +++ b/src/Gui/StyleParameters/ParameterManager.h @@ -0,0 +1,457 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kacper Donat * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD 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 * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#ifndef STYLEPARAMETERS_PARAMETERMANAGER_H +#define STYLEPARAMETERS_PARAMETERMANAGER_H + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace Gui::StyleParameters +{ + +// Forward declaration for Parser +class Parser; + +/** + * @brief Represents a length in a specified unit. + * + * This struct is a very simplified representation of lengths that can be used as parameters for + * styling purposes. The length basically consists of value and unit. Unit is optional, empty unit + * represents a dimensionless length that can be used as a scalar. This struct does not care about + * unit conversions as its uses do not require it. + */ +struct Length +{ + /// Numeric value of the length. + double value; + /// Unit of the length, empty if the value is dimensionless. + std::string unit; + + /** + * @name Operators + * + * This struct supports basic operations on Length. Each operation requires for operands to be + * the same unit. Multiplication and division additionally allow one operand to be dimensionless + * and hence act as a scalar. + * + * @code{c++} + * Length a { 10, "px" }; + * Length b { 5, "px" }; + * + * Length differentUnit { 3, "rem" } + * Length scalar { 2, "" }; + * + * // basic operations with the same unit are allowed + * auto sum = a + b; // 15 px + * auto difference = a - 5; // 10 px + * + * // basic operations with mixed units are NOT allowed + * auto sumOfIncompatibleUnits = a + differentUnit; // will throw + * auto productOfIncompatibleUnits = a * differentUnit; // will throw + * + * // exception is that for multiplication and division dimensionless units are allowed + * auto productWithScalar = a * scalar; // 20 px + * @endcode + * @{ + */ + Length operator+(const Length& rhs) const; + Length operator-(const Length& rhs) const; + Length operator-() const; + + Length operator/(const Length& rhs) const; + Length operator*(const Length& rhs) const; + /// @} + +private: + void ensureEqualUnits(const Length& rhs) const; +}; + +/** + * @brief This struct represents any valid value that can be used as the parameter value. + * + * The value can be one of three basic types: + * - Numbers / Lengths (so any length with optional unit) (Length) + * - Colors (QColor) + * - Any other generic expression. (std::string) + * + * As a rule, operations can be only performed over values of the same type. + */ +struct Value : std::variant +{ + using std::variant::variant; + + /** + * Converts the object into its string representation. + * + * @return A string representation of the object that can later be used in QSS. + */ + std::string toString() const; +}; + +/** + * @struct Parameter + * + * @brief Represents a named, dynamic expression-based parameter. + * + * The Parameter structure is used to define reusable named variables in styling or layout systems. + * Each parameter consists of a `name` and a `value` string, where the value is a CSS-like expression + * that supports numbers, units, arithmetic, colors, functions, and parameter references. + * + * ### Naming Convention + * Parameter names must be unique and follow **CamelCase**. + */ +struct Parameter +{ + /// Comparator that assumes that parameters are equal as long as name is the same + struct NameComparator + { + bool operator()(const Parameter& lhs, const Parameter& rhs) const + { + return lhs.name < rhs.name; + } + }; + + /// Name of the parameter, name should follow CamelCase + std::string name; + /// Expression associated with the parameter + std::string value; +}; + +enum class ParameterSourceOption +{ + // clang-format off + /// Parameters are read-only and the source does not allow editing + ReadOnly = 1 << 0, + /// Parameters are expected to be edited by the user, not only theme developers + UserEditable = 1 << 1, + // clang-format on +}; + +using ParameterSourceOptions = Base::Flags; + +/** + * @brief Abstract base class representing a source of style parameters. + * + * A `ParameterSource` is responsible for managing a collection of named parameters. Each source + * has metadata describing its type, characteristics, and behavior. + * + * ### Key Responsibilities + * - Define, update, and remove parameters within the source. + * - Provide access to all parameters or specific ones by name. + * - Act as a backend for parameter management, feeding the `ParameterManager` with available + * parameter data. + * + * ### Metadata + * Each parameter source includes metadata consisting of: + * - `name`: Name of the source, for identification purposes. + * - `options`: Flags specifying optional behavior (e.g., `ReadOnly`, `UserEditable`). + * + * ### Notes on Usage + * - Subclasses of `ParameterSource` (e.g., `BuiltInParameterSource`, `UserParameterSource`) + * implement different storage mechanisms and behaviors based on whether parameters are + * pre-defined, user-defined, or loaded in memory. + * - Parameters can be retrieved and manipulated globally through the `ParameterManager`, which + * aggregates multiple `ParameterSource` instances. + * + * #### Example + * @code{.cpp} + * // Create an in-memory parameter source + * InMemoryParameterSource source({ + * Parameter{ "BasePadding", "16px" }, + * Parameter{ "DefaultColor", "#ff00ff" }, + * }); + * + * source.define(Parameter{ "Margin", "4px" }); // Adds a new parameter + * + * auto padding = source.get("BasePadding"); // Retrieves parameter named "BasePadding" + * auto parametersList = source.all(); // Retrieve all parameters + * @endcode + * + * ### Subclass Requirements + * Derived classes must implement: + * - `all()` - Retrieve all parameters in the source. + * - `get()` - Retrieve a specific parameter. + * - `define()` - Add or update a parameter, can be left empty for readonly sources. + * - `remove()` - Remove a parameter by name, can be left empty for readonly sources. + */ +class GuiExport ParameterSource +{ +public: + using enum ParameterSourceOption; + + /** + * @brief Contains metadata information about a `ParameterSource`. + * + * The `Metadata` struct provides a way to describe the characteristics and identity + * of a `ParameterSource`. It includes a name for identification and a set of options + * that define the source's behavior and restrictions. + */ + struct Metadata + { + /// The name of the parameter source. Should be marked for translation using QT_TR_NOOP + std::string name; + /// Flags defining the behavior and properties of the parameter source. + ParameterSourceOptions options {}; + }; + + /// Metadata of the parameter source + Metadata metadata; + + FC_DEFAULT_MOVE(ParameterSource); + FC_DISABLE_COPY(ParameterSource); + + explicit ParameterSource(const Metadata& metadata); + virtual ~ParameterSource() = default; + + /** + * @brief Retrieves a list of all parameters available in the source. + * + * This method returns every parameter defined within this source, enabling iteration and bulk + * access to all stored values. + * + * @return A list containing all `Parameter` objects stored in the source. + */ + virtual std::list all() const = 0; + + /** + * @brief Retrieves a specific parameter by its name. + * + * @param[in] name The name of the parameter to retrieve. + * @return An optional containing the requested parameter if it exists, or empty if not. + */ + virtual std::optional get(const std::string& name) const = 0; + + /** + * @brief Defines or updates a parameter in the source. + * + * Adds a new parameter to the source if it doesn't already exist or updates the value of an + * existing parameter with the same name. + * + * @param[in] parameter The `Parameter` object to define or update in the source. + */ + virtual void define([[maybe_unused]] const Parameter& parameter) {} + + /** + * @brief Removes a parameter from the source by its name. + * + * Deletes the specific parameter from the source if it exists. If no parameter with the given + * name is found, the method does nothing. + * + * @param[in] name The name of the parameter to remove. + */ + virtual void remove([[maybe_unused]] const std::string& name) {} +}; + +/** + * @brief In-memory parameter source that stores parameters in a map. + * + * This source is useful for temporary parameter storage or when you need to + * define parameters programmatically without persisting them to disk. + */ +class GuiExport InMemoryParameterSource : public ParameterSource +{ + std::map parameters; + +public: + InMemoryParameterSource(const std::list& parameters, const Metadata& metadata); + + std::list all() const override; + std::optional get(const std::string& name) const override; + void define(const Parameter& parameter) override; + void remove(const std::string& name) override; +}; + +/** + * @brief Built-in parameter source that reads from FreeCAD's parameter system. + * + * This source provides access to predefined parameters that are stored in + * FreeCAD's global parameter system. These parameters are typically defined + * by the application and are read-only. + */ +class GuiExport BuiltInParameterSource : public ParameterSource +{ +public: + explicit BuiltInParameterSource(const Metadata& metadata = {}); + + std::list all() const override; + std::optional get(const std::string& name) const override; + +private: + ParameterGrp::handle hGrpThemes = + App::GetApplication().GetParameterGroupByPath("User parameter:BaseApp/Preferences/Themes"); + ParameterGrp::handle hGrpView = + App::GetApplication().GetParameterGroupByPath("User parameter:BaseApp/Preferences/View"); + + std::map params = { + {"ThemeAccentColor1", hGrpThemes}, + {"ThemeAccentColor2", hGrpThemes}, + {"ThemeAccentColor3", hGrpThemes}, + {"BackgroundColor", hGrpView}, + }; +}; + +/** + * @brief User-defined parameter source that reads from user preferences. + * + * This source provides access to user-defined parameters that are stored + * in the user's preference file. These parameters can be modified by the + * user and persist across application sessions. + */ +class GuiExport UserParameterSource : public ParameterSource +{ + ParameterGrp::handle hGrp; + +public: + UserParameterSource(ParameterGrp::handle hGrp, const Metadata& metadata); + + std::list all() const override; + std::optional get(const std::string& name) const override; + void define(const Parameter& parameter) override; + void remove(const std::string& name) override; +}; + +/** + * @brief Central manager for style parameters that aggregates multiple sources. + * + * The ParameterManager is responsible for: + * - Managing multiple parameter sources + * - Resolving parameter references and expressions + * - Caching resolved values for performance + * - Handling circular references + */ +class GuiExport ParameterManager +{ + std::list _sources; + mutable std::map _resolved; + +public: + struct ResolveContext + { + /// Names of parameters currently being resolved. + std::set visited; + }; + + ParameterManager(); + + /** + * @brief Clears the internal cache of resolved values. + * + * Call this method when parameters have been modified to ensure + * that the changes are reflected in subsequent resolutions. + */ + void reload(); + + /** + * @brief Replaces parameter placeholders in a string with their resolved values. + * + * This method performs simple string substitution of @parameter references + * with their actual values. It does not evaluate expressions, only performs + * direct substitution. + * + * @param expression The string containing parameter placeholders + * @param context Resolution context for handling circular references + * @return The string with all placeholders replaced + */ + std::string replacePlaceholders(const std::string& expression, ResolveContext context = {}) const; + + /** + * @brief Returns all available parameters from all sources. + * + * Parameters are returned in order of source priority, with later sources + * taking precedence over earlier ones. + * + * @return List of all available parameters + */ + std::list parameters() const; + + /** + * @brief Gets the raw expression string for a parameter. + * + * @param name The name of the parameter + * @return The expression string if the parameter exists, empty otherwise + */ + std::optional expression(const std::string& name) const; + + /** + * @brief Resolves a parameter to its final value. + * + * This method evaluates the parameter's expression and returns the computed + * value. The result is cached for subsequent calls. + * + * @param name The name of the parameter to resolve + * @param context Resolution context for handling circular references + * @return The resolved value + */ + Value resolve(const std::string& name, ResolveContext context = {}) const; + + /** + * @brief Evaluates an expression string and returns the result. + * + * @param expression The expression to evaluate + * @param context Resolution context for handling circular references + * @return The evaluated value + */ + Value evaluate(const std::string& expression, ResolveContext context = {}) const; + + /** + * @brief Gets a parameter by name from any source. + * + * @param name The name of the parameter + * @return The parameter if found, empty otherwise + */ + std::optional parameter(const std::string& name) const; + + /** + * @brief Adds a parameter source to the manager. + * + * Sources are evaluated in the order they are added, with later sources + * taking precedence over earlier ones. + * + * @param source The parameter source to add + */ + void addSource(ParameterSource* source); + + /** + * @brief Returns all registered parameter sources. + * + * @return List of parameter sources in order of registration + */ + std::list sources() const; +}; + +} // namespace Gui::StyleParameters + +ENABLE_BITMASK_OPERATORS(Gui::StyleParameters::ParameterSourceOption); + +#endif // STYLEPARAMETERS_PARAMETERMANAGER_H \ No newline at end of file diff --git a/src/Gui/StyleParameters/Parser.cpp b/src/Gui/StyleParameters/Parser.cpp new file mode 100644 index 0000000000..dbcb52f27a --- /dev/null +++ b/src/Gui/StyleParameters/Parser.cpp @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kacper Donat * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD 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 * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#include "PreCompiled.h" + +#include "Parser.h" +#include "ParameterManager.h" + +#ifndef _PreComp_ +#include +#include +#include +#include +#include +#endif + +namespace Gui::StyleParameters +{ + +Value ParameterReference::evaluate(const EvaluationContext& context) const +{ + return context.manager->resolve(name, context.context); +} + +Value Number::evaluate([[maybe_unused]] const EvaluationContext& context) const +{ + return value; +} + +Value Color::evaluate([[maybe_unused]] const EvaluationContext& context) const +{ + return color; +} + +Value FunctionCall::evaluate(const EvaluationContext& context) const +{ + if (arguments.size() != 2) { + THROWM(Base::ExpressionError, + fmt::format("Function '{}' expects 2 arguments, got {}", + functionName, + arguments.size())); + } + + auto colorArg = arguments[0]->evaluate(context); + auto amountArg = arguments[1]->evaluate(context); + + if (!std::holds_alternative(colorArg)) { + THROWM(Base::ExpressionError, + fmt::format("'{}' is not supported for colors", functionName)); + } + + auto color = std::get(colorArg); + + // In Qt if you want to make color 20% darker or lighter, you need to pass 120 as the value + // we, however, want users to pass only the relative difference, hence we need to add the + // 100 required by Qt. + // + // NOLINTNEXTLINE(*-magic-numbers) + auto amount = 100 + static_cast(std::get(amountArg).value); + + if (functionName == "lighten") { + return color.lighter(amount); + } + + if (functionName == "darken") { + return color.darker(amount); + } + + THROWM(Base::ExpressionError, fmt::format("Unknown function '{}'", functionName)); +} + +Value BinaryOp::evaluate(const EvaluationContext& context) const +{ + Value lval = left->evaluate(context); + Value rval = right->evaluate(context); + + if (!std::holds_alternative(lval) || !std::holds_alternative(rval)) { + THROWM(Base::ExpressionError, "Math operations are supported only on lengths"); + } + + auto lhs = std::get(lval); + auto rhs = std::get(rval); + + switch (op) { + case Operator::Add: + return lhs + rhs; + case Operator::Subtract: + return lhs - rhs; + case Operator::Multiply: + return lhs * rhs; + case Operator::Divide: + return lhs / rhs; + default: + THROWM(Base::ExpressionError, "Unknown operator"); + } +} + +Value UnaryOp::evaluate(const EvaluationContext& context) const +{ + Value val = operand->evaluate(context); + if (std::holds_alternative(val)) { + THROWM(Base::ExpressionError, "Unary operations on colors are not supported"); + } + + auto v = std::get(val); + switch (op) { + case Operator::Add: + return v; + case Operator::Subtract: + return -v; + default: + THROWM(Base::ExpressionError, "Unknown unary operator"); + } +} + +std::unique_ptr Parser::parse() +{ + auto expr = parseExpression(); + skipWhitespace(); + if (pos != input.size()) { + THROWM(Base::ParserError, + fmt::format("Unexpected characters at end of input: {}", input.substr(pos))); + } + return expr; +} + +bool Parser::peekString(const char* function) const +{ + return input.compare(pos, strlen(function), function) == 0; +} + +std::unique_ptr Parser::parseExpression() +{ + auto expr = parseTerm(); + while (true) { + skipWhitespace(); + if (match('+')) { + expr = std::make_unique(std::move(expr), Operator::Add, parseTerm()); + } + else if (match('-')) { + expr = std::make_unique(std::move(expr), Operator::Subtract, parseTerm()); + } + else { + break; + } + } + return expr; +} + +std::unique_ptr Parser::parseTerm() +{ + auto expr = parseFactor(); + while (true) { + skipWhitespace(); + if (match('*')) { + expr = std::make_unique(std::move(expr), Operator::Multiply, parseFactor()); + } + else if (match('/')) { + expr = std::make_unique(std::move(expr), Operator::Divide, parseFactor()); + } + else { + break; + } + } + return expr; +} + +std::unique_ptr Parser::parseFactor() +{ + skipWhitespace(); + if (match('+') || match('-')) { + Operator op = (input[pos - 1] == '+') ? Operator::Add : Operator::Subtract; + return std::make_unique(op, parseFactor()); + } + if (match('(')) { + auto expr = parseExpression(); + if (!match(')')) { + THROWM(Base::ParserError, fmt::format("Expected ')', got '{}'", input[pos])); + } + return expr; + } + if (peekColor()) { + return parseColor(); + } + if (peekParameter()) { + return parseParameter(); + } + if (peekFunction()) { + return parseFunctionCall(); + } + return parseNumber(); +} + +bool Parser::peekColor() +{ + skipWhitespace(); + // clang-format off + return input[pos] == '#' + || peekString(rgbFunction) + || peekString(rgbaFunction); + // clang-format on +} + +std::unique_ptr Parser::parseColor() +{ + const auto parseHexadecimalColor = [&]() { + constexpr int hexadecimalBase = 16; + + // Format is #RRGGBB + pos++; + int r = std::stoi(input.substr(pos, 2), nullptr, hexadecimalBase); + pos += 2; + int g = std::stoi(input.substr(pos, 2), nullptr, hexadecimalBase); + pos += 2; + int b = std::stoi(input.substr(pos, 2), nullptr, hexadecimalBase); + pos += 2; + + return std::make_unique(QColor(r, g, b)); + }; + + const auto parseFunctionStyleColor = [&]() { + bool hasAlpha = peekString(rgbaFunction); + + pos += hasAlpha ? strlen(rgbaFunction) : strlen(rgbFunction); + + int r = parseInt(); + if (!match(',')) { + THROWM(Base::ParserError, fmt::format("Expected ',' after red, got '{}'", input[pos])); + } + int g = parseInt(); + if (!match(',')) { + THROWM(Base::ParserError, fmt::format("Expected ',' after green, got '{}'", input[pos])); + } + int b = parseInt(); + int a = 255; // NOLINT(*-magic-numbers) + if (hasAlpha) { + if (!match(',')) { + THROWM(Base::ParserError, fmt::format("Expected ',' after blue, got '{}'", input[pos])); + } + a = parseInt(); + } + if (!match(')')) { + THROWM(Base::ParserError, fmt::format("Expected ')' after color arguments, got '{}'", input[pos])); + } + return std::make_unique(QColor(r, g, b, a)); + }; + + skipWhitespace(); + + try { + if (input[pos] == '#') { + return parseHexadecimalColor(); + } + + if (peekString(rgbFunction) || peekString(rgbaFunction)) { + return parseFunctionStyleColor(); + } + } catch (std::invalid_argument&) { + THROWM(Base::ParserError, "Invalid color format, expected #RRGGBB or rgb(r,g,b) or rgba(r,g,b,a)"); + } + + THROWM(Base::ParserError, "Unknown color format"); +} + +bool Parser::peekParameter() +{ + skipWhitespace(); + return pos < input.size() && input[pos] == '@'; +} + +std::unique_ptr Parser::parseParameter() +{ + skipWhitespace(); + if (!match('@')) { + THROWM(Base::ParserError, fmt::format("Expected '@' for parameter, got '{}'", input[pos])); + } + size_t start = pos; + while (pos < input.size() && (isalnum(input[pos]) || input[pos] == '_')) { + ++pos; + } + if (start == pos) { + THROWM(Base::ParserError, + fmt::format("Expected parameter name after '@', got '{}'", input[pos])); + } + return std::make_unique(input.substr(start, pos - start)); +} + +bool Parser::peekFunction() +{ + skipWhitespace(); + return pos < input.size() && isalpha(input[pos]); +} + +std::unique_ptr Parser::parseFunctionCall() +{ + skipWhitespace(); + size_t start = pos; + while (pos < input.size() && isalnum(input[pos])) { + ++pos; + } + std::string functionName = input.substr(start, pos - start); + + if (!match('(')) { + THROWM(Base::ParserError, + fmt::format("Expected '(' after function name, got '{}'", input[pos])); + } + + std::vector> arguments; + if (!match(')')) { + do { // NOLINT(*-avoid-do-while) + arguments.push_back(parseExpression()); + } while (match(',')); + + if (!match(')')) { + THROWM(Base::ParserError, + fmt::format("Expected ')' after function arguments, got '{}'", input[pos])); + } + } + + return std::make_unique(functionName, std::move(arguments)); +} + +int Parser::parseInt() +{ + skipWhitespace(); + size_t start = pos; + while (pos < input.size() && (isdigit(input[pos]) || input[pos] == '.')) { + ++pos; + } + return std::stoi(input.substr(start, pos - start)); +} + +std::unique_ptr Parser::parseNumber() +{ + skipWhitespace(); + size_t start = pos; + while (pos < input.size() && (isdigit(input[pos]) || input[pos] == '.')) { + ++pos; + } + + std::string number = input.substr(start, pos - start); + + try { + double value = std::stod(number); + std::string unit = parseUnit(); + return std::make_unique(value, unit); + } + catch (std::invalid_argument& e) { + THROWM(Base::ParserError, fmt::format("Invalid number: {}", number)); + } +} + +std::string Parser::parseUnit() +{ + skipWhitespace(); + size_t start = pos; + while (pos < input.size() && (isalpha(input[pos]) || input[pos] == '%')) { + ++pos; + } + if (start == pos) { + return ""; + } + return input.substr(start, pos - start); +} + +bool Parser::match(char expected) +{ + skipWhitespace(); + if (pos < input.size() && input[pos] == expected) { + ++pos; + return true; + } + return false; +} + +void Parser::skipWhitespace() +{ + while (pos < input.size() && isspace(input[pos])) { + ++pos; + } +} + +} // namespace Gui::StyleParameters \ No newline at end of file diff --git a/src/Gui/StyleParameters/Parser.h b/src/Gui/StyleParameters/Parser.h new file mode 100644 index 0000000000..3e7867cae9 --- /dev/null +++ b/src/Gui/StyleParameters/Parser.h @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kacper Donat * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD 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 * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#ifndef STYLEPARAMETERS_PARSER_H +#define STYLEPARAMETERS_PARSER_H + +#include +#include +#include + +#include "ParameterManager.h" + +namespace Gui::StyleParameters +{ + +enum class Operator : std::uint8_t +{ + Add, + Subtract, + Multiply, + Divide +}; + +struct EvaluationContext +{ + const ParameterManager* manager {}; + ParameterManager::ResolveContext context; +}; + +// Abstract Syntax Tree (AST) Base +struct GuiExport Expr +{ + Expr() = default; + + FC_DEFAULT_MOVE(Expr); + FC_DISABLE_COPY(Expr); + + virtual Value evaluate(const EvaluationContext& context) const = 0; + virtual ~Expr() = default; +}; + +struct GuiExport ParameterReference: public Expr +{ + std::string name; + + explicit ParameterReference(std::string name) + : name(std::move(name)) + {} + + Value evaluate(const EvaluationContext& context) const override; +}; + +struct GuiExport Number: public Expr +{ + Length value; + + Number(double value, std::string unit) + : value({value, std::move(unit)}) + {} + + Value evaluate([[maybe_unused]] const EvaluationContext& context) const override; +}; + +struct GuiExport Color: public Expr +{ + QColor color; + + explicit Color(QColor color) + : color(std::move(color)) + {} + + Value evaluate([[maybe_unused]] const EvaluationContext& context) const override; +}; + +struct GuiExport FunctionCall: public Expr +{ + std::string functionName; + std::vector> arguments; + + FunctionCall(std::string functionName, std::vector> arguments) + : functionName(std::move(functionName)) + , arguments(std::move(arguments)) + {} + + Value evaluate(const EvaluationContext& context) const override; +}; + +struct GuiExport BinaryOp: public Expr +{ + std::unique_ptr left, right; + Operator op; + + BinaryOp(std::unique_ptr left, Operator op, std::unique_ptr right) + : left(std::move(left)) + , right(std::move(right)) + , op(op) + {} + + Value evaluate(const EvaluationContext& context) const override; +}; + +struct GuiExport UnaryOp: public Expr +{ + Operator op; + std::unique_ptr operand; + + UnaryOp(Operator op, std::unique_ptr operand) + : op(op) + , operand(std::move(operand)) + {} + + Value evaluate(const EvaluationContext& context) const override; +}; + +class GuiExport Parser +{ + static constexpr auto rgbFunction = "rgb("; + static constexpr auto rgbaFunction = "rgba("; + + std::string input; + size_t pos = 0; + +public: + explicit Parser(std::string input) + : input(std::move(input)) + {} + + std::unique_ptr parse(); + +private: + bool peekString(const char* function) const; + std::unique_ptr parseExpression(); + std::unique_ptr parseTerm(); + std::unique_ptr parseFactor(); + bool peekColor(); + std::unique_ptr parseColor(); + bool peekParameter(); + std::unique_ptr parseParameter(); + bool peekFunction(); + std::unique_ptr parseFunctionCall(); + int parseInt(); + std::unique_ptr parseNumber(); + std::string parseUnit(); + bool match(char expected); + void skipWhitespace(); +}; + +} // namespace Gui::StyleParameters + +#endif // STYLEPARAMETERS_PARSER_H \ No newline at end of file diff --git a/src/Gui/Tools.h b/src/Gui/Tools.h index 29f5741a68..364da69ff1 100644 --- a/src/Gui/Tools.h +++ b/src/Gui/Tools.h @@ -1,5 +1,6 @@ /*************************************************************************** * Copyright (c) 2020 Werner Mayer * + * Copyright (c) 2025 Kacper Donat * * * * This file is part of the FreeCAD CAx development system. * * * @@ -23,39 +24,100 @@ #ifndef GUI_TOOLS_H #define GUI_TOOLS_H +#include + #include #include #include -#include +#include -namespace Gui { - -/*! - * \brief The QtTools class - * Helper class to reduce adding a lot of extra QT_VERSION checks to client code. +/** + * @brief The QtTools namespace + * + * Helper namespace to provide utilities to ease work with Qt. */ -class GuiExport QtTools { -public: - static int horizontalAdvance(const QFontMetrics& fm, QChar ch) { +namespace Gui::QtTools +{ +inline int horizontalAdvance(const QFontMetrics& fm, QChar ch) +{ + return fm.horizontalAdvance(ch); +} - return fm.horizontalAdvance(ch); - } - static int horizontalAdvance(const QFontMetrics& fm, const QString& text, int len = -1) { - return fm.horizontalAdvance(text, len); - } - static bool matches(QKeyEvent* ke, const QKeySequence& ks) { - uint searchkey = (ke->modifiers() | ke->key()) & ~(Qt::KeypadModifier | Qt::GroupSwitchModifier); - return ks == QKeySequence(searchkey); - } - static QKeySequence::StandardKey deleteKeySequence() { +inline int horizontalAdvance(const QFontMetrics& fm, const QString& text, int len = -1) +{ + return fm.horizontalAdvance(text, len); +} + +inline bool matches(QKeyEvent* ke, const QKeySequence& ks) +{ + uint searchkey = + (ke->modifiers() | ke->key()) & ~(Qt::KeypadModifier | Qt::GroupSwitchModifier); + return ks == QKeySequence(searchkey); +} + +inline QKeySequence::StandardKey deleteKeySequence() { #ifdef FC_OS_MACOSX - return QKeySequence::Backspace; + return QKeySequence::Backspace; #else - return QKeySequence::Delete; + return QKeySequence::Delete; #endif +} + +// clang-format off +/** + * TreeWalkCallable is a function that takes const QModelIndex& and: + * - returns void, if there is no stopping logic; + * - returns boolean, if there is logic that should stop tree traversal. + */ +template +concept TreeWalkCallable = + std::is_invocable_r_v || + std::is_invocable_r_v; +// clang-format on + +/** + * @brief Recursively traverses a QAbstractItemModel tree structure. + * + * The function traverses a tree model starting from a given index, or the root + * if no index is provided. For each node, it invokes the provided callable `func`. + * + * The callable can: + * - Return `void`, in which case the traversal continues through all nodes. + * - Return `bool`, in which case returning `true` stops further traversal. + * + * @param[in] model The tree model to traverse. + * @param[in] func A callable object applied to each QModelIndex. It can either + * return `void` or `bool` (for stopping logic). + * @param[in] index The starting index for traversal. If omitted, defaults to the root. + */ +void walkTreeModel(const QAbstractItemModel* model, + TreeWalkCallable auto&& func, + const QModelIndex& index = {}) +{ + using ReturnType = std::invoke_result_t; + + if (index.isValid()) { + if constexpr (std::is_same_v) { + func(index); + } + else if constexpr (std::is_same_v) { + if (func(index)) { + return; + } + } } -}; -} // namespace Gui + for (int i = 0; i < model->rowCount(index); ++i) { + walkTreeModel(model, func, model->index(i, 0, index)); + } +} -#endif // GUI_TOOLS_H +template +T valueOr(const QVariant& variant, const T& defaultValue) +{ + return variant.canConvert() ? variant.value() : defaultValue; +} + +} // namespace Gui::QtTools + +#endif // GUI_TOOLS_H diff --git a/tests/src/Gui/CMakeLists.txt b/tests/src/Gui/CMakeLists.txt index 774cf35f31..2c1e3f8ded 100644 --- a/tests/src/Gui/CMakeLists.txt +++ b/tests/src/Gui/CMakeLists.txt @@ -2,6 +2,9 @@ target_sources(Tests_run PRIVATE Assistant.cpp Camera.cpp + StyleParameters/StyleParametersApplicationTest.cpp + StyleParameters/ParserTest.cpp + StyleParameters/ParameterManagerTest.cpp ) # Qt tests diff --git a/tests/src/Gui/StyleParameters/ParameterManagerTest.cpp b/tests/src/Gui/StyleParameters/ParameterManagerTest.cpp new file mode 100644 index 0000000000..09c40cf13b --- /dev/null +++ b/tests/src/Gui/StyleParameters/ParameterManagerTest.cpp @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kacper Donat * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD 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 * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#include + +#include +#include + +using namespace Gui::StyleParameters; + +class ParameterManagerTest: public ::testing::Test +{ +protected: + void SetUp() override + { + // Create test sources + auto source1 = std::make_unique( + std::list { + {"BaseSize", "8px"}, + {"PrimaryColor", "#ff0000"}, + {"SecondaryColor", "#00ff00"}, + }, + ParameterSource::Metadata {"Source 1"}); + + auto source2 = std::make_unique( + std::list { + {"BaseSize", "16px"}, // Override from source1 + {"Margin", "@BaseSize * 2"}, + {"Padding", "@BaseSize / 2"}, + }, + ParameterSource::Metadata {"Source 2"}); + + manager.addSource(source1.get()); + manager.addSource(source2.get()); + sources.push_back(std::move(source1)); + sources.push_back(std::move(source2)); + } + + Gui::StyleParameters::ParameterManager manager; + std::vector> sources; +}; + +// Test basic parameter resolution +TEST_F(ParameterManagerTest, BasicParameterResolution) +{ + { + auto result = manager.resolve("BaseSize"); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 16.0); // Should get value from source2 (later source) + EXPECT_EQ(length.unit, "px"); + } + + { + auto result = manager.resolve("PrimaryColor"); + EXPECT_TRUE(std::holds_alternative(result)); + auto color = std::get(result); + EXPECT_EQ(color.red(), 255); + EXPECT_EQ(color.green(), 0); + EXPECT_EQ(color.blue(), 0); + } + + { + auto result = manager.resolve("SecondaryColor"); + EXPECT_TRUE(std::holds_alternative(result)); + auto color = std::get(result); + EXPECT_EQ(color.red(), 0); + EXPECT_EQ(color.green(), 255); + EXPECT_EQ(color.blue(), 0); + } +} + +// Test parameter references +TEST_F(ParameterManagerTest, ParameterReferences) +{ + { + auto result = manager.resolve("Margin"); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 32.0); // @BaseSize * 2 = 16 * 2 = 32 + EXPECT_EQ(length.unit, "px"); + } + + { + auto result = manager.resolve("Padding"); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 8.0); // @BaseSize / 2 = 16 / 2 = 8 + EXPECT_EQ(length.unit, "px"); + } +} + +// Test caching +TEST_F(ParameterManagerTest, Caching) +{ + // First resolution should cache the result + auto result1 = manager.resolve("BaseSize"); + EXPECT_TRUE(std::holds_alternative(result1)); + + // Second resolution should use cached value + auto result2 = manager.resolve("BaseSize"); + EXPECT_TRUE(std::holds_alternative(result2)); + + // Results should be identical + auto length1 = std::get(result1); + auto length2 = std::get(result2); + EXPECT_DOUBLE_EQ(length1.value, length2.value); + EXPECT_EQ(length1.unit, length2.unit); +} + +// Test cache invalidation +TEST_F(ParameterManagerTest, CacheInvalidation) +{ + // Initial resolution + auto result1 = manager.resolve("BaseSize"); + EXPECT_TRUE(std::holds_alternative(result1)); + auto length1 = std::get(result1); + EXPECT_DOUBLE_EQ(length1.value, 16.0); + + // Reload should clear cache + manager.reload(); + + // Resolution after reload should work the same + auto result2 = manager.resolve("BaseSize"); + EXPECT_TRUE(std::holds_alternative(result2)); + auto length2 = std::get(result2); + EXPECT_DOUBLE_EQ(length2.value, 16.0); + EXPECT_EQ(length1.unit, length2.unit); +} + +// Test source priority +TEST_F(ParameterManagerTest, SourcePriority) +{ + // Create a third source with higher priority + auto source3 = std::make_unique( + std::list { + {"BaseSize", "24px"}, // Should override both previous sources + }, + ParameterSource::Metadata {"Source 3"}); + + manager.addSource(source3.get()); + sources.push_back(std::move(source3)); + + // Should get value from the latest source (highest priority) + auto result = manager.resolve("BaseSize"); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 24.0); + EXPECT_EQ(length.unit, "px"); +} + +// Test parameter listing +TEST_F(ParameterManagerTest, ParameterListing) +{ + auto params = manager.parameters(); + + // Should contain all parameters from all sources + std::set paramNames; + for (const auto& param : params) { + paramNames.insert(param.name); + } + + EXPECT_TRUE(paramNames.contains("BaseSize")); + EXPECT_TRUE(paramNames.contains("PrimaryColor")); + EXPECT_TRUE(paramNames.contains("SecondaryColor")); + EXPECT_TRUE(paramNames.contains("Margin")); + EXPECT_TRUE(paramNames.contains("Padding")); + + // Should not contain duplicates (BaseSize should appear only once) + EXPECT_EQ(paramNames.count("BaseSize"), 1); +} + +// Test expression retrieval +TEST_F(ParameterManagerTest, ExpressionRetrieval) +{ + { + auto expr = manager.expression("BaseSize"); + EXPECT_TRUE(expr.has_value()); + EXPECT_EQ(*expr, "16px"); + } + + { + auto expr = manager.expression("Margin"); + EXPECT_TRUE(expr.has_value()); + EXPECT_EQ(*expr, "@BaseSize * 2"); + } + + { + auto expr = manager.expression("NonExistent"); + EXPECT_FALSE(expr.has_value()); + } +} + +// Test parameter retrieval +TEST_F(ParameterManagerTest, ParameterRetrieval) +{ + { + auto param = manager.parameter("BaseSize"); + EXPECT_TRUE(param.has_value()); + EXPECT_EQ(param->name, "BaseSize"); + EXPECT_EQ(param->value, "16px"); + } + + { + auto param = manager.parameter("NonExistent"); + EXPECT_FALSE(param.has_value()); + } +} + +// Test source management +TEST_F(ParameterManagerTest, SourceManagement) +{ + auto sources = manager.sources(); + EXPECT_EQ(sources.size(), 2); // We added 2 sources in SetUp + + // Test that we can access the sources + for (auto source : sources) { + EXPECT_NE(source, nullptr); + auto params = source->all(); + EXPECT_FALSE(params.empty()); + } +} + +// Test circular reference detection +TEST_F(ParameterManagerTest, CircularReferenceDetection) +{ + // Create a source with circular reference + auto circularSource = std::make_unique( + std::list { + {"A", "@B"}, + {"B", "@A"}, + }, + ParameterSource::Metadata {"Circular Source"}); + + manager.addSource(circularSource.get()); + sources.push_back(std::move(circularSource)); + + // Should handle circular reference gracefully + auto result = manager.resolve("A"); + // Should return the expression string as fallback + EXPECT_TRUE(std::holds_alternative(result)); +} + +// Test complex expressions +TEST_F(ParameterManagerTest, ComplexExpressions) +{ + // Create a source with complex expressions + auto complexSource = std::make_unique( + std::list { + {"ComplexMargin", "(@BaseSize + 4px) * 2"}, + {"ComplexPadding", "(@BaseSize - 2px) / 2"}, + {"ColorWithFunction", "lighten(@PrimaryColor, 20)"}, + }, + ParameterSource::Metadata {"Complex Source"}); + + manager.addSource(complexSource.get()); + sources.push_back(std::move(complexSource)); + + { + auto result = manager.resolve("ComplexMargin"); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 40.0); // (16 + 4) * 2 = 20 * 2 = 40 + EXPECT_EQ(length.unit, "px"); + } + + { + auto result = manager.resolve("ComplexPadding"); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 7.0); // (16 - 2) / 2 = 14 / 2 = 7 + EXPECT_EQ(length.unit, "px"); + } + + { + auto result = manager.resolve("ColorWithFunction"); + EXPECT_TRUE(std::holds_alternative(result)); + auto color = std::get(result); + // Should be lighter than the original red + EXPECT_GT(color.lightness(), QColor("#ff0000").lightness()); + } +} + +// Test error handling +TEST_F(ParameterManagerTest, ErrorHandling) +{ + // Test non-existent parameter + auto result = manager.resolve("NonExistent"); + EXPECT_TRUE(std::holds_alternative(result)); + EXPECT_EQ(std::get(result), ""); + + // Test invalid expression + auto invalidSource = std::make_unique( + std::list { + {"Invalid", "invalid expression that will fail"}, + }, + ParameterSource::Metadata {"Invalid Source"}); + + manager.addSource(invalidSource.get()); + sources.push_back(std::move(invalidSource)); + + // Should handle invalid expression gracefully + auto invalidResult = manager.resolve("Invalid"); + // Should return the expression string as fallback + EXPECT_TRUE(std::holds_alternative(invalidResult)); +} diff --git a/tests/src/Gui/StyleParameters/ParserTest.cpp b/tests/src/Gui/StyleParameters/ParserTest.cpp new file mode 100644 index 0000000000..a7e1b50734 --- /dev/null +++ b/tests/src/Gui/StyleParameters/ParserTest.cpp @@ -0,0 +1,617 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kacper Donat * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD 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 * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#include + +#include + +#include +#include + +using namespace Gui::StyleParameters; + +class ParserTest: public ::testing::Test +{ +protected: + void SetUp() override + { + // Create a simple parameter manager for testing + auto source = std::make_unique( + std::list { + {"TestParam", "10px"}, + {"TestColor", "#ff0000"}, + {"TestNumber", "5"}, + }, + ParameterSource::Metadata {"Test Source"}); + + manager.addSource(source.get()); + sources.push_back(std::move(source)); + } + + Gui::StyleParameters::ParameterManager manager; + std::vector> sources; +}; + +// Test number parsing +TEST_F(ParserTest, ParseNumbers) +{ + { + Parser parser("42"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 42.0); + EXPECT_EQ(length.unit, ""); + } + + { + Parser parser("10.5px"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 10.5); + EXPECT_EQ(length.unit, "px"); + } + + { + Parser parser("2.5em"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 2.5); + EXPECT_EQ(length.unit, "em"); + } + + { + Parser parser("100%"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 100.0); + EXPECT_EQ(length.unit, "%"); + } +} + +// Test color parsing +TEST_F(ParserTest, ParseColors) +{ + { + Parser parser("#ff0000"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto color = std::get(result); + EXPECT_EQ(color.red(), 255); + EXPECT_EQ(color.green(), 0); + EXPECT_EQ(color.blue(), 0); + } + + { + Parser parser("#00ff00"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto color = std::get(result); + EXPECT_EQ(color.red(), 0); + EXPECT_EQ(color.green(), 255); + EXPECT_EQ(color.blue(), 0); + } + + { + Parser parser("#0000ff"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto color = std::get(result); + EXPECT_EQ(color.red(), 0); + EXPECT_EQ(color.green(), 0); + EXPECT_EQ(color.blue(), 255); + } + + { + Parser parser("rgb(255, 0, 0)"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto color = std::get(result); + EXPECT_EQ(color.red(), 255); + EXPECT_EQ(color.green(), 0); + EXPECT_EQ(color.blue(), 0); + } + + { + Parser parser("rgba(255, 0, 0, 128)"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto color = std::get(result); + EXPECT_EQ(color.red(), 255); + EXPECT_EQ(color.green(), 0); + EXPECT_EQ(color.blue(), 0); + EXPECT_EQ(color.alpha(), 128); + } +} + +// Test parameter reference parsing +TEST_F(ParserTest, ParseParameterReferences) +{ + { + Parser parser("@TestParam"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 10.0); + EXPECT_EQ(length.unit, "px"); + } + + { + Parser parser("@TestColor"); + auto expr = parser.parse(); + auto result = expr->evaluate({.manager = &manager, .context = {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto color = std::get(result); + EXPECT_EQ(color.red(), 255); + EXPECT_EQ(color.green(), 0); + EXPECT_EQ(color.blue(), 0); + } + + { + Parser parser("@TestNumber"); + auto expr = parser.parse(); + auto result = expr->evaluate({.manager = &manager, .context = {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 5.0); + EXPECT_EQ(length.unit, ""); + } +} + +// Test arithmetic operations +TEST_F(ParserTest, ParseArithmeticOperations) +{ + { + Parser parser("10 + 5"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 15.0); + EXPECT_EQ(length.unit, ""); + } + + { + Parser parser("10px + 5px"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 15.0); + EXPECT_EQ(length.unit, "px"); + } + + { + Parser parser("10 - 5"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 5.0); + EXPECT_EQ(length.unit, ""); + } + + { + Parser parser("10px - 5px"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 5.0); + EXPECT_EQ(length.unit, "px"); + } + + { + Parser parser("10 * 5"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 50.0); + EXPECT_EQ(length.unit, ""); + } + + { + Parser parser("10px * 2"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 20.0); + EXPECT_EQ(length.unit, "px"); + } + + { + Parser parser("10 / 2"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 5.0); + EXPECT_EQ(length.unit, ""); + } + + { + Parser parser("10px / 2"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 5.0); + EXPECT_EQ(length.unit, "px"); + } +} + +// Test complex expressions +TEST_F(ParserTest, ParseComplexExpressions) +{ + { + Parser parser("(10 + 5) * 2"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 30.0); + EXPECT_EQ(length.unit, ""); + } + + { + Parser parser("(10px + 5px) * 2"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 30.0); + EXPECT_EQ(length.unit, "px"); + } + + { + Parser parser("@TestParam + 5px"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 15.0); + EXPECT_EQ(length.unit, "px"); + } + + { + Parser parser("@TestParam * @TestNumber"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 50.0); + EXPECT_EQ(length.unit, "px"); + } +} + +// Test unary operations +TEST_F(ParserTest, ParseUnaryOperations) +{ + { + Parser parser("+10"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 10.0); + EXPECT_EQ(length.unit, ""); + } + + { + Parser parser("-10"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, -10.0); + EXPECT_EQ(length.unit, ""); + } + + { + Parser parser("-10px"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, -10.0); + EXPECT_EQ(length.unit, "px"); + } +} + +// Test function calls +TEST_F(ParserTest, ParseFunctionCalls) +{ + { + Parser parser("lighten(#ff0000, 20)"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto color = std::get(result); + // The result should be lighter than the original red + EXPECT_GT(color.lightness(), QColor("#ff0000").lightness()); + } + + { + Parser parser("darken(#ff0000, 20)"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto color = std::get(result); + // The result should be darker than the original red + EXPECT_LT(color.lightness(), QColor("#ff0000").lightness()); + } + + { + Parser parser("lighten(@TestColor, 20)"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto color = std::get(result); + // The result should be lighter than the original red + EXPECT_GT(color.lightness(), QColor("#ff0000").lightness()); + } +} + +// Test error cases +TEST_F(ParserTest, ParseErrors) +{ + // Invalid color format + EXPECT_THROW( + { + Parser parser("#invalid"); + parser.parse(); + }, + Base::ParserError); + + // Invalid RGB format + EXPECT_THROW( + { + Parser parser("rgb(invalid)"); + parser.parse(); + }, + Base::ParserError); + + // Missing closing parenthesis + EXPECT_THROW( + { + Parser parser("(10 + 5"); + parser.parse(); + }, + Base::ParserError); + + // Invalid function + EXPECT_THROW( + { + Parser parser("invalid()"); + auto expr = parser.parse(); + expr->evaluate({&manager, {}}); + }, + Base::ExpressionError); + + // Division by zero + EXPECT_THROW( + { + Parser parser("10 / 0"); + auto expr = parser.parse(); + expr->evaluate({&manager, {}}); + }, + Base::RuntimeError); + + // Unit mismatch + EXPECT_THROW( + { + Parser parser("10px + 5em"); + auto expr = parser.parse(); + expr->evaluate({&manager, {}}); + }, + Base::RuntimeError); + + // Unary operation on color + EXPECT_THROW( + { + Parser parser("-@TestColor"); + auto expr = parser.parse(); + expr->evaluate({&manager, {}}); + }, + Base::ExpressionError); + + // Function with wrong number of arguments + EXPECT_THROW( + { + Parser parser("lighten(#ff0000)"); + auto expr = parser.parse(); + expr->evaluate({&manager, {}}); + }, + Base::ExpressionError); + + // Function with wrong argument type + EXPECT_THROW( + { + Parser parser("lighten(10px, 20)"); + auto expr = parser.parse(); + expr->evaluate({&manager, {}}); + }, + Base::ExpressionError); +} + +// Test whitespace handling +TEST_F(ParserTest, ParseWhitespace) +{ + { + Parser parser(" 10 + 5 "); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 15.0); + EXPECT_EQ(length.unit, ""); + } + + { + Parser parser("10px+5px"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 15.0); + EXPECT_EQ(length.unit, "px"); + } + + { + Parser parser("rgb(255,0,0)"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto color = std::get(result); + EXPECT_EQ(color.red(), 255); + EXPECT_EQ(color.green(), 0); + EXPECT_EQ(color.blue(), 0); + } +} + +// Test edge cases +TEST_F(ParserTest, ParseEdgeCases) +{ + // Empty input + EXPECT_THROW( + { + Parser parser(""); + parser.parse(); + }, + Base::ParserError); + + // Just whitespace + EXPECT_THROW( + { + Parser parser(" "); + parser.parse(); + }, + Base::ParserError); + + // Single number + { + Parser parser("42"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 42.0); + EXPECT_EQ(length.unit, ""); + } + + // Single color + { + Parser parser("#ff0000"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto color = std::get(result); + EXPECT_EQ(color.red(), 255); + EXPECT_EQ(color.green(), 0); + EXPECT_EQ(color.blue(), 0); + } + + // Single parameter reference + { + Parser parser("@TestParam"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 10.0); + EXPECT_EQ(length.unit, "px"); + } +} + +// Test operator precedence +TEST_F(ParserTest, ParseOperatorPrecedence) +{ + { + Parser parser("2 + 3 * 4"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 14.0); // 2 + (3 * 4) = 2 + 12 = 14 + EXPECT_EQ(length.unit, ""); + } + + { + Parser parser("10 - 3 * 2"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 4.0); // 10 - (3 * 2) = 10 - 6 = 4 + EXPECT_EQ(length.unit, ""); + } + + { + Parser parser("20 / 4 + 3"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 8.0); // (20 / 4) + 3 = 5 + 3 = 8 + EXPECT_EQ(length.unit, ""); + } +} + +// Test nested parentheses +TEST_F(ParserTest, ParseNestedParentheses) +{ + { + Parser parser("((2 + 3) * 4)"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 20.0); // (5) * 4 = 20 + EXPECT_EQ(length.unit, ""); + } + + { + Parser parser("(10 - (3 + 2)) * 2"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + auto length = std::get(result); + EXPECT_DOUBLE_EQ(length.value, 10.0); // (10 - 5) * 2 = 5 * 2 = 10 + EXPECT_EQ(length.unit, ""); + } +} diff --git a/tests/src/Gui/StyleParameters/StyleParametersApplicationTest.cpp b/tests/src/Gui/StyleParameters/StyleParametersApplicationTest.cpp new file mode 100644 index 0000000000..cacdfeea91 --- /dev/null +++ b/tests/src/Gui/StyleParameters/StyleParametersApplicationTest.cpp @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kacper Donat * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD 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 * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#include + +#include "src/App/InitApplication.h" + +#include +#include + +using namespace Gui; + +class StyleParametersApplicationTest: public ::testing::Test +{ +protected: + static Application* app; + + static void SetUpTestSuite() + { + tests::initApplication(); + app = new Application(true); + } + + void SetUp() override + { + auto styleParamManager = app->styleParameterManager(); + + styleParamManager->addSource(new StyleParameters::InMemoryParameterSource( + { + {.name = "ColorPrimary", .value = "#ff0000"}, + {.name = "FontSize", .value = "12px"}, + {.name = "BoxWidth", .value = "100px"}, + }, + {.name = "Fixture Source"})); + } +}; + +Application* StyleParametersApplicationTest::app = {}; + +// Test for replacing variables in QSS string +TEST_F(StyleParametersApplicationTest, ReplaceVariablesInQss) +{ + QString qss = "QWidget { color: @ColorPrimary; font-size: @FontSize; width: @BoxWidth; }"; + QString result = app->replaceVariablesInQss(qss); + + EXPECT_EQ(result.toStdString(), "QWidget { color: #ff0000; font-size: 12px; width: 100px; }"); +} + +// Test if unknown variables remain unchanged +TEST_F(StyleParametersApplicationTest, ReplaceVariablesInQssWithUnknownVariable) +{ + QString qss = "QWidget { color: @UnknownColor; margin: 10px; }"; + QString result = app->replaceVariablesInQss(qss); + + EXPECT_EQ(result.toStdString(), "QWidget { color: ; margin: 10px; }"); +} + +// Test with an empty QSS string +TEST_F(StyleParametersApplicationTest, ReplaceVariablesInQssWithEmptyString) +{ + QString qss = ""; + QString result = app->replaceVariablesInQss(qss); + + EXPECT_EQ(result.toStdString(), ""); +} + +// Test replacing multiple occurrences of the same variable +TEST_F(StyleParametersApplicationTest, ReplaceVariablesInQssWithMultipleOccurrences) +{ + QString qss = "QWidget { color: @ColorPrimary; background: @ColorPrimary; }"; + QString result = app->replaceVariablesInQss(qss); + + EXPECT_EQ(result.toStdString(), "QWidget { color: #ff0000; background: #ff0000; }"); +}