From 0f4ad868f703489068907963c2062ebd58910d03 Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Sat, 7 Feb 2026 15:24:14 +0100 Subject: [PATCH 1/8] Gui: Add Tuple type to StyleParameters Add a tuple value type to the StyleParameters system, allowing related values to be grouped into a single parameter with optional named fields. Tuple is stored inline in the Value variant. The circular dependency (Value contains Tuple, Tuple elements contain Value) is broken by storing element values as shared_ptr inside Tuple::Element. Generic holds() and get() template methods are added to Value for uniform type-checked access to all variant alternatives. Co-Authored-By: Claude Opus 4.6 --- src/Gui/Dialogs/DlgThemeEditor.cpp | 3 + src/Gui/StyleParameters/ParameterManager.cpp | 52 ++++++++++++-- src/Gui/StyleParameters/ParameterManager.h | 72 ++++++++++++++++++-- 3 files changed, 116 insertions(+), 11 deletions(-) diff --git a/src/Gui/Dialogs/DlgThemeEditor.cpp b/src/Gui/Dialogs/DlgThemeEditor.cpp index a0e50c083d..55f85ef987 100644 --- a/src/Gui/Dialogs/DlgThemeEditor.cpp +++ b/src/Gui/Dialogs/DlgThemeEditor.cpp @@ -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 diff --git a/src/Gui/StyleParameters/ParameterManager.cpp b/src/Gui/StyleParameters/ParameterManager.cpp index c1579c895a..1c64f9f5d8 100644 --- a/src/Gui/StyleParameters/ParameterManager.cpp +++ b/src/Gui/StyleParameters/ParameterManager.cpp @@ -95,17 +95,59 @@ void Numeric::ensureEqualUnits(const Numeric& rhs) const std::string Value::toString() const { - if (std::holds_alternative(*this)) { - auto [value, unit] = std::get(*this); + if (holds()) { + auto [value, unit] = get(); return fmt::format("{}{}", value, unit); } - if (std::holds_alternative(*this)) { - auto color = std::get(*this); + if (holds()) { + auto color = get(); return fmt::format("#{:0>6x}", color.getPackedRGB() >> 8); // NOLINT(*-magic-numbers) } - return std::get(*this); + if (holds()) { + const auto& tuple = get(); + std::string result = "("; + for (size_t i = 0; i < tuple.elements.size(); ++i) { + if (i > 0) { + result += ", "; + } + const auto& elem = tuple.elements[i]; + if (elem.name) { + result += *elem.name + ": "; + } + result += elem.value->toString(); + } + result += ")"; + return result; + } + + return get(); +} + +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(); } ParameterSource::ParameterSource(const Metadata& metadata) diff --git a/src/Gui/StyleParameters/ParameterManager.h b/src/Gui/StyleParameters/ParameterManager.h index ddebc31ede..242f7d67d7 100644 --- a/src/Gui/StyleParameters/ParameterManager.h +++ b/src/Gui/StyleParameters/ParameterManager.h @@ -26,6 +26,7 @@ #include #include +#include #include #include #include @@ -105,19 +106,58 @@ private: void ensureEqualUnits(const Numeric& rhs) const; }; +// Forward declaration: Tuple::Element uses shared_ptr 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 Tuple +{ + struct Element + { + std::optional name; + std::shared_ptr value; + }; + + std::vector 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 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) + * 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 Value: std::variant +struct Value: std::variant { - using std::variant::variant; + using std::variant::variant; /** * Converts the object into its string representation. @@ -125,6 +165,26 @@ struct Value: std::variant * @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 + bool holds() const + { + return std::holds_alternative(*this); + } + + /** + * @brief Gets the value of the given type. + * + * Throws std::bad_variant_access if the value does not hold the requested type. + */ + template + const T& get() const + { + return std::get(*this); + } }; /** From 884baf2cb827c97dd2b65b695c14d05c48eec877 Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Sat, 7 Feb 2026 15:24:22 +0100 Subject: [PATCH 2/8] Gui: Add tuple parsing to StyleParameters parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TupleLiteral AST node and parser support for tuple syntax using parentheses with comma-separated elements: (val1, val2) for unnamed tuples, (name: val1, val2) for named elements. Disambiguation with grouped expressions in parseFactor(): - `(identifier:` lookahead → tuple - expression followed by `,` → tuple - expression followed by `)` → grouped expression (backward compatible) Co-Authored-By: Claude Opus 4.6 --- src/Gui/StyleParameters/Parser.cpp | 105 +++++++++++++++++++++++++++++ src/Gui/StyleParameters/Parser.h | 15 +++++ 2 files changed, 120 insertions(+) diff --git a/src/Gui/StyleParameters/Parser.cpp b/src/Gui/StyleParameters/Parser.cpp index 008308601c..a5343de055 100644 --- a/src/Gui/StyleParameters/Parser.cpp +++ b/src/Gui/StyleParameters/Parser.cpp @@ -166,6 +166,17 @@ Value BinaryOp::evaluate(const EvaluationContext& context) const } } +Value TupleLiteral::evaluate(const EvaluationContext& context) const +{ + Tuple tuple; + for (const auto& elem : elements) { + tuple.elements.push_back( + {elem.name, std::make_shared(elem.expression->evaluate(context))} + ); + } + return tuple; +} + Value UnaryOp::evaluate(const EvaluationContext& context) const { Value val = operand->evaluate(context); @@ -246,7 +257,25 @@ std::unique_ptr Parser::parseFactor() return std::make_unique(op, parseFactor()); } if (match('(')) { + // Disambiguation: tuple vs grouped expression + // 1. If we see `identifier:` pattern → definitely a tuple + if (peekNamedElement()) { + return parseTuple(); + } + + // 2. Otherwise parse first expression auto 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); + return parseTuple(std::move(first)); + } + + // If followed by `)` → grouped expression (backward compatible) if (!match(')')) { THROWM(Base::ParserError, fmt::format("Expected ')', got '{}'", input[pos])); } @@ -403,6 +432,82 @@ std::unique_ptr Parser::parseFunctionCall() return std::make_unique(functionName, std::move(arguments)); } +bool Parser::peekNamedElement() +{ + size_t saved = pos; + skipWhitespace(); + + // Check for `identifier :` pattern + if (pos >= input.size() || !isalpha(input[pos])) { + pos = saved; + return false; + } + + // 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 Parser::parseTuple(std::optional firstElement) +{ + auto tuple = std::make_unique(); + + 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() { skipWhitespace(); diff --git a/src/Gui/StyleParameters/Parser.h b/src/Gui/StyleParameters/Parser.h index f962ac52f9..2623d7d4e2 100644 --- a/src/Gui/StyleParameters/Parser.h +++ b/src/Gui/StyleParameters/Parser.h @@ -132,6 +132,19 @@ struct GuiExport UnaryOp: public Expr Value evaluate(const EvaluationContext& context) const override; }; +struct GuiExport TupleLiteral: public Expr +{ + struct Element + { + std::optional name; + std::unique_ptr expression; + }; + + std::vector elements; + + Value evaluate(const EvaluationContext& context) const override; +}; + class GuiExport Parser { static constexpr auto rgbFunction = "rgb("; @@ -161,6 +174,8 @@ private: int parseInt(); std::unique_ptr parseNumber(); std::string parseUnit(); + bool peekNamedElement(); + std::unique_ptr parseTuple(std::optional firstElement = std::nullopt); bool match(char expected); void skipWhitespace(); }; From 7c813a6f5af486f488ad9df2d48cc69e1de6d47d Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Sat, 7 Feb 2026 15:24:28 +0100 Subject: [PATCH 3/8] Gui: Add tests for StyleParameters tuple support Test cases cover unnamed, named, and mixed tuples, single named elements, nested tuples, expressions and mixed types in elements, toString roundtrip, Value::holds/get accessors, Tuple::at/find/size, error handling, and backward compatibility of grouped expressions. Co-Authored-By: Claude Opus 4.6 --- tests/src/Gui/StyleParameters/ParserTest.cpp | 262 +++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/tests/src/Gui/StyleParameters/ParserTest.cpp b/tests/src/Gui/StyleParameters/ParserTest.cpp index 4df1f2e3c0..18f3b2e0a6 100644 --- a/tests/src/Gui/StyleParameters/ParserTest.cpp +++ b/tests/src/Gui/StyleParameters/ParserTest.cpp @@ -604,6 +604,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()); + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 3); + EXPECT_DOUBLE_EQ(std::get(tuple.at(0)).value, 10.0); + EXPECT_DOUBLE_EQ(std::get(tuple.at(1)).value, 20.0); + EXPECT_DOUBLE_EQ(std::get(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()); + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 2); + + auto* x = tuple.find("x"); + ASSERT_NE(x, nullptr); + EXPECT_DOUBLE_EQ(std::get(*x).value, 10.0); + + auto* y = tuple.find("y"); + ASSERT_NE(y, nullptr); + EXPECT_DOUBLE_EQ(std::get(*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()); + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 2); + + auto* x = tuple.find("x"); + ASSERT_NE(x, nullptr); + EXPECT_DOUBLE_EQ(std::get(*x).value, 10.0); + EXPECT_DOUBLE_EQ(std::get(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()); + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 1); + + auto* x = tuple.find("x"); + ASSERT_NE(x, nullptr); + EXPECT_DOUBLE_EQ(std::get(*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()); + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 2); + + auto* x = tuple.find("x"); + ASSERT_NE(x, nullptr); + EXPECT_DOUBLE_EQ(std::get(*x).value, 15.0); + + auto* y = tuple.find("y"); + ASSERT_NE(y, nullptr); + EXPECT_DOUBLE_EQ(std::get(*y).value, 10.0); + EXPECT_EQ(std::get(*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()); + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 2); + + auto* color = tuple.find("color"); + ASSERT_NE(color, nullptr); + EXPECT_TRUE(std::holds_alternative(*color)); + auto c = std::get(*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(*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()); + const auto& outer = result.get(); + EXPECT_EQ(outer.size(), 2); + + EXPECT_TRUE(outer.at(0).holds()); + const auto& inner1 = outer.at(0).get(); + EXPECT_EQ(inner1.size(), 2); + EXPECT_DOUBLE_EQ(std::get(inner1.at(0)).value, 1.0); + EXPECT_DOUBLE_EQ(std::get(inner1.at(1)).value, 2.0); + + EXPECT_TRUE(outer.at(1).holds()); + const auto& inner2 = outer.at(1).get(); + EXPECT_EQ(inner2.size(), 2); + EXPECT_DOUBLE_EQ(std::get(inner2.at(0)).value, 3.0); + EXPECT_DOUBLE_EQ(std::get(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()); + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 2); + + auto* x = tuple.find("x"); + ASSERT_NE(x, nullptr); + EXPECT_TRUE(std::holds_alternative(*x)); + + auto* y = tuple.find("y"); + ASSERT_NE(y, nullptr); + EXPECT_DOUBLE_EQ(std::get(*y).value, 20.0); + EXPECT_EQ(std::get(*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()); + EXPECT_FALSE(numericValue.holds()); + EXPECT_DOUBLE_EQ(numericValue.get().value, 42.0); + + Value colorValue = Base::Color(1.0, 0.0, 0.0); + EXPECT_TRUE(colorValue.holds()); + EXPECT_FALSE(colorValue.holds()); + + Value stringValue = std::string("hello"); + EXPECT_TRUE(stringValue.holds()); + EXPECT_FALSE(stringValue.holds()); + + Parser parser("(1, 2)"); + auto expr = parser.parse(); + auto tupleValue = expr->evaluate({&manager, {}}); + EXPECT_TRUE(tupleValue.holds()); + EXPECT_FALSE(tupleValue.holds()); + EXPECT_EQ(tupleValue.get().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(); + + 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(); + + 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(result)); + EXPECT_DOUBLE_EQ(std::get(result).value, 30.0); + } + + { + Parser parser("(42)"); + auto expr = parser.parse(); + + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + EXPECT_DOUBLE_EQ(std::get(result).value, 42.0); + } +} + // Test nested parentheses TEST_F(ParserTest, ParseNestedParentheses) { From a64a43b9caf936007c9d374758f9571d7e755b3b Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Sat, 7 Feb 2026 16:18:16 +0100 Subject: [PATCH 4/8] Gui: Refactor function arguments to use TupleLiteral Replace the separate argument parsing in parseFunctionCall() with a call to parseTuple(), unifying the two comma-separated-expression parsing paths. FunctionCall now stores a TupleLiteral instead of a vector of expressions, and evaluates it into a Tuple value that functions access via at()/find(). Co-Authored-By: Claude Opus 4.6 --- src/Gui/StyleParameters/Parser.cpp | 65 +++++++++++++----------------- src/Gui/StyleParameters/Parser.h | 34 ++++++++-------- 2 files changed, 45 insertions(+), 54 deletions(-) diff --git a/src/Gui/StyleParameters/Parser.cpp b/src/Gui/StyleParameters/Parser.cpp index a5343de055..027b65c6d3 100644 --- a/src/Gui/StyleParameters/Parser.cpp +++ b/src/Gui/StyleParameters/Parser.cpp @@ -53,29 +53,32 @@ 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) { + auto argsValue = arguments.evaluate(context); + const auto& args = argsValue.get(); + + const auto lightenOrDarken = [this, &args]() -> Value { + if (args.size() != 2) { THROWM( Base::ExpressionError, - fmt::format("Function '{}' expects 2 arguments, got {}", functionName, arguments.size()) + fmt::format("Function '{}' expects 2 arguments, got {}", functionName, args.size()) ); } - auto colorArg = arguments[0]->evaluate(context); - auto amountArg = arguments[1]->evaluate(context); + const auto& colorArg = args.at(0); + const auto& amountArg = args.at(1); - if (!std::holds_alternative(colorArg)) { + if (!colorArg.holds()) { THROWM(Base::ExpressionError, fmt::format("'{}' is not supported for colors", functionName)); } - auto color = std::get(colorArg).asValue(); + auto color = colorArg.get().asValue(); // 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); + auto amount = 100 + static_cast(amountArg.get().value); if (functionName == "lighten") { return Base::Color::fromValue(color.lighter(amount)); @@ -88,36 +91,36 @@ Value FunctionCall::evaluate(const EvaluationContext& context) const return {}; }; - const auto blend = [this](const EvaluationContext& context) -> Value { - if (arguments.size() != 3) { + const auto blend = [this, &args]() -> Value { + if (args.size() != 3) { THROWM( Base::ExpressionError, - fmt::format("Function '{}' expects 3 arguments, got {}", functionName, arguments.size()) + fmt::format("Function '{}' expects 3 arguments, got {}", functionName, args.size()) ); } - auto firstColorArg = arguments[0]->evaluate(context); - auto secondColorArg = arguments[1]->evaluate(context); - auto amountArg = arguments[2]->evaluate(context); + const auto& firstColorArg = args.at(0); + const auto& secondColorArg = args.at(1); + const auto& amountArg = args.at(2); - if (!std::holds_alternative(firstColorArg)) { + if (!firstColorArg.holds()) { THROWM( Base::ExpressionError, fmt::format("first argument of '{}' must be color", functionName) ); } - if (!std::holds_alternative(secondColorArg)) { + if (!secondColorArg.holds()) { THROWM( Base::ExpressionError, fmt::format("second argument of '{}' must be color", functionName) ); } - auto firstColor = std::get(firstColorArg); - auto secondColor = std::get(secondColorArg); + auto firstColor = firstColorArg.get(); + auto secondColor = secondColorArg.get(); - auto amount = Base::fromPercent(static_cast(std::get(amountArg).value)); + auto amount = Base::fromPercent(static_cast(amountArg.get().value)); return Base::Color( (1 - amount) * firstColor.r + amount * secondColor.r, @@ -126,15 +129,14 @@ Value FunctionCall::evaluate(const EvaluationContext& context) const ); }; - std::map> functions = { + std::map> 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)); @@ -415,21 +417,8 @@ std::unique_ptr Parser::parseFunctionCall() 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)); + auto arguments = parseTuple(); + return std::make_unique(functionName, std::move(*arguments)); } bool Parser::peekNamedElement() @@ -459,7 +448,7 @@ bool Parser::peekNamedElement() return found; } -std::unique_ptr Parser::parseTuple(std::optional firstElement) +std::unique_ptr Parser::parseTuple(std::optional firstElement) { auto tuple = std::make_unique(); diff --git a/src/Gui/StyleParameters/Parser.h b/src/Gui/StyleParameters/Parser.h index 2623d7d4e2..70c73bd15a 100644 --- a/src/Gui/StyleParameters/Parser.h +++ b/src/Gui/StyleParameters/Parser.h @@ -92,12 +92,25 @@ struct GuiExport Color: public Expr Value evaluate([[maybe_unused]] const EvaluationContext& context) const override; }; +struct GuiExport TupleLiteral: public Expr +{ + struct Element + { + std::optional name; + std::unique_ptr expression; + }; + + std::vector elements; + + Value evaluate(const EvaluationContext& context) const override; +}; + struct GuiExport FunctionCall: public Expr { std::string functionName; - std::vector> arguments; + TupleLiteral arguments; - FunctionCall(std::string functionName, std::vector> arguments) + FunctionCall(std::string functionName, TupleLiteral arguments) : functionName(std::move(functionName)) , arguments(std::move(arguments)) {} @@ -132,19 +145,6 @@ struct GuiExport UnaryOp: public Expr Value evaluate(const EvaluationContext& context) const override; }; -struct GuiExport TupleLiteral: public Expr -{ - struct Element - { - std::optional name; - std::unique_ptr expression; - }; - - std::vector elements; - - Value evaluate(const EvaluationContext& context) const override; -}; - class GuiExport Parser { static constexpr auto rgbFunction = "rgb("; @@ -175,7 +175,9 @@ private: std::unique_ptr parseNumber(); std::string parseUnit(); bool peekNamedElement(); - std::unique_ptr parseTuple(std::optional firstElement = std::nullopt); + std::unique_ptr parseTuple( + std::optional firstElement = std::nullopt + ); bool match(char expected); void skipWhitespace(); }; From 9e0538abc873c1dee3f9322af7770ca24f1e90e9 Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Sat, 7 Feb 2026 17:40:37 +0100 Subject: [PATCH 5/8] Gui: Add ArgumentParser for named/positional function arguments Adds ArgumentParser class that resolves mixed positional and named arguments against a declared function signature (Python-style). Includes Tuple::get(name) for type-checked access with user-friendly error messages. Refactors lighten/darken/blend to use it. Co-Authored-By: Claude Opus 4.6 --- src/Gui/StyleParameters/ParameterManager.cpp | 74 +++++++ src/Gui/StyleParameters/ParameterManager.h | 78 +++++++ src/Gui/StyleParameters/Parser.cpp | 52 +---- tests/src/Gui/StyleParameters/ParserTest.cpp | 214 +++++++++++++++++++ 4 files changed, 374 insertions(+), 44 deletions(-) diff --git a/src/Gui/StyleParameters/ParameterManager.cpp b/src/Gui/StyleParameters/ParameterManager.cpp index 1c64f9f5d8..d0399800ac 100644 --- a/src/Gui/StyleParameters/ParameterManager.cpp +++ b/src/Gui/StyleParameters/ParameterManager.cpp @@ -150,6 +150,80 @@ size_t Tuple::size() const return elements.size(); } +ArgumentParser::ArgumentParser(std::initializer_list params) + : params_(params) +{} + +Tuple ArgumentParser::resolve(const Tuple& args) const +{ + // Slots for resolved values, one per declared parameter + std::vector> 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(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(*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.push_back({params_[i].name, std::move(slots[i])}); + } + + return result; +} + ParameterSource::ParameterSource(const Metadata& metadata) : metadata(metadata) {} diff --git a/src/Gui/StyleParameters/ParameterManager.h b/src/Gui/StyleParameters/ParameterManager.h index 242f7d67d7..b151794825 100644 --- a/src/Gui/StyleParameters/ParameterManager.h +++ b/src/Gui/StyleParameters/ParameterManager.h @@ -30,6 +30,7 @@ #include #include #include +#include #include #include @@ -142,6 +143,14 @@ struct Tuple * @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 + const T& get(const std::string& name) const; }; /** @@ -187,6 +196,75 @@ struct Value: std::variant } }; +template +constexpr const char* valueTypeName() +{ + if constexpr (std::is_same_v) { + return "numeric"; + } + else if constexpr (std::is_same_v) { + return "color"; + } + else if constexpr (std::is_same_v) { + return "string"; + } + else if constexpr (std::is_same_v) { + return "tuple"; + } +} + +template +const T& Tuple::get(const std::string& name) const +{ + const Value* v = find(name); + if (!v) { + THROWM(Base::ExpressionError, fmt::format("Missing argument '{}'", name)); + } + if (!v->holds()) { + THROWM( + Base::ExpressionError, + fmt::format("Argument '{}' must be {}, got {}", name, valueTypeName(), v->toString()) + ); + } + return v->get(); +} + +/** + * @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 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.find("color"); + * auto& amount = *resolved.find("amount"); + * @endcode + */ +class GuiExport ArgumentParser +{ +public: + ArgumentParser(std::initializer_list params); + + Tuple resolve(const Tuple& args) const; + +private: + std::vector params_; +}; + /** * @brief A structure to define parameters which can be referenced in the code. * diff --git a/src/Gui/StyleParameters/Parser.cpp b/src/Gui/StyleParameters/Parser.cpp index 027b65c6d3..2e46dea7f5 100644 --- a/src/Gui/StyleParameters/Parser.cpp +++ b/src/Gui/StyleParameters/Parser.cpp @@ -57,28 +57,16 @@ Value FunctionCall::evaluate(const EvaluationContext& context) const const auto& args = argsValue.get(); const auto lightenOrDarken = [this, &args]() -> Value { - if (args.size() != 2) { - THROWM( - Base::ExpressionError, - fmt::format("Function '{}' expects 2 arguments, got {}", functionName, args.size()) - ); - } + auto resolved = ArgumentParser {{"color"}, {"amount"}}.resolve(args); - const auto& colorArg = args.at(0); - const auto& amountArg = args.at(1); - - if (!colorArg.holds()) { - THROWM(Base::ExpressionError, fmt::format("'{}' is not supported for colors", functionName)); - } - - auto color = colorArg.get().asValue(); + auto color = resolved.get("color").asValue(); // 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(amountArg.get().value); + auto amount = 100 + static_cast(resolved.get("amount").value); if (functionName == "lighten") { return Base::Color::fromValue(color.lighter(amount)); @@ -91,36 +79,12 @@ Value FunctionCall::evaluate(const EvaluationContext& context) const return {}; }; - const auto blend = [this, &args]() -> Value { - if (args.size() != 3) { - THROWM( - Base::ExpressionError, - fmt::format("Function '{}' expects 3 arguments, got {}", functionName, args.size()) - ); - } + const auto blend = [&args]() -> Value { + auto resolved = ArgumentParser {{"from"}, {"to"}, {"amount"}}.resolve(args); - const auto& firstColorArg = args.at(0); - const auto& secondColorArg = args.at(1); - const auto& amountArg = args.at(2); - - if (!firstColorArg.holds()) { - THROWM( - Base::ExpressionError, - fmt::format("first argument of '{}' must be color", functionName) - ); - } - - if (!secondColorArg.holds()) { - THROWM( - Base::ExpressionError, - fmt::format("second argument of '{}' must be color", functionName) - ); - } - - auto firstColor = firstColorArg.get(); - auto secondColor = secondColorArg.get(); - - auto amount = Base::fromPercent(static_cast(amountArg.get().value)); + auto firstColor = resolved.get("from"); + auto secondColor = resolved.get("to"); + auto amount = Base::fromPercent(static_cast(resolved.get("amount").value)); return Base::Color( (1 - amount) * firstColor.r + amount * secondColor.r, diff --git a/tests/src/Gui/StyleParameters/ParserTest.cpp b/tests/src/Gui/StyleParameters/ParserTest.cpp index 18f3b2e0a6..f2c5715240 100644 --- a/tests/src/Gui/StyleParameters/ParserTest.cpp +++ b/tests/src/Gui/StyleParameters/ParserTest.cpp @@ -889,3 +889,217 @@ TEST_F(ParserTest, ParseNestedParentheses) EXPECT_EQ(length.unit, ""); } } + +// ArgumentParser tests + +class ArgumentParserTest: public ::testing::Test +{ + struct NameValuePair + { + std::optional name; + Value value; + }; + +protected: + static Tuple makeTuple(std::vector elements) + { + Tuple tuple; + for (auto& [name, value] : elements) { + tuple.elements.emplace_back(name, std::make_shared(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().value, 1.0); + EXPECT_DOUBLE_EQ(resolved.find("y")->get().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().value, 10.0); + EXPECT_DOUBLE_EQ(resolved.find("y")->get().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().value, 10.0); + EXPECT_DOUBLE_EQ(resolved.find("y")->get().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().value, 1.0); + EXPECT_DOUBLE_EQ(resolved.find("y")->get().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().value, 1.0); + EXPECT_DOUBLE_EQ(resolved.find("y")->get().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().value, 1.0); + EXPECT_DOUBLE_EQ(resolved.find("y")->get().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().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().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().value, 1.0); + EXPECT_DOUBLE_EQ(resolved.at(1).get().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()); + EXPECT_TRUE(resolved.find("amount")->holds()); +} + +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("color")); + EXPECT_NO_THROW(resolved.get("amount")); + EXPECT_DOUBLE_EQ(resolved.get("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("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("nonexistent"), Base::ExpressionError); +} From b7b3187e1de338bff3ecf25737e884374baf9fa5 Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Sat, 7 Feb 2026 23:14:49 +0100 Subject: [PATCH 6/8] Gui: Split Value types out of ParameterManager into Value.h/.cpp Move Numeric, Tuple, Value, valueTypeName, ArgumentParser, and ParamDef into their own Value.h/.cpp files. ParameterManager.h now includes Value.h and keeps only parameter sources and the manager itself. Co-Authored-By: Claude Opus 4.6 --- src/Gui/CMakeLists.txt | 2 + src/Gui/StyleParameters/ParameterManager.cpp | 183 ------------- src/Gui/StyleParameters/ParameterManager.h | 219 +--------------- src/Gui/StyleParameters/Value.cpp | 219 ++++++++++++++++ src/Gui/StyleParameters/Value.h | 262 +++++++++++++++++++ 5 files changed, 485 insertions(+), 400 deletions(-) create mode 100644 src/Gui/StyleParameters/Value.cpp create mode 100644 src/Gui/StyleParameters/Value.h diff --git a/src/Gui/CMakeLists.txt b/src/Gui/CMakeLists.txt index 28dcd164bc..9eceb4bfe8 100644 --- a/src/Gui/CMakeLists.txt +++ b/src/Gui/CMakeLists.txt @@ -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 ) diff --git a/src/Gui/StyleParameters/ParameterManager.cpp b/src/Gui/StyleParameters/ParameterManager.cpp index d0399800ac..cc4b41009c 100644 --- a/src/Gui/StyleParameters/ParameterManager.cpp +++ b/src/Gui/StyleParameters/ParameterManager.cpp @@ -28,7 +28,6 @@ #include #include -#include #include #include #include @@ -42,188 +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 (holds()) { - auto [value, unit] = get(); - return fmt::format("{}{}", value, unit); - } - - if (holds()) { - auto color = get(); - return fmt::format("#{:0>6x}", color.getPackedRGB() >> 8); // NOLINT(*-magic-numbers) - } - - if (holds()) { - const auto& tuple = get(); - std::string result = "("; - for (size_t i = 0; i < tuple.elements.size(); ++i) { - if (i > 0) { - result += ", "; - } - const auto& elem = tuple.elements[i]; - if (elem.name) { - result += *elem.name + ": "; - } - result += elem.value->toString(); - } - result += ")"; - return result; - } - - return get(); -} - -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 params) - : params_(params) -{} - -Tuple ArgumentParser::resolve(const Tuple& args) const -{ - // Slots for resolved values, one per declared parameter - std::vector> 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(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(*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.push_back({params_[i].name, std::move(slots[i])}); - } - - return result; -} - ParameterSource::ParameterSource(const Metadata& metadata) : metadata(metadata) {} diff --git a/src/Gui/StyleParameters/ParameterManager.h b/src/Gui/StyleParameters/ParameterManager.h index b151794825..3e969c4127 100644 --- a/src/Gui/StyleParameters/ParameterManager.h +++ b/src/Gui/StyleParameters/ParameterManager.h @@ -26,19 +26,16 @@ #include #include -#include #include #include #include -#include -#include - -#include #include #include #include +#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_) \ @@ -53,218 +50,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; -}; - -// Forward declaration: Tuple::Element uses shared_ptr 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 Tuple -{ - struct Element - { - std::optional name; - std::shared_ptr value; - }; - - std::vector 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 - 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 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; - - /** - * @brief Checks whether this value holds the given type. - */ - template - bool holds() const - { - return std::holds_alternative(*this); - } - - /** - * @brief Gets the value of the given type. - * - * Throws std::bad_variant_access if the value does not hold the requested type. - */ - template - const T& get() const - { - return std::get(*this); - } -}; - -template -constexpr const char* valueTypeName() -{ - if constexpr (std::is_same_v) { - return "numeric"; - } - else if constexpr (std::is_same_v) { - return "color"; - } - else if constexpr (std::is_same_v) { - return "string"; - } - else if constexpr (std::is_same_v) { - return "tuple"; - } -} - -template -const T& Tuple::get(const std::string& name) const -{ - const Value* v = find(name); - if (!v) { - THROWM(Base::ExpressionError, fmt::format("Missing argument '{}'", name)); - } - if (!v->holds()) { - THROWM( - Base::ExpressionError, - fmt::format("Argument '{}' must be {}, got {}", name, valueTypeName(), v->toString()) - ); - } - return v->get(); -} - -/** - * @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 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.find("color"); - * auto& amount = *resolved.find("amount"); - * @endcode - */ -class GuiExport ArgumentParser -{ -public: - ArgumentParser(std::initializer_list params); - - Tuple resolve(const Tuple& args) const; - -private: - std::vector params_; -}; - /** * @brief A structure to define parameters which can be referenced in the code. * diff --git a/src/Gui/StyleParameters/Value.cpp b/src/Gui/StyleParameters/Value.cpp new file mode 100644 index 0000000000..13761842b0 --- /dev/null +++ b/src/Gui/StyleParameters/Value.cpp @@ -0,0 +1,219 @@ +// 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 "Value.h" + +#include +#include + +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()) { + auto [value, unit] = get(); + return fmt::format("{}{}", value, unit); + } + + if (holds()) { + auto color = get(); + return fmt::format("#{:0>6x}", color.getPackedRGB() >> 8); // NOLINT(*-magic-numbers) + } + + if (holds()) { + const auto& tuple = get(); + + std::vector 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(); +} + +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 params) + : params_(params) +{} + +Tuple ArgumentParser::resolve(const Tuple& args) const +{ + // Slots for resolved values, one per declared parameter + std::vector> 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(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(*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 diff --git a/src/Gui/StyleParameters/Value.h b/src/Gui/StyleParameters/Value.h new file mode 100644 index 0000000000..4aff64f191 --- /dev/null +++ b/src/Gui/StyleParameters/Value.h @@ -0,0 +1,262 @@ +// 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_VALUE_H +#define STYLEPARAMETERS_VALUE_H + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +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 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 name; + std::shared_ptr value; + }; + + std::vector 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 + 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 +{ + 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; + + /** + * @brief Checks whether this value holds the given type. + */ + template + bool holds() const + { + return std::holds_alternative(*this); + } + + /** + * @brief Gets the value of the given type. + * + * Throws std::bad_variant_access if the value does not hold the requested type. + */ + template + const T& get() const + { + return std::get(*this); + } +}; + +template +constexpr const char* valueTypeName() +{ + if constexpr (std::is_same_v) { + return "numeric"; + } + else if constexpr (std::is_same_v) { + return "color"; + } + else if constexpr (std::is_same_v) { + return "string"; + } + else if constexpr (std::is_same_v) { + return "tuple"; + } + + return ""; +} + +template +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()) { + THROWM( + Base::ExpressionError, + fmt::format("Argument '{}' must be {}, got {}", name, valueTypeName(), value->toString()) + ); + } + + return value->get(); +} + +/** + * @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 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("color"); + * auto& amount = resolved.get("amount"); + * @endcode + */ +class GuiExport ArgumentParser +{ +public: + ArgumentParser(std::initializer_list params); + + Tuple resolve(const Tuple& args) const; + +private: + std::vector params_; +}; + +} // namespace Gui::StyleParameters + +#endif // STYLEPARAMETERS_VALUE_H From 7bf20508651b430d11373c250df6d6bed610d225 Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Sun, 8 Feb 2026 15:12:31 +0100 Subject: [PATCH 7/8] Gui: Add dot syntax for tuple member access in StyleParameters Add MemberAccess AST node and postfix `.member` parsing to support extracting individual elements from tuple-valued parameters, e.g. `@ButtonBorderThickness.left` or `@Grays.0`. Co-Authored-By: Claude Opus 4.6 --- src/Gui/StyleParameters/Parser.cpp | 101 ++++++++++---- src/Gui/StyleParameters/Parser.h | 14 ++ tests/src/Gui/StyleParameters/ParserTest.cpp | 132 +++++++++++++++++++ 3 files changed, 218 insertions(+), 29 deletions(-) diff --git a/src/Gui/StyleParameters/Parser.cpp b/src/Gui/StyleParameters/Parser.cpp index 2e46dea7f5..e2f004282f 100644 --- a/src/Gui/StyleParameters/Parser.cpp +++ b/src/Gui/StyleParameters/Parser.cpp @@ -28,9 +28,7 @@ #include #include -#include -#include -#include +#include #include namespace Gui::StyleParameters @@ -161,6 +159,26 @@ Value UnaryOp::evaluate(const EvaluationContext& context) const } } +Value MemberAccess::evaluate(const EvaluationContext& context) const +{ + Value val = object->evaluate(context); + if (!val.holds()) { + THROWM(Base::ExpressionError, "Member access requires a tuple"); + } + + const auto& tuple = val.get(); + + 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 Parser::parse() { auto expr = parseExpression(); @@ -222,41 +240,54 @@ std::unique_ptr Parser::parseFactor() Operator op = (input[pos - 1] == '+') ? Operator::Add : Operator::Subtract; return std::make_unique(op, parseFactor()); } + + std::unique_ptr expr; + if (match('(')) { // Disambiguation: tuple vs grouped expression // 1. If we see `identifier:` pattern → definitely a tuple if (peekNamedElement()) { - return parseTuple(); + expr = parseTuple(); } + else { + // 2. Otherwise parse first expression + expr = parseExpression(); + skipWhitespace(); - // 2. Otherwise parse first expression - auto 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); - return parseTuple(std::move(first)); + // 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])); + } + } } + } + else if (peekColor()) { + expr = parseColor(); + } + else if (peekParameter()) { + expr = parseParameter(); + } + else if (peekFunction()) { + expr = parseFunctionCall(); + } + else { + expr = parseNumber(); + } - // If followed by `)` → grouped expression (backward compatible) - if (!match(')')) { - THROWM(Base::ParserError, fmt::format("Expected ')', got '{}'", input[pos])); - } - return expr; + while (pos < input.size() && input[pos] == '.') { + ++pos; + expr = std::make_unique(std::move(expr), parseMember()); } - if (peekColor()) { - return parseColor(); - } - if (peekParameter()) { - return parseParameter(); - } - if (peekFunction()) { - return parseFunctionCall(); - } - return parseNumber(); + + return expr; } bool Parser::peekColor() @@ -504,6 +535,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(); diff --git a/src/Gui/StyleParameters/Parser.h b/src/Gui/StyleParameters/Parser.h index 70c73bd15a..3864607619 100644 --- a/src/Gui/StyleParameters/Parser.h +++ b/src/Gui/StyleParameters/Parser.h @@ -145,6 +145,19 @@ struct GuiExport UnaryOp: public Expr Value evaluate(const EvaluationContext& context) const override; }; +struct GuiExport MemberAccess: public Expr +{ + std::unique_ptr object; + std::string member; + + MemberAccess(std::unique_ptr 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("; @@ -174,6 +187,7 @@ private: int parseInt(); std::unique_ptr parseNumber(); std::string parseUnit(); + std::string parseMember(); bool peekNamedElement(); std::unique_ptr parseTuple( std::optional firstElement = std::nullopt diff --git a/tests/src/Gui/StyleParameters/ParserTest.cpp b/tests/src/Gui/StyleParameters/ParserTest.cpp index f2c5715240..c2370fe35c 100644 --- a/tests/src/Gui/StyleParameters/ParserTest.cpp +++ b/tests/src/Gui/StyleParameters/ParserTest.cpp @@ -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"} ); @@ -890,6 +893,135 @@ TEST_F(ParserTest, ParseNestedParentheses) } } +// 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(result)); + + auto length = std::get(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(result)); + + auto length = std::get(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(result)); + EXPECT_DOUBLE_EQ(std::get(result).value, 10.0); + } + + { + Parser parser("@TestIndexedTuple.2"); + auto expr = parser.parse(); + + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + EXPECT_DOUBLE_EQ(std::get(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(result)); + EXPECT_DOUBLE_EQ(std::get(result).value, 10.0); + } + + { + Parser parser("(10, 20, 30).1"); + auto expr = parser.parse(); + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(std::holds_alternative(result)); + EXPECT_DOUBLE_EQ(std::get(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(result)); + + auto length = std::get(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(result)); + EXPECT_DOUBLE_EQ(std::get(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 + ); +} + // ArgumentParser tests class ArgumentParserTest: public ::testing::Test From 1cc53d911ad1f7daec9865abe98330ba9ddbe921 Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Sun, 8 Feb 2026 16:27:09 +0100 Subject: [PATCH 8/8] Gui: Add element-wise tuple arithmetic to StyleParameters Add arithmetic operators (+, -, *, /, unary -) to Value, enabling element-wise operations on tuples and scalar broadcast with numerics. Tuple addition/subtraction uses union semantics: named elements match by name, unnamed elements match positionally, and unmatched elements pass through. BinaryOp and UnaryOp now delegate to these Value operators. Co-Authored-By: Claude Opus 4.6 --- src/Gui/StyleParameters/Parser.cpp | 23 +- src/Gui/StyleParameters/Value.cpp | 158 ++++++++++++ src/Gui/StyleParameters/Value.h | 14 ++ tests/src/Gui/StyleParameters/ParserTest.cpp | 242 +++++++++++++++++++ 4 files changed, 420 insertions(+), 17 deletions(-) diff --git a/src/Gui/StyleParameters/Parser.cpp b/src/Gui/StyleParameters/Parser.cpp index e2f004282f..7de687a8dc 100644 --- a/src/Gui/StyleParameters/Parser.cpp +++ b/src/Gui/StyleParameters/Parser.cpp @@ -109,22 +109,15 @@ 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; + 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"); } @@ -144,16 +137,12 @@ Value TupleLiteral::evaluate(const EvaluationContext& context) const 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; + return val; case Operator::Subtract: - return -v; + return -val; default: THROWM(Base::ExpressionError, "Unknown unary operator"); } diff --git a/src/Gui/StyleParameters/Value.cpp b/src/Gui/StyleParameters/Value.cpp index 13761842b0..c0a9937ec9 100644 --- a/src/Gui/StyleParameters/Value.cpp +++ b/src/Gui/StyleParameters/Value.cpp @@ -23,6 +23,7 @@ #include "Value.h" +#include #include #include @@ -117,6 +118,163 @@ std::string Value::toString() const return get(); } +namespace +{ + +Tuple elementWise(const Tuple& lhs, const Tuple& rhs, auto op) +{ + Tuple result; + std::vector 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(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 lhsUnnamed; + std::vector 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(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(op(tuple.at(i), scalar)) + ); + } + return result; +} + +} // namespace + +Value Value::operator+(const Value& rhs) const +{ + if (holds() && rhs.holds()) { + return get() + rhs.get(); + } + if (holds() && rhs.holds()) { + return elementWise(get(), rhs.get(), std::plus {}); + } + THROWM(Base::ExpressionError, "Addition requires two numerics or two tuples of equal size"); +} + +Value Value::operator-(const Value& rhs) const +{ + if (holds() && rhs.holds()) { + return get() - rhs.get(); + } + if (holds() && rhs.holds()) { + return elementWise(get(), rhs.get(), std::minus {}); + } + THROWM(Base::ExpressionError, "Subtraction requires two numerics or two tuples of equal size"); +} + +Value Value::operator*(const Value& rhs) const +{ + if (holds() && rhs.holds()) { + return get() * rhs.get(); + } + if (holds() && rhs.holds()) { + return scalarBroadcast(get(), rhs, std::multiplies {}); + } + if (holds() && rhs.holds()) { + return scalarBroadcast(rhs.get(), *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() && rhs.holds()) { + return get() / rhs.get(); + } + if (holds() && rhs.holds()) { + return scalarBroadcast(get(), rhs, std::divides {}); + } + THROWM(Base::ExpressionError, "Division requires two numerics or tuple divided by scalar"); +} + +Value Value::operator-() const +{ + if (holds()) { + return -get(); + } + if (holds()) { + Tuple result; + const auto& tuple = get(); + for (size_t i = 0; i < tuple.size(); ++i) { + result.elements.emplace_back( + tuple.elements[i].name, + std::make_shared(-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()) { diff --git a/src/Gui/StyleParameters/Value.h b/src/Gui/StyleParameters/Value.h index 4aff64f191..46f4dff0ac 100644 --- a/src/Gui/StyleParameters/Value.h +++ b/src/Gui/StyleParameters/Value.h @@ -181,6 +181,20 @@ struct GuiExport Value: std::variant { return std::get(*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 diff --git a/tests/src/Gui/StyleParameters/ParserTest.cpp b/tests/src/Gui/StyleParameters/ParserTest.cpp index c2370fe35c..35b6d8e752 100644 --- a/tests/src/Gui/StyleParameters/ParserTest.cpp +++ b/tests/src/Gui/StyleParameters/ParserTest.cpp @@ -1022,6 +1022,248 @@ TEST_F(ParserTest, MemberAccessErrors) ); } +// 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()); + + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 2); + EXPECT_DOUBLE_EQ(tuple.at(0).get().value, 4.0); + EXPECT_EQ(tuple.at(0).get().unit, "px"); + EXPECT_DOUBLE_EQ(tuple.at(1).get().value, 6.0); + EXPECT_EQ(tuple.at(1).get().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()); + + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 2); + EXPECT_DOUBLE_EQ(tuple.at(0).get().value, 7.0); + EXPECT_DOUBLE_EQ(tuple.at(1).get().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()); + + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 3); + EXPECT_DOUBLE_EQ(tuple.at(0).get().value, 2.0); + EXPECT_EQ(tuple.at(0).get().unit, "px"); + EXPECT_DOUBLE_EQ(tuple.at(1).get().value, 4.0); + EXPECT_DOUBLE_EQ(tuple.at(2).get().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()); + + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 3); + EXPECT_DOUBLE_EQ(tuple.at(0).get().value, 2.0); + EXPECT_EQ(tuple.at(0).get().unit, "px"); + EXPECT_DOUBLE_EQ(tuple.at(1).get().value, 4.0); + EXPECT_DOUBLE_EQ(tuple.at(2).get().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()); + + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 2); + EXPECT_DOUBLE_EQ(tuple.at(0).get().value, 5.0); + EXPECT_DOUBLE_EQ(tuple.at(1).get().value, 10.0); +} + +TEST_F(ParserTest, TupleUnaryNegate) +{ + Parser parser("-(1px, 2px)"); + auto expr = parser.parse(); + + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(result.holds()); + + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 2); + EXPECT_DOUBLE_EQ(tuple.at(0).get().value, -1.0); + EXPECT_EQ(tuple.at(0).get().unit, "px"); + EXPECT_DOUBLE_EQ(tuple.at(1).get().value, -2.0); + EXPECT_EQ(tuple.at(1).get().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()); + + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 2); + + auto* x = tuple.find("x"); + ASSERT_NE(x, nullptr); + EXPECT_DOUBLE_EQ(x->get().value, 4.0); + + auto* y = tuple.find("y"); + ASSERT_NE(y, nullptr); + EXPECT_DOUBLE_EQ(y->get().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()); + + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 2); + + auto* x = tuple.find("x"); + ASSERT_NE(x, nullptr); + EXPECT_DOUBLE_EQ(x->get().value, 15.0); + + auto* y = tuple.find("y"); + ASSERT_NE(y, nullptr); + EXPECT_DOUBLE_EQ(y->get().value, 50.0); +} + +TEST_F(ParserTest, TupleParamArithmetic) +{ + Parser parser("@TestTupleParam + @TestTupleParam"); + auto expr = parser.parse(); + + auto result = expr->evaluate({&manager, {}}); + EXPECT_TRUE(result.holds()); + + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 4); + EXPECT_DOUBLE_EQ(tuple.at(0).get().value, 2.0); + EXPECT_DOUBLE_EQ(tuple.at(1).get().value, 4.0); + EXPECT_DOUBLE_EQ(tuple.at(2).get().value, 6.0); + EXPECT_DOUBLE_EQ(tuple.at(3).get().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()); + + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 4); + + auto* x = tuple.find("x"); + ASSERT_NE(x, nullptr); + EXPECT_DOUBLE_EQ(x->get().value, 5.0); + + auto* y = tuple.find("y"); + ASSERT_NE(y, nullptr); + EXPECT_DOUBLE_EQ(y->get().value, 10.0); + + EXPECT_DOUBLE_EQ(tuple.at(2).get().value, 15.0); + EXPECT_DOUBLE_EQ(tuple.at(3).get().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()); + + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 3); + + EXPECT_DOUBLE_EQ(tuple.find("x")->get().value, 4.0); + EXPECT_DOUBLE_EQ(tuple.find("y")->get().value, 6.0); + EXPECT_DOUBLE_EQ(tuple.find("z")->get().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()); + + const auto& tuple = result.get(); + EXPECT_EQ(tuple.size(), 3); + + EXPECT_DOUBLE_EQ(tuple.at(0).get().value, 4.0); + EXPECT_DOUBLE_EQ(tuple.at(1).get().value, 6.0); + EXPECT_DOUBLE_EQ(tuple.at(2).get().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()); + + const auto& outer = result.get(); + EXPECT_EQ(outer.size(), 2); + + EXPECT_TRUE(outer.at(0).holds()); + const auto& inner1 = outer.at(0).get(); + EXPECT_DOUBLE_EQ(inner1.at(0).get().value, 2.0); + EXPECT_DOUBLE_EQ(inner1.at(1).get().value, 4.0); + + EXPECT_TRUE(outer.at(1).holds()); + const auto& inner2 = outer.at(1).get(); + EXPECT_DOUBLE_EQ(inner2.at(0).get().value, 6.0); + EXPECT_DOUBLE_EQ(inner2.at(1).get().value, 8.0); +} + // ArgumentParser tests class ArgumentParserTest: public ::testing::Test