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; }"); +}