Merge pull request #27428 from kadet1090/add-style-parameter-tuples

Gui: Add Tuple type to StyleParameters
This commit is contained in:
Chris Hennes
2026-02-25 08:28:50 -06:00
committed by GitHub
9 changed files with 1723 additions and 238 deletions
+2
View File
@@ -1402,6 +1402,7 @@ SET(FreeCADGui_CPP_SRCS
StartupProcess.cpp
TransactionObject.cpp
ToolHandler.cpp
StyleParameters/Value.cpp
StyleParameters/Parser.cpp
StyleParameters/ParameterManager.cpp
)
@@ -1447,6 +1448,7 @@ SET(FreeCADGui_SRCS
StatusBarLabel.h
TransactionObject.h
ToolHandler.h
StyleParameters/Value.h
StyleParameters/Parser.h
StyleParameters/ParameterManager.h
)
+3
View File
@@ -66,6 +66,9 @@ QString typeOfTokenValue(const Gui::StyleParameters::Value& value)
},
[](const Base::Color&) {
return QWidget::tr("Color");
},
[](const Gui::StyleParameters::Tuple&) {
return QWidget::tr("Tuple");
}
},
value
@@ -28,7 +28,6 @@
#include <fstream>
#include <yaml-cpp/yaml.h>
#include <QColor>
#include <QRegularExpression>
#include <QString>
#include <ranges>
@@ -42,72 +41,6 @@ FC_LOG_LEVEL_INIT("Gui", true, true)
namespace Gui::StyleParameters
{
Numeric Numeric::operator+(const Numeric& rhs) const
{
ensureEqualUnits(rhs);
return {value + rhs.value, unit};
}
Numeric Numeric::operator-(const Numeric& rhs) const
{
ensureEqualUnits(rhs);
return {value - rhs.value, unit};
}
Numeric Numeric::operator-() const
{
return {-value, unit};
}
Numeric Numeric::operator/(const Numeric& 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};
}
Numeric Numeric::operator*(const Numeric& rhs) const
{
if (rhs.unit.empty() || unit.empty()) {
return {value * rhs.value, unit};
}
ensureEqualUnits(rhs);
return {value * rhs.value, unit};
}
void Numeric::ensureEqualUnits(const Numeric& 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<Numeric>(*this)) {
auto [value, unit] = std::get<Numeric>(*this);
return fmt::format("{}{}", value, unit);
}
if (std::holds_alternative<Base::Color>(*this)) {
auto color = std::get<Base::Color>(*this);
return fmt::format("#{:0>6x}", color.getPackedRGB() >> 8); // NOLINT(*-magic-numbers)
}
return std::get<std::string>(*this);
}
ParameterSource::ParameterSource(const Metadata& metadata)
: metadata(metadata)
{}
+2 -79
View File
@@ -28,14 +28,13 @@
#include <optional>
#include <set>
#include <string>
#include <vector>
#include <QColor>
#include <App/Application.h>
#include <Base/Bitmask.h>
#include <Base/Parameter.h>
#include "Value.h"
// That macro uses inline const because some older compilers to not properly support constexpr
// for std::string. It should be changed into static constepxr once we migrate to newer compiler.
#define DEFINE_STYLE_PARAMETER(_name_, _defaultValue_) \
@@ -50,82 +49,6 @@ 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 Numeric
{
/// 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++}
* Numeric a { 10, "px" };
* Numeric b { 5, "px" };
*
* Numeric differentUnit { 3, "rem" }
* Numeric 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
* @{
*/
Numeric operator+(const Numeric& rhs) const;
Numeric operator-(const Numeric& rhs) const;
Numeric operator-() const;
Numeric operator/(const Numeric& rhs) const;
Numeric operator*(const Numeric& rhs) const;
/// @}
private:
void ensureEqualUnits(const Numeric& 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<Numeric, Base::Color, std::string>
{
using std::variant<Numeric, Base::Color, std::string>::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;
};
/**
* @brief A structure to define parameters which can be referenced in the code.
*
+180 -90
View File
@@ -28,9 +28,7 @@
#include <Base/Tools.h>
#include <QColor>
#include <QRegularExpression>
#include <QString>
#include <ranges>
#include <algorithm>
#include <variant>
namespace Gui::StyleParameters
@@ -53,29 +51,20 @@ Value Color::evaluate([[maybe_unused]] const EvaluationContext& context) const
Value FunctionCall::evaluate(const EvaluationContext& context) const
{
const auto lightenOrDarken = [this](const EvaluationContext& context) -> Value {
if (arguments.size() != 2) {
THROWM(
Base::ExpressionError,
fmt::format("Function '{}' expects 2 arguments, got {}", functionName, arguments.size())
);
}
auto argsValue = arguments.evaluate(context);
const auto& args = argsValue.get<Tuple>();
auto colorArg = arguments[0]->evaluate(context);
auto amountArg = arguments[1]->evaluate(context);
const auto lightenOrDarken = [this, &args]() -> Value {
auto resolved = ArgumentParser {{"color"}, {"amount"}}.resolve(args);
if (!std::holds_alternative<Base::Color>(colorArg)) {
THROWM(Base::ExpressionError, fmt::format("'{}' is not supported for colors", functionName));
}
auto color = std::get<Base::Color>(colorArg).asValue<QColor>();
auto color = resolved.get<Base::Color>("color").asValue<QColor>();
// 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<int>(std::get<Numeric>(amountArg).value);
auto amount = 100 + static_cast<int>(resolved.get<Numeric>("amount").value);
if (functionName == "lighten") {
return Base::Color::fromValue(color.lighter(amount));
@@ -88,36 +77,12 @@ Value FunctionCall::evaluate(const EvaluationContext& context) const
return {};
};
const auto blend = [this](const EvaluationContext& context) -> Value {
if (arguments.size() != 3) {
THROWM(
Base::ExpressionError,
fmt::format("Function '{}' expects 3 arguments, got {}", functionName, arguments.size())
);
}
const auto blend = [&args]() -> Value {
auto resolved = ArgumentParser {{"from"}, {"to"}, {"amount"}}.resolve(args);
auto firstColorArg = arguments[0]->evaluate(context);
auto secondColorArg = arguments[1]->evaluate(context);
auto amountArg = arguments[2]->evaluate(context);
if (!std::holds_alternative<Base::Color>(firstColorArg)) {
THROWM(
Base::ExpressionError,
fmt::format("first argument of '{}' must be color", functionName)
);
}
if (!std::holds_alternative<Base::Color>(secondColorArg)) {
THROWM(
Base::ExpressionError,
fmt::format("second argument of '{}' must be color", functionName)
);
}
auto firstColor = std::get<Base::Color>(firstColorArg);
auto secondColor = std::get<Base::Color>(secondColorArg);
auto amount = Base::fromPercent(static_cast<long>(std::get<Numeric>(amountArg).value));
auto firstColor = resolved.get<Base::Color>("from");
auto secondColor = resolved.get<Base::Color>("to");
auto amount = Base::fromPercent(static_cast<long>(resolved.get<Numeric>("amount").value));
return Base::Color(
(1 - amount) * firstColor.r + amount * secondColor.r,
@@ -126,15 +91,14 @@ Value FunctionCall::evaluate(const EvaluationContext& context) const
);
};
std::map<std::string, std::function<Value(const EvaluationContext&)>> functions = {
std::map<std::string, std::function<Value()>> functions = {
{"lighten", lightenOrDarken},
{"darken", lightenOrDarken},
{"blend", blend},
};
if (functions.contains(functionName)) {
auto function = functions.at(functionName);
return function(context);
return functions.at(functionName)();
}
THROWM(Base::ExpressionError, fmt::format("Unknown function '{}'", functionName));
@@ -145,45 +109,65 @@ Value BinaryOp::evaluate(const EvaluationContext& context) const
Value lval = left->evaluate(context);
Value rval = right->evaluate(context);
if (!std::holds_alternative<Numeric>(lval) || !std::holds_alternative<Numeric>(rval)) {
THROWM(Base::ExpressionError, "Math operations are supported only on lengths");
}
auto lhs = std::get<Numeric>(lval);
auto rhs = std::get<Numeric>(rval);
switch (op) {
case Operator::Add:
return lhs + rhs;
return lval + rval;
case Operator::Subtract:
return lhs - rhs;
return lval - rval;
case Operator::Multiply:
return lhs * rhs;
return lval * rval;
case Operator::Divide:
return lhs / rhs;
return lval / rval;
default:
THROWM(Base::ExpressionError, "Unknown operator");
}
}
Value TupleLiteral::evaluate(const EvaluationContext& context) const
{
Tuple tuple;
for (const auto& elem : elements) {
tuple.elements.push_back(
{elem.name, std::make_shared<const Value>(elem.expression->evaluate(context))}
);
}
return tuple;
}
Value UnaryOp::evaluate(const EvaluationContext& context) const
{
Value val = operand->evaluate(context);
if (std::holds_alternative<Base::Color>(val)) {
THROWM(Base::ExpressionError, "Unary operations on colors are not supported");
}
auto v = std::get<Numeric>(val);
switch (op) {
case Operator::Add:
return v;
return val;
case Operator::Subtract:
return -v;
return -val;
default:
THROWM(Base::ExpressionError, "Unknown unary operator");
}
}
Value MemberAccess::evaluate(const EvaluationContext& context) const
{
Value val = object->evaluate(context);
if (!val.holds<Tuple>()) {
THROWM(Base::ExpressionError, "Member access requires a tuple");
}
const auto& tuple = val.get<Tuple>();
if (const Value* found = tuple.find(member)) {
return *found;
}
if (std::ranges::all_of(member, ::isdigit)) {
return tuple.at(std::stoul(member));
}
THROWM(Base::ExpressionError, fmt::format("Tuple has no member '{}'", member));
}
std::unique_ptr<Expr> Parser::parse()
{
auto expr = parseExpression();
@@ -245,23 +229,54 @@ std::unique_ptr<Expr> Parser::parseFactor()
Operator op = (input[pos - 1] == '+') ? Operator::Add : Operator::Subtract;
return std::make_unique<UnaryOp>(op, parseFactor());
}
std::unique_ptr<Expr> expr;
if (match('(')) {
auto expr = parseExpression();
if (!match(')')) {
THROWM(Base::ParserError, fmt::format("Expected ')', got '{}'", input[pos]));
// Disambiguation: tuple vs grouped expression
// 1. If we see `identifier:` pattern → definitely a tuple
if (peekNamedElement()) {
expr = parseTuple();
}
else {
// 2. Otherwise parse first expression
expr = parseExpression();
skipWhitespace();
// If followed by `,` → reinterpret as tuple with this as first element
if (pos < input.size() && input[pos] == ',') {
++pos;
TupleLiteral::Element first;
first.expression = std::move(expr);
expr = parseTuple(std::move(first));
}
else {
// If followed by `)` → grouped expression (backward compatible)
if (!match(')')) {
THROWM(Base::ParserError, fmt::format("Expected ')', got '{}'", input[pos]));
}
}
}
return expr;
}
if (peekColor()) {
return parseColor();
else if (peekColor()) {
expr = parseColor();
}
if (peekParameter()) {
return parseParameter();
else if (peekParameter()) {
expr = parseParameter();
}
if (peekFunction()) {
return parseFunctionCall();
else if (peekFunction()) {
expr = parseFunctionCall();
}
return parseNumber();
else {
expr = parseNumber();
}
while (pos < input.size() && input[pos] == '.') {
++pos;
expr = std::make_unique<MemberAccess>(std::move(expr), parseMember());
}
return expr;
}
bool Parser::peekColor()
@@ -386,21 +401,84 @@ std::unique_ptr<Expr> Parser::parseFunctionCall()
THROWM(Base::ParserError, fmt::format("Expected '(' after function name, got '{}'", input[pos]));
}
std::vector<std::unique_ptr<Expr>> arguments;
if (!match(')')) {
do { // NOLINT(*-avoid-do-while)
arguments.push_back(parseExpression());
} while (match(','));
auto arguments = parseTuple();
return std::make_unique<FunctionCall>(functionName, std::move(*arguments));
}
if (!match(')')) {
THROWM(
Base::ParserError,
fmt::format("Expected ')' after function arguments, got '{}'", input[pos])
);
}
bool Parser::peekNamedElement()
{
size_t saved = pos;
skipWhitespace();
// Check for `identifier :` pattern
if (pos >= input.size() || !isalpha(input[pos])) {
pos = saved;
return false;
}
return std::make_unique<FunctionCall>(functionName, std::move(arguments));
// Skip identifier characters
while (pos < input.size() && (isalnum(input[pos]) || input[pos] == '_')) {
++pos;
}
// Skip whitespace between identifier and colon
while (pos < input.size() && isspace(input[pos])) {
++pos;
}
// Check for colon
bool found = pos < input.size() && input[pos] == ':';
pos = saved;
return found;
}
std::unique_ptr<TupleLiteral> Parser::parseTuple(std::optional<TupleLiteral::Element> firstElement)
{
auto tuple = std::make_unique<TupleLiteral>();
const auto parseElement = [this]() {
TupleLiteral::Element elem;
// Check if this element has a name
if (peekNamedElement()) {
skipWhitespace();
size_t start = pos;
while (pos < input.size() && (isalnum(input[pos]) || input[pos] == '_')) {
++pos;
}
elem.name = input.substr(start, pos - start);
skipWhitespace();
++pos; // consume ':'
}
elem.expression = parseExpression();
return elem;
};
if (firstElement) {
// Called from unnamed-element path: first element already parsed, comma already consumed
tuple->elements.push_back(std::move(*firstElement));
// Parse the second element immediately (comma was already consumed by caller)
tuple->elements.push_back(parseElement());
}
// Parse remaining elements
while (true) {
skipWhitespace();
if (pos < input.size() && input[pos] == ')') {
++pos;
return tuple;
}
if (!tuple->elements.empty() && !match(',')) {
if (pos >= input.size()) {
THROWM(Base::ParserError, "Expected ')' to close tuple");
}
THROWM(Base::ParserError, fmt::format("Expected ',' or ')' in tuple, got '{}'", input[pos]));
}
tuple->elements.push_back(parseElement());
}
}
int Parser::parseInt()
@@ -446,6 +524,18 @@ std::string Parser::parseUnit()
return input.substr(start, pos - start);
}
std::string Parser::parseMember()
{
size_t start = pos;
while (pos < input.size() && (isalnum(input[pos]) || input[pos] == '_')) {
++pos;
}
if (start == pos) {
THROWM(Base::ParserError, "Expected member name after '.'");
}
return input.substr(start, pos - start);
}
bool Parser::match(char expected)
{
skipWhitespace();
+33 -2
View File
@@ -91,12 +91,25 @@ struct GuiExport Color: public Expr
Value evaluate([[maybe_unused]] const EvaluationContext& context) const override;
};
struct GuiExport TupleLiteral: public Expr
{
struct Element
{
std::optional<std::string> name;
std::unique_ptr<Expr> expression;
};
std::vector<Element> elements;
Value evaluate(const EvaluationContext& context) const override;
};
struct GuiExport FunctionCall: public Expr
{
std::string functionName;
std::vector<std::unique_ptr<Expr>> arguments;
TupleLiteral arguments;
FunctionCall(std::string functionName, std::vector<std::unique_ptr<Expr>> arguments)
FunctionCall(std::string functionName, TupleLiteral arguments)
: functionName(std::move(functionName))
, arguments(std::move(arguments))
{}
@@ -131,6 +144,19 @@ struct GuiExport UnaryOp: public Expr
Value evaluate(const EvaluationContext& context) const override;
};
struct GuiExport MemberAccess: public Expr
{
std::unique_ptr<Expr> object;
std::string member;
MemberAccess(std::unique_ptr<Expr> object, std::string member)
: object(std::move(object))
, member(std::move(member))
{}
Value evaluate(const EvaluationContext& context) const override;
};
class GuiExport Parser
{
static constexpr auto rgbFunction = "rgb(";
@@ -160,6 +186,11 @@ private:
int parseInt();
std::unique_ptr<Expr> parseNumber();
std::string parseUnit();
std::string parseMember();
bool peekNamedElement();
std::unique_ptr<TupleLiteral> parseTuple(
std::optional<TupleLiteral::Element> firstElement = std::nullopt
);
bool match(char expected);
void skipWhitespace();
};
+377
View File
@@ -0,0 +1,377 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kacper Donat <[email protected]> *
* *
* 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 *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#include "Value.h"
#include <functional>
#include <ranges>
#include <fmt/ranges.h>
namespace Gui::StyleParameters
{
Numeric Numeric::operator+(const Numeric& rhs) const
{
ensureEqualUnits(rhs);
return {value + rhs.value, unit};
}
Numeric Numeric::operator-(const Numeric& rhs) const
{
ensureEqualUnits(rhs);
return {value - rhs.value, unit};
}
Numeric Numeric::operator-() const
{
return {-value, unit};
}
Numeric Numeric::operator/(const Numeric& 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};
}
Numeric Numeric::operator*(const Numeric& rhs) const
{
if (rhs.unit.empty() || unit.empty()) {
return {value * rhs.value, unit};
}
ensureEqualUnits(rhs);
return {value * rhs.value, unit};
}
void Numeric::ensureEqualUnits(const Numeric& 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 (holds<Numeric>()) {
auto [value, unit] = get<Numeric>();
return fmt::format("{}{}", value, unit);
}
if (holds<Base::Color>()) {
auto color = get<Base::Color>();
return fmt::format("#{:0>6x}", color.getPackedRGB() >> 8); // NOLINT(*-magic-numbers)
}
if (holds<Tuple>()) {
const auto& tuple = get<Tuple>();
std::vector<std::string> parts;
parts.reserve(tuple.elements.size());
for (const auto& [name, value] : tuple.elements) {
std::string string;
if (name) {
string = fmt::format("{}: {}", *name, value->toString());
}
else {
string = value->toString();
}
parts.push_back(std::move(string));
}
return fmt::format("({})", fmt::join(parts, ", "));
}
return get<std::string>();
}
namespace
{
Tuple elementWise(const Tuple& lhs, const Tuple& rhs, auto op)
{
Tuple result;
std::vector<bool> rhsUsed(rhs.size(), false);
// Phase 1: LHS named elements — match by name in RHS
for (size_t i = 0; i < lhs.size(); ++i) {
if (!lhs.elements[i].name) {
continue;
}
const auto& name = *lhs.elements[i].name;
bool matched = false;
for (size_t j = 0; j < rhs.size(); ++j) {
if (!rhsUsed[j] && rhs.elements[j].name && *rhs.elements[j].name == name) {
rhsUsed[j] = true;
matched = true;
result.elements.emplace_back(
lhs.elements[i].name,
std::make_shared<const Value>(op(lhs.at(i), rhs.at(j)))
);
break;
}
}
if (!matched) {
result.elements.push_back(lhs.elements[i]);
}
}
// Phase 2: RHS named elements not yet matched
for (size_t j = 0; j < rhs.size(); ++j) {
if (rhsUsed[j] || !rhs.elements[j].name) {
continue;
}
rhsUsed[j] = true;
result.elements.push_back(rhs.elements[j]);
}
// Phase 3: Unnamed elements — match positionally
std::vector<size_t> lhsUnnamed;
std::vector<size_t> rhsUnnamed;
for (size_t i = 0; i < lhs.size(); ++i) {
if (!lhs.elements[i].name) {
lhsUnnamed.push_back(i);
}
}
for (size_t j = 0; j < rhs.size(); ++j) {
if (!rhsUsed[j]) {
rhsUnnamed.push_back(j);
}
}
size_t paired = std::min(lhsUnnamed.size(), rhsUnnamed.size());
for (size_t k = 0; k < paired; ++k) {
result.elements.emplace_back(
std::nullopt,
std::make_shared<const Value>(op(lhs.at(lhsUnnamed[k]), rhs.at(rhsUnnamed[k])))
);
}
for (size_t k = paired; k < lhsUnnamed.size(); ++k) {
result.elements.push_back(lhs.elements[lhsUnnamed[k]]);
}
for (size_t k = paired; k < rhsUnnamed.size(); ++k) {
result.elements.push_back(rhs.elements[rhsUnnamed[k]]);
}
return result;
}
Tuple scalarBroadcast(const Tuple& tuple, const Value& scalar, auto op)
{
Tuple result;
for (size_t i = 0; i < tuple.size(); ++i) {
result.elements.emplace_back(
tuple.elements[i].name,
std::make_shared<const Value>(op(tuple.at(i), scalar))
);
}
return result;
}
} // namespace
Value Value::operator+(const Value& rhs) const
{
if (holds<Numeric>() && rhs.holds<Numeric>()) {
return get<Numeric>() + rhs.get<Numeric>();
}
if (holds<Tuple>() && rhs.holds<Tuple>()) {
return elementWise(get<Tuple>(), rhs.get<Tuple>(), std::plus<Value> {});
}
THROWM(Base::ExpressionError, "Addition requires two numerics or two tuples of equal size");
}
Value Value::operator-(const Value& rhs) const
{
if (holds<Numeric>() && rhs.holds<Numeric>()) {
return get<Numeric>() - rhs.get<Numeric>();
}
if (holds<Tuple>() && rhs.holds<Tuple>()) {
return elementWise(get<Tuple>(), rhs.get<Tuple>(), std::minus<Value> {});
}
THROWM(Base::ExpressionError, "Subtraction requires two numerics or two tuples of equal size");
}
Value Value::operator*(const Value& rhs) const
{
if (holds<Numeric>() && rhs.holds<Numeric>()) {
return get<Numeric>() * rhs.get<Numeric>();
}
if (holds<Tuple>() && rhs.holds<Numeric>()) {
return scalarBroadcast(get<Tuple>(), rhs, std::multiplies<Value> {});
}
if (holds<Numeric>() && rhs.holds<Tuple>()) {
return scalarBroadcast(rhs.get<Tuple>(), *this, [](const Value& elem, const Value& scalar) {
return elem * scalar;
});
}
THROWM(Base::ExpressionError, "Multiplication requires two numerics or tuple with scalar");
}
Value Value::operator/(const Value& rhs) const
{
if (holds<Numeric>() && rhs.holds<Numeric>()) {
return get<Numeric>() / rhs.get<Numeric>();
}
if (holds<Tuple>() && rhs.holds<Numeric>()) {
return scalarBroadcast(get<Tuple>(), rhs, std::divides<Value> {});
}
THROWM(Base::ExpressionError, "Division requires two numerics or tuple divided by scalar");
}
Value Value::operator-() const
{
if (holds<Numeric>()) {
return -get<Numeric>();
}
if (holds<Tuple>()) {
Tuple result;
const auto& tuple = get<Tuple>();
for (size_t i = 0; i < tuple.size(); ++i) {
result.elements.emplace_back(
tuple.elements[i].name,
std::make_shared<const Value>(-tuple.at(i))
);
}
return result;
}
THROWM(Base::ExpressionError, "Unary negation requires a numeric or tuple value");
}
const Value& Tuple::at(size_t index) const
{
if (index >= elements.size()) {
THROWM(
Base::RuntimeError,
fmt::format("Tuple index {} out of range (size {})", index, elements.size())
);
}
return *elements[index].value;
}
const Value* Tuple::find(const std::string& name) const
{
auto it = std::ranges::find_if(elements, [&](const Element& elem) {
return elem.name && *elem.name == name;
});
return it != elements.end() ? it->value.get() : nullptr;
}
size_t Tuple::size() const
{
return elements.size();
}
ArgumentParser::ArgumentParser(std::initializer_list<ParamDef> params)
: params_(params)
{}
Tuple ArgumentParser::resolve(const Tuple& args) const
{
// Slots for resolved values, one per declared parameter
std::vector<std::shared_ptr<const Value>> slots(params_.size());
// 1. Match named arguments to their declared parameter
for (const auto& elem : args.elements) {
if (!elem.name) {
continue;
}
auto it = std::ranges::find_if(params_, [&](const ParamDef& p) {
return p.name == *elem.name;
});
if (it == params_.end()) {
THROWM(Base::ExpressionError, fmt::format("Unknown argument '{}'", *elem.name));
}
auto index = static_cast<size_t>(std::distance(params_.begin(), it));
if (slots[index]) {
THROWM(Base::ExpressionError, fmt::format("Duplicate argument '{}'", *elem.name));
}
slots[index] = elem.value;
}
// 2. Fill remaining slots with unnamed arguments in order
auto unnamed = args.elements
| std::views::filter([](const Tuple::Element& e) { return !e.name.has_value(); });
auto unnamedIt = unnamed.begin();
for (size_t i = 0; i < params_.size(); ++i) {
if (slots[i]) {
continue; // already claimed by name
}
if (unnamedIt != unnamed.end()) {
slots[i] = unnamedIt->value;
++unnamedIt;
}
else if (params_[i].defaultValue) {
slots[i] = std::make_shared<const Value>(*params_[i].defaultValue);
}
else {
THROWM(
Base::ExpressionError,
fmt::format("Missing required argument '{}'", params_[i].name)
);
}
}
// 3. Check for excess positional arguments
if (unnamedIt != unnamed.end()) {
THROWM(
Base::ExpressionError,
fmt::format("Too many arguments: expected {}, got {}", params_.size(), args.size())
);
}
// 4. Build result Tuple with all elements named per signature
Tuple result;
for (size_t i = 0; i < params_.size(); ++i) {
result.elements.emplace_back(params_[i].name, std::move(slots[i]));
}
return result;
}
} // namespace Gui::StyleParameters
+276
View File
@@ -0,0 +1,276 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kacper Donat <[email protected]> *
* *
* 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 *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#ifndef STYLEPARAMETERS_VALUE_H
#define STYLEPARAMETERS_VALUE_H
#include <memory>
#include <optional>
#include <string>
#include <type_traits>
#include <variant>
#include <vector>
#include <fmt/format.h>
#include <Base/Color.h>
#include <Base/Exception.h>
#include <FCGlobal.h>
namespace Gui::StyleParameters
{
/**
* @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 GuiExport Numeric
{
/// 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++}
* Numeric a { 10, "px" };
* Numeric b { 5, "px" };
*
* Numeric differentUnit { 3, "rem" }
* Numeric 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
* @{
*/
Numeric operator+(const Numeric& rhs) const;
Numeric operator-(const Numeric& rhs) const;
Numeric operator-() const;
Numeric operator/(const Numeric& rhs) const;
Numeric operator*(const Numeric& rhs) const;
/// @}
private:
void ensureEqualUnits(const Numeric& rhs) const;
};
// Forward declaration: Tuple::Element uses shared_ptr<const Value> to break the
// circular dependency (Value contains Tuple, Tuple elements contain Value).
struct Value;
/**
* @brief Represents a tuple of named or unnamed values.
*
* Tuples group related values into a single parameter using `(key: val1, val2)` syntax.
* Elements can optionally have names for named access.
*/
struct GuiExport Tuple
{
struct Element
{
std::optional<std::string> name;
std::shared_ptr<const Value> value;
};
std::vector<Element> elements;
/**
* @brief Returns the value at the given index (bounds-checked).
* @throws Base::RuntimeError if index is out of range.
*/
const Value& at(size_t index) const;
/**
* @brief Finds an element by name.
* @return Pointer to the value if found, nullptr otherwise.
*/
const Value* find(const std::string& name) const;
/**
* @brief Returns the number of elements in the tuple.
*/
size_t size() const;
/**
* @brief Gets a named element with type checking and user-friendly error messages.
*
* @throws Base::ExpressionError if the element is not found or has the wrong type.
*/
template<typename T>
const T& get(const std::string& name) const;
};
/**
* @brief This struct represents any valid value that can be used as the parameter value.
*
* The value can be one of four basic types:
* - Numbers / Lengths (so any length with optional unit) (Numeric)
* - Colors (Base::Color)
* - Any other generic expression (std::string)
* - Tuples of values (Tuple)
*
* As a rule, operations can be only performed over values of the same type.
*/
struct GuiExport Value: std::variant<Numeric, Base::Color, std::string, Tuple>
{
using std::variant<Numeric, Base::Color, std::string, Tuple>::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;
/**
* @brief Checks whether this value holds the given type.
*/
template<typename T>
bool holds() const
{
return std::holds_alternative<T>(*this);
}
/**
* @brief Gets the value of the given type.
*
* Throws std::bad_variant_access if the value does not hold the requested type.
*/
template<typename T>
const T& get() const
{
return std::get<T>(*this);
}
/**
* @name Arithmetic operators
*
* Element-wise operations for tuples of equal size, scalar broadcast for Tuple × Numeric.
* Delegates to Numeric operators for Numeric × Numeric.
* @{
*/
Value operator+(const Value& rhs) const;
Value operator-(const Value& rhs) const;
Value operator*(const Value& rhs) const;
Value operator/(const Value& rhs) const;
Value operator-() const;
/// @}
};
template<typename T>
constexpr const char* valueTypeName()
{
if constexpr (std::is_same_v<T, Numeric>) {
return "numeric";
}
else if constexpr (std::is_same_v<T, Base::Color>) {
return "color";
}
else if constexpr (std::is_same_v<T, std::string>) {
return "string";
}
else if constexpr (std::is_same_v<T, Tuple>) {
return "tuple";
}
return "<unknown>";
}
template<typename T>
const T& Tuple::get(const std::string& name) const
{
const Value* value = find(name);
if (!value) {
THROWM(Base::ExpressionError, fmt::format("Missing argument '{}'", name));
}
if (!value->holds<T>()) {
THROWM(
Base::ExpressionError,
fmt::format("Argument '{}' must be {}, got {}", name, valueTypeName<T>(), value->toString())
);
}
return value->get<T>();
}
/**
* @brief Defines a single parameter in a function signature.
*
* Used with ArgumentParser to declare positional/named parameters with optional defaults.
*/
struct ParamDef
{
std::string name;
std::optional<Value> defaultValue = {};
};
/**
* @brief Resolves a Tuple of mixed positional/named arguments against a declared signature.
*
* Named arguments are matched by name, unnamed arguments fill remaining slots in declaration
* order, and defaults fill any still-empty slots. The result is a Tuple with all elements named
* per the signature.
*
* Example:
* @code{.cpp}
* auto resolved = ArgumentParser{{"color"}, {"amount", Numeric{20, ""}}}.resolve(args);
* auto& color = resolved.get<Base::Color>("color");
* auto& amount = resolved.get<Numeric>("amount");
* @endcode
*/
class GuiExport ArgumentParser
{
public:
ArgumentParser(std::initializer_list<ParamDef> params);
Tuple resolve(const Tuple& args) const;
private:
std::vector<ParamDef> params_;
};
} // namespace Gui::StyleParameters
#endif // STYLEPARAMETERS_VALUE_H
@@ -41,6 +41,9 @@ protected:
{"TestParam", "10px"},
{"TestColor", "#ff0000"},
{"TestNumber", "5"},
{"TestTupleParam", "(left: 1px, right: 2px, top: 3px, bottom: 4px)"},
{"TestIndexedTuple", "(10, 20, 30)"},
{"TestNestedTuple", "((x: 1, y: 2), (x: 3, y: 4))"},
},
ParameterSource::Metadata {"Test Source"}
);
@@ -604,6 +607,268 @@ TEST_F(ParserTest, ParseOperatorPrecedence)
}
}
// Test unnamed tuple
TEST_F(ParserTest, ParseUnnamedTuple)
{
Parser parser("(10, 20, 30)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 3);
EXPECT_DOUBLE_EQ(std::get<Numeric>(tuple.at(0)).value, 10.0);
EXPECT_DOUBLE_EQ(std::get<Numeric>(tuple.at(1)).value, 20.0);
EXPECT_DOUBLE_EQ(std::get<Numeric>(tuple.at(2)).value, 30.0);
}
// Test named tuple
TEST_F(ParserTest, ParseNamedTuple)
{
Parser parser("(x: 10, y: 20)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 2);
auto* x = tuple.find("x");
ASSERT_NE(x, nullptr);
EXPECT_DOUBLE_EQ(std::get<Numeric>(*x).value, 10.0);
auto* y = tuple.find("y");
ASSERT_NE(y, nullptr);
EXPECT_DOUBLE_EQ(std::get<Numeric>(*y).value, 20.0);
}
// Test mixed named/unnamed tuple
TEST_F(ParserTest, ParseMixedTuple)
{
Parser parser("(x: 10, 20)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 2);
auto* x = tuple.find("x");
ASSERT_NE(x, nullptr);
EXPECT_DOUBLE_EQ(std::get<Numeric>(*x).value, 10.0);
EXPECT_DOUBLE_EQ(std::get<Numeric>(tuple.at(1)).value, 20.0);
}
// Test single named element is a tuple, not a grouped expression
TEST_F(ParserTest, ParseSingleNamedElementIsTuple)
{
Parser parser("(x: 10)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 1);
auto* x = tuple.find("x");
ASSERT_NE(x, nullptr);
EXPECT_DOUBLE_EQ(std::get<Numeric>(*x).value, 10.0);
}
// Test expressions inside tuple elements
TEST_F(ParserTest, ParseTupleWithExpressions)
{
Parser parser("(x: 10 + 5, y: @TestParam)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 2);
auto* x = tuple.find("x");
ASSERT_NE(x, nullptr);
EXPECT_DOUBLE_EQ(std::get<Numeric>(*x).value, 15.0);
auto* y = tuple.find("y");
ASSERT_NE(y, nullptr);
EXPECT_DOUBLE_EQ(std::get<Numeric>(*y).value, 10.0);
EXPECT_EQ(std::get<Numeric>(*y).unit, "px");
}
// Test mixed types in tuple
TEST_F(ParserTest, ParseTupleWithMixedTypes)
{
Parser parser("(color: #ff0000, opacity: 0.5)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 2);
auto* color = tuple.find("color");
ASSERT_NE(color, nullptr);
EXPECT_TRUE(std::holds_alternative<Base::Color>(*color));
auto c = std::get<Base::Color>(*color);
EXPECT_EQ(c.r, 1);
EXPECT_EQ(c.g, 0);
EXPECT_EQ(c.b, 0);
auto* opacity = tuple.find("opacity");
ASSERT_NE(opacity, nullptr);
EXPECT_DOUBLE_EQ(std::get<Numeric>(*opacity).value, 0.5);
}
// Test nested tuples
TEST_F(ParserTest, ParseNestedTuples)
{
Parser parser("((1, 2), (3, 4))");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& outer = result.get<Tuple>();
EXPECT_EQ(outer.size(), 2);
EXPECT_TRUE(outer.at(0).holds<Tuple>());
const auto& inner1 = outer.at(0).get<Tuple>();
EXPECT_EQ(inner1.size(), 2);
EXPECT_DOUBLE_EQ(std::get<Numeric>(inner1.at(0)).value, 1.0);
EXPECT_DOUBLE_EQ(std::get<Numeric>(inner1.at(1)).value, 2.0);
EXPECT_TRUE(outer.at(1).holds<Tuple>());
const auto& inner2 = outer.at(1).get<Tuple>();
EXPECT_EQ(inner2.size(), 2);
EXPECT_DOUBLE_EQ(std::get<Numeric>(inner2.at(0)).value, 3.0);
EXPECT_DOUBLE_EQ(std::get<Numeric>(inner2.at(1)).value, 4.0);
}
// Test complex expressions in tuple elements
TEST_F(ParserTest, ParseTupleWithComplexExpressions)
{
Parser parser("(x: lighten(#ff0000, 20), y: 10px * 2)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 2);
auto* x = tuple.find("x");
ASSERT_NE(x, nullptr);
EXPECT_TRUE(std::holds_alternative<Base::Color>(*x));
auto* y = tuple.find("y");
ASSERT_NE(y, nullptr);
EXPECT_DOUBLE_EQ(std::get<Numeric>(*y).value, 20.0);
EXPECT_EQ(std::get<Numeric>(*y).unit, "px");
}
// Test tuple toString roundtrip
TEST_F(ParserTest, TupleToString)
{
{
Parser parser("(x: 10, y: 20)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
auto str = result.toString();
EXPECT_EQ(str, "(x: 10, y: 20)");
}
{
Parser parser("(10, 20, 30)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
auto str = result.toString();
EXPECT_EQ(str, "(10, 20, 30)");
}
{
Parser parser("(x: 10, 20)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
auto str = result.toString();
EXPECT_EQ(str, "(x: 10, 20)");
}
}
// Test Value::holds and Value::get
TEST_F(ParserTest, ValueHoldsAndGet)
{
Value numericValue = Numeric {42, "px"};
EXPECT_TRUE(numericValue.holds<Numeric>());
EXPECT_FALSE(numericValue.holds<Tuple>());
EXPECT_DOUBLE_EQ(numericValue.get<Numeric>().value, 42.0);
Value colorValue = Base::Color(1.0, 0.0, 0.0);
EXPECT_TRUE(colorValue.holds<Base::Color>());
EXPECT_FALSE(colorValue.holds<Tuple>());
Value stringValue = std::string("hello");
EXPECT_TRUE(stringValue.holds<std::string>());
EXPECT_FALSE(stringValue.holds<Tuple>());
Parser parser("(1, 2)");
auto expr = parser.parse();
auto tupleValue = expr->evaluate({&manager, {}});
EXPECT_TRUE(tupleValue.holds<Tuple>());
EXPECT_FALSE(tupleValue.holds<Numeric>());
EXPECT_EQ(tupleValue.get<Tuple>().size(), 2);
}
// Test Tuple::at out of bounds
TEST_F(ParserTest, TupleAtOutOfBounds)
{
Parser parser("(10, 20)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
const auto& tuple = result.get<Tuple>();
EXPECT_NO_THROW(tuple.at(0));
EXPECT_NO_THROW(tuple.at(1));
EXPECT_THROW(tuple.at(2), Base::RuntimeError);
}
// Test Tuple::find nonexistent name
TEST_F(ParserTest, TupleFindNonexistent)
{
Parser parser("(x: 10, y: 20)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
const auto& tuple = result.get<Tuple>();
EXPECT_NE(tuple.find("x"), nullptr);
EXPECT_NE(tuple.find("y"), nullptr);
EXPECT_EQ(tuple.find("z"), nullptr);
}
// Test error: missing ')' in tuple
TEST_F(ParserTest, TupleMissingClosingParen)
{
EXPECT_THROW(
{
Parser parser("(x: 10, y: 20");
parser.parse();
},
Base::ParserError
);
}
// Test that grouped expressions still work (backward compatibility)
TEST_F(ParserTest, GroupedExpressionStillWorks)
{
{
Parser parser("(10 + 5) * 2");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(std::holds_alternative<Numeric>(result));
EXPECT_DOUBLE_EQ(std::get<Numeric>(result).value, 30.0);
}
{
Parser parser("(42)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(std::holds_alternative<Numeric>(result));
EXPECT_DOUBLE_EQ(std::get<Numeric>(result).value, 42.0);
}
}
// Test nested parentheses
TEST_F(ParserTest, ParseNestedParentheses)
{
@@ -627,3 +892,588 @@ TEST_F(ParserTest, ParseNestedParentheses)
EXPECT_EQ(length.unit, "");
}
}
// Test member access on tuple parameters (named)
TEST_F(ParserTest, MemberAccessNamed)
{
{
Parser parser("@TestTupleParam.left");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(std::holds_alternative<Numeric>(result));
auto length = std::get<Numeric>(result);
EXPECT_DOUBLE_EQ(length.value, 1.0);
EXPECT_EQ(length.unit, "px");
}
{
Parser parser("@TestTupleParam.right");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(std::holds_alternative<Numeric>(result));
auto length = std::get<Numeric>(result);
EXPECT_DOUBLE_EQ(length.value, 2.0);
EXPECT_EQ(length.unit, "px");
}
}
// Test member access on tuple parameters (indexed)
TEST_F(ParserTest, MemberAccessIndexed)
{
{
Parser parser("@TestIndexedTuple.0");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(std::holds_alternative<Numeric>(result));
EXPECT_DOUBLE_EQ(std::get<Numeric>(result).value, 10.0);
}
{
Parser parser("@TestIndexedTuple.2");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(std::holds_alternative<Numeric>(result));
EXPECT_DOUBLE_EQ(std::get<Numeric>(result).value, 30.0);
}
}
// Test member access on inline tuples
TEST_F(ParserTest, MemberAccessInlineTuple)
{
{
Parser parser("(x: 10, y: 20).x");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(std::holds_alternative<Numeric>(result));
EXPECT_DOUBLE_EQ(std::get<Numeric>(result).value, 10.0);
}
{
Parser parser("(10, 20, 30).1");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(std::holds_alternative<Numeric>(result));
EXPECT_DOUBLE_EQ(std::get<Numeric>(result).value, 20.0);
}
}
// Test member access in arithmetic expressions
TEST_F(ParserTest, MemberAccessInArithmetic)
{
Parser parser("@TestTupleParam.left + @TestTupleParam.right");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(std::holds_alternative<Numeric>(result));
auto length = std::get<Numeric>(result);
EXPECT_DOUBLE_EQ(length.value, 3.0);
EXPECT_EQ(length.unit, "px");
}
// Test chained member access on nested tuples
TEST_F(ParserTest, MemberAccessChained)
{
Parser parser("@TestNestedTuple.0.x");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(std::holds_alternative<Numeric>(result));
EXPECT_DOUBLE_EQ(std::get<Numeric>(result).value, 1.0);
}
// Test member access error cases
TEST_F(ParserTest, MemberAccessErrors)
{
// Named member not found
EXPECT_THROW(
{
Parser parser("@TestTupleParam.nonexistent");
auto expr = parser.parse();
expr->evaluate({&manager, {}});
},
Base::ExpressionError
);
// Index out of bounds
EXPECT_THROW(
{
Parser parser("@TestIndexedTuple.5");
auto expr = parser.parse();
expr->evaluate({&manager, {}});
},
Base::RuntimeError
);
// Member access on non-tuple
EXPECT_THROW(
{
Parser parser("@TestNumber.foo");
auto expr = parser.parse();
expr->evaluate({&manager, {}});
},
Base::ExpressionError
);
}
// Tuple arithmetic tests
TEST_F(ParserTest, TupleElementWiseAdd)
{
Parser parser("(1px, 2px) + (3px, 4px)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 2);
EXPECT_DOUBLE_EQ(tuple.at(0).get<Numeric>().value, 4.0);
EXPECT_EQ(tuple.at(0).get<Numeric>().unit, "px");
EXPECT_DOUBLE_EQ(tuple.at(1).get<Numeric>().value, 6.0);
EXPECT_EQ(tuple.at(1).get<Numeric>().unit, "px");
}
TEST_F(ParserTest, TupleElementWiseSubtract)
{
Parser parser("(10, 20) - (3, 7)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 2);
EXPECT_DOUBLE_EQ(tuple.at(0).get<Numeric>().value, 7.0);
EXPECT_DOUBLE_EQ(tuple.at(1).get<Numeric>().value, 13.0);
}
TEST_F(ParserTest, TupleScalarMultiplyTupleFirst)
{
Parser parser("(1px, 2px, 3px) * 2");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 3);
EXPECT_DOUBLE_EQ(tuple.at(0).get<Numeric>().value, 2.0);
EXPECT_EQ(tuple.at(0).get<Numeric>().unit, "px");
EXPECT_DOUBLE_EQ(tuple.at(1).get<Numeric>().value, 4.0);
EXPECT_DOUBLE_EQ(tuple.at(2).get<Numeric>().value, 6.0);
}
TEST_F(ParserTest, TupleScalarMultiplyScalarFirst)
{
Parser parser("2 * (1px, 2px, 3px)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 3);
EXPECT_DOUBLE_EQ(tuple.at(0).get<Numeric>().value, 2.0);
EXPECT_EQ(tuple.at(0).get<Numeric>().unit, "px");
EXPECT_DOUBLE_EQ(tuple.at(1).get<Numeric>().value, 4.0);
EXPECT_DOUBLE_EQ(tuple.at(2).get<Numeric>().value, 6.0);
}
TEST_F(ParserTest, TupleScalarDivide)
{
Parser parser("(10, 20) / 2");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 2);
EXPECT_DOUBLE_EQ(tuple.at(0).get<Numeric>().value, 5.0);
EXPECT_DOUBLE_EQ(tuple.at(1).get<Numeric>().value, 10.0);
}
TEST_F(ParserTest, TupleUnaryNegate)
{
Parser parser("-(1px, 2px)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 2);
EXPECT_DOUBLE_EQ(tuple.at(0).get<Numeric>().value, -1.0);
EXPECT_EQ(tuple.at(0).get<Numeric>().unit, "px");
EXPECT_DOUBLE_EQ(tuple.at(1).get<Numeric>().value, -2.0);
EXPECT_EQ(tuple.at(1).get<Numeric>().unit, "px");
}
TEST_F(ParserTest, TupleAddPreservesNames)
{
Parser parser("(x: 1, y: 2) + (x: 3, y: 4)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 2);
auto* x = tuple.find("x");
ASSERT_NE(x, nullptr);
EXPECT_DOUBLE_EQ(x->get<Numeric>().value, 4.0);
auto* y = tuple.find("y");
ASSERT_NE(y, nullptr);
EXPECT_DOUBLE_EQ(y->get<Numeric>().value, 6.0);
}
TEST_F(ParserTest, TupleAddMatchesByName)
{
Parser parser("(x: 10, y: 20) + (y: 30, x: 5)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 2);
auto* x = tuple.find("x");
ASSERT_NE(x, nullptr);
EXPECT_DOUBLE_EQ(x->get<Numeric>().value, 15.0);
auto* y = tuple.find("y");
ASSERT_NE(y, nullptr);
EXPECT_DOUBLE_EQ(y->get<Numeric>().value, 50.0);
}
TEST_F(ParserTest, TupleParamArithmetic)
{
Parser parser("@TestTupleParam + @TestTupleParam");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 4);
EXPECT_DOUBLE_EQ(tuple.at(0).get<Numeric>().value, 2.0);
EXPECT_DOUBLE_EQ(tuple.at(1).get<Numeric>().value, 4.0);
EXPECT_DOUBLE_EQ(tuple.at(2).get<Numeric>().value, 6.0);
EXPECT_DOUBLE_EQ(tuple.at(3).get<Numeric>().value, 8.0);
}
TEST_F(ParserTest, TupleUnionMixedNamedUnnamed)
{
// (x: 5, 10) + (y: 10, 5, 20) → (x: 5, y: 10, 15, 20)
Parser parser("(x: 5, 10) + (y: 10, 5, 20)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 4);
auto* x = tuple.find("x");
ASSERT_NE(x, nullptr);
EXPECT_DOUBLE_EQ(x->get<Numeric>().value, 5.0);
auto* y = tuple.find("y");
ASSERT_NE(y, nullptr);
EXPECT_DOUBLE_EQ(y->get<Numeric>().value, 10.0);
EXPECT_DOUBLE_EQ(tuple.at(2).get<Numeric>().value, 15.0);
EXPECT_DOUBLE_EQ(tuple.at(3).get<Numeric>().value, 20.0);
}
TEST_F(ParserTest, TupleUnionNamedDifferentSizes)
{
// (x: 1, y: 2) + (x: 3, y: 4, z: 5) → (x: 4, y: 6, z: 5)
Parser parser("(x: 1, y: 2) + (x: 3, y: 4, z: 5)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 3);
EXPECT_DOUBLE_EQ(tuple.find("x")->get<Numeric>().value, 4.0);
EXPECT_DOUBLE_EQ(tuple.find("y")->get<Numeric>().value, 6.0);
EXPECT_DOUBLE_EQ(tuple.find("z")->get<Numeric>().value, 5.0);
}
TEST_F(ParserTest, TupleUnionUnnamedDifferentSizes)
{
// (1, 2) + (3, 4, 5) → (4, 6, 5)
Parser parser("(1, 2) + (3, 4, 5)");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& tuple = result.get<Tuple>();
EXPECT_EQ(tuple.size(), 3);
EXPECT_DOUBLE_EQ(tuple.at(0).get<Numeric>().value, 4.0);
EXPECT_DOUBLE_EQ(tuple.at(1).get<Numeric>().value, 6.0);
EXPECT_DOUBLE_EQ(tuple.at(2).get<Numeric>().value, 5.0);
}
TEST_F(ParserTest, TupleAddNumericError)
{
EXPECT_THROW(
{
Parser parser("(1, 2) + 5");
auto expr = parser.parse();
expr->evaluate({&manager, {}});
},
Base::ExpressionError
);
}
TEST_F(ParserTest, TupleNestedScalarMultiply)
{
Parser parser("((1, 2), (3, 4)) * 2");
auto expr = parser.parse();
auto result = expr->evaluate({&manager, {}});
EXPECT_TRUE(result.holds<Tuple>());
const auto& outer = result.get<Tuple>();
EXPECT_EQ(outer.size(), 2);
EXPECT_TRUE(outer.at(0).holds<Tuple>());
const auto& inner1 = outer.at(0).get<Tuple>();
EXPECT_DOUBLE_EQ(inner1.at(0).get<Numeric>().value, 2.0);
EXPECT_DOUBLE_EQ(inner1.at(1).get<Numeric>().value, 4.0);
EXPECT_TRUE(outer.at(1).holds<Tuple>());
const auto& inner2 = outer.at(1).get<Tuple>();
EXPECT_DOUBLE_EQ(inner2.at(0).get<Numeric>().value, 6.0);
EXPECT_DOUBLE_EQ(inner2.at(1).get<Numeric>().value, 8.0);
}
// ArgumentParser tests
class ArgumentParserTest: public ::testing::Test
{
struct NameValuePair
{
std::optional<std::string> name;
Value value;
};
protected:
static Tuple makeTuple(std::vector<NameValuePair> elements)
{
Tuple tuple;
for (auto& [name, value] : elements) {
tuple.elements.emplace_back(name, std::make_shared<const Value>(std::move(value)));
}
return tuple;
}
};
TEST_F(ArgumentParserTest, AllPositional)
{
auto args = makeTuple({
{.name = std::nullopt, .value = Numeric {1, ""}},
{.name = std::nullopt, .value = Numeric {2, ""}},
});
auto resolved = ArgumentParser {{"x"}, {"y"}}.resolve(args);
ASSERT_NE(resolved.find("x"), nullptr);
ASSERT_NE(resolved.find("y"), nullptr);
EXPECT_DOUBLE_EQ(resolved.find("x")->get<Numeric>().value, 1.0);
EXPECT_DOUBLE_EQ(resolved.find("y")->get<Numeric>().value, 2.0);
}
TEST_F(ArgumentParserTest, AllNamed)
{
auto args = makeTuple({
{.name = std::string("x"), .value = Numeric {10, ""}},
{.name = std::string("y"), .value = Numeric {20, ""}},
});
auto resolved = ArgumentParser {{"x"}, {"y"}}.resolve(args);
EXPECT_DOUBLE_EQ(resolved.find("x")->get<Numeric>().value, 10.0);
EXPECT_DOUBLE_EQ(resolved.find("y")->get<Numeric>().value, 20.0);
}
TEST_F(ArgumentParserTest, AllNamedReversedOrder)
{
auto args = makeTuple({
{.name = std::string("y"), .value = Numeric {20, ""}},
{.name = std::string("x"), .value = Numeric {10, ""}},
});
auto resolved = ArgumentParser {{"x"}, {"y"}}.resolve(args);
EXPECT_DOUBLE_EQ(resolved.find("x")->get<Numeric>().value, 10.0);
EXPECT_DOUBLE_EQ(resolved.find("y")->get<Numeric>().value, 20.0);
}
TEST_F(ArgumentParserTest, MixedPositionalThenNamed)
{
// f(1, y: 2) with signature (x, y)
auto args = makeTuple({
{.name = std::nullopt, .value = Numeric {1, ""}},
{.name = std::string("y"), .value = Numeric {2, ""}},
});
auto resolved = ArgumentParser {{"x"}, {"y"}}.resolve(args);
EXPECT_DOUBLE_EQ(resolved.find("x")->get<Numeric>().value, 1.0);
EXPECT_DOUBLE_EQ(resolved.find("y")->get<Numeric>().value, 2.0);
}
TEST_F(ArgumentParserTest, NamedThenPositionalFillsRemainingSlot)
{
// f(y: 2, 1) with signature (x, y) — positional 1 fills unclaimed x
auto args = makeTuple({
{.name = std::string("y"), .value = Numeric {2, ""}},
{.name = std::nullopt, .value = Numeric {1, ""}},
});
auto resolved = ArgumentParser {{"x"}, {"y"}}.resolve(args);
EXPECT_DOUBLE_EQ(resolved.find("x")->get<Numeric>().value, 1.0);
EXPECT_DOUBLE_EQ(resolved.find("y")->get<Numeric>().value, 2.0);
}
TEST_F(ArgumentParserTest, DefaultValueUsedWhenMissing)
{
auto args = makeTuple({
{.name = std::nullopt, .value = Numeric {1, ""}},
});
auto resolved = ArgumentParser {{"x"}, {"y", Numeric {99, ""}}}.resolve(args);
EXPECT_DOUBLE_EQ(resolved.find("x")->get<Numeric>().value, 1.0);
EXPECT_DOUBLE_EQ(resolved.find("y")->get<Numeric>().value, 99.0);
}
TEST_F(ArgumentParserTest, DefaultValueOverriddenByPositional)
{
auto args = makeTuple({
{.name = std::nullopt, .value = Numeric {1, ""}},
{.name = std::nullopt, .value = Numeric {2, ""}},
});
auto resolved = ArgumentParser {{"x"}, {"y", Numeric {99, ""}}}.resolve(args);
EXPECT_DOUBLE_EQ(resolved.find("y")->get<Numeric>().value, 2.0);
}
TEST_F(ArgumentParserTest, DefaultValueOverriddenByName)
{
auto args = makeTuple({
{.name = std::nullopt, .value = Numeric {1, ""}},
{.name = std::string("y"), .value = Numeric {2, ""}},
});
auto resolved = ArgumentParser {{"x"}, {"y", Numeric {99, ""}}}.resolve(args);
EXPECT_DOUBLE_EQ(resolved.find("y")->get<Numeric>().value, 2.0);
}
TEST_F(ArgumentParserTest, ResolvedTupleHasCorrectOrder)
{
auto args = makeTuple({
{.name = std::string("y"), .value = Numeric {2, ""}},
{.name = std::nullopt, .value = Numeric {1, ""}},
});
auto resolved = ArgumentParser {{"x"}, {"y"}}.resolve(args);
// at(0) is x, at(1) is y — matches declaration order
EXPECT_DOUBLE_EQ(resolved.at(0).get<Numeric>().value, 1.0);
EXPECT_DOUBLE_EQ(resolved.at(1).get<Numeric>().value, 2.0);
}
TEST_F(ArgumentParserTest, MixedTypes)
{
auto args = makeTuple({
{.name = std::nullopt, .value = Base::Color(1.0, 0.0, 0.0)},
{.name = std::nullopt, .value = Numeric {20, ""}},
});
auto resolved = ArgumentParser {{"color"}, {"amount"}}.resolve(args);
EXPECT_TRUE(resolved.find("color")->holds<Base::Color>());
EXPECT_TRUE(resolved.find("amount")->holds<Numeric>());
}
TEST_F(ArgumentParserTest, ErrorOnUnknownName)
{
auto args = makeTuple({
{.name = std::string("unknown"), .value = Numeric {1, ""}},
});
ArgumentParser parser {{"x"}, {"y"}};
EXPECT_THROW(parser.resolve(args), Base::ExpressionError);
}
TEST_F(ArgumentParserTest, ErrorOnDuplicateName)
{
auto args = makeTuple({
{.name = std::string("x"), .value = Numeric {1, ""}},
{.name = std::string("x"), .value = Numeric {2, ""}},
});
ArgumentParser parser {{"x"}, {"y"}};
EXPECT_THROW(parser.resolve(args), Base::ExpressionError);
}
TEST_F(ArgumentParserTest, ErrorOnMissingRequired)
{
auto args = makeTuple({
{.name = std::nullopt, .value = Numeric {1, ""}},
});
ArgumentParser parser {{"x"}, {"y"}};
EXPECT_THROW(parser.resolve(args), Base::ExpressionError);
}
TEST_F(ArgumentParserTest, ErrorOnExcessPositional)
{
auto args = makeTuple({
{.name = std::nullopt, .value = Numeric {1, ""}},
{.name = std::nullopt, .value = Numeric {2, ""}},
{.name = std::nullopt, .value = Numeric {3, ""}},
});
ArgumentParser parser {{"x"}, {"y"}};
EXPECT_THROW(parser.resolve(args), Base::ExpressionError);
}
TEST_F(ArgumentParserTest, TypedGetSuccess)
{
auto args = makeTuple({
{.name = std::nullopt, .value = Base::Color(1.0, 0.0, 0.0)},
{.name = std::nullopt, .value = Numeric {20, ""}},
});
auto resolved = ArgumentParser {{"color"}, {"amount"}}.resolve(args);
EXPECT_NO_THROW(resolved.get<Base::Color>("color"));
EXPECT_NO_THROW(resolved.get<Numeric>("amount"));
EXPECT_DOUBLE_EQ(resolved.get<Numeric>("amount").value, 20.0);
}
TEST_F(ArgumentParserTest, TypedGetWrongType)
{
auto args = makeTuple({
{.name = std::nullopt, .value = Numeric {10, "px"}},
{.name = std::nullopt, .value = Numeric {20, ""}},
});
auto resolved = ArgumentParser {{"color"}, {"amount"}}.resolve(args);
EXPECT_THROW(resolved.get<Base::Color>("color"), Base::ExpressionError);
}
TEST_F(ArgumentParserTest, TypedGetMissingName)
{
auto args = makeTuple({{.name = std::nullopt, .value = Numeric {10, ""}}});
auto resolved = ArgumentParser {{"x"}}.resolve(args);
EXPECT_THROW(resolved.get<Numeric>("nonexistent"), Base::ExpressionError);
}