feat(editor and runtime): per-channel (R/G/B/A) passthrough color pro… (#13324) 20db61d6e7 feat(editor and runtime): per-channel (R/G/B/A) passthrough color properties Co-authored-by: hernan <hernan@rive.app>
diff --git a/.rive_head b/.rive_head index 5ee99bb..0e228a2 100644 --- a/.rive_head +++ b/.rive_head
@@ -1 +1 @@ -e9f24d0296d8f2513b0ac1287b9c5fb5f1314f7f +20db61d6e7ec27f06f7d485ab8ad293b540508a3
diff --git a/dev/core_generator/lib/src/definition.dart b/dev/core_generator/lib/src/definition.dart index 597967b..66acbac 100644 --- a/dev/core_generator/lib/src/definition.dart +++ b/dev/core_generator/lib/src/definition.dart
@@ -47,6 +47,10 @@ Definition? _rawExtensionOf; Key? _key; bool _isAbstract = false; + bool _isMixin = false; + bool get isMixin => _isMixin; + final List<Definition> _mixinsOf = []; + List<Definition> get mixinsOf => _mixinsOf; bool _editorOnly = false; bool _forRuntime = true; bool get forRuntime => _forRuntime; @@ -103,6 +107,40 @@ if (abstractValue is bool) { _isAbstract = abstractValue; } + dynamic isMixinValue = data['isMixin']; + if (isMixinValue is bool) { + _isMixin = isMixinValue; + } + // Parse mixins. In the C++ runtime a mixin is emitted as a non-Core + // interface base (multiple inheritance) plus per-consumer dispatch in + // core_registry (via a static from(Core*) resolver). We record the mixin + // relationship for codegen/registry dispatch, but do not fold the mixin's + // properties into the consumer's own property list. + void addMixin(String name) { + // Editor-only mixins (e.g. publishable, taggable) are not synced into the + // runtime def tree. Skip any mixin whose def is absent here — only + // runtime-capable mixins (e.g. color_channels) participate in C++. + if (!File(defsPath + name).existsSync()) { + return; + } + final mixinDef = Definition.make(name); + if (mixinDef != null) { + _mixinsOf.add(mixinDef); + } + } + + dynamic mixinFilename = data['mixin']; + if (mixinFilename is String) { + addMixin(mixinFilename); + } + dynamic mixinFilenames = data['mixins']; + if (mixinFilenames is List) { + for (final name in mixinFilenames) { + if (name is String) { + addMixin(name); + } + } + } dynamic editorOnlyValue = data['editorOnly']; if (editorOnlyValue is bool) { _editorOnly = editorOnlyValue; @@ -168,6 +206,23 @@ } } if (target == null) { + // A mixin can passthrough a mask provided by the host class that + // includes it (e.g. `colorValue` on SolidColor/GradientStop). The + // mask is not a same-def sibling; core_registry drives it per + // consuming type. Bit/width validation still applies. + if (_isMixin) { + p.bitmaskTargetIsHostProvided = true; + final bit = p.passthroughBit!; + final width = p.passthroughBitWidthOrDefault; + if (bit < 0 || width < 1 || bit + width > 32) { + color( + '${p.name}: passthroughBit/passthroughBitWidth must fit in 0..32 ' + '(bit $bit, width $width).', + front: Styles.RED, + ); + } + continue; + } color( '${p.name}: passthroughForBitmask "${p.passthroughForBitmask}" ' 'not found.', @@ -175,9 +230,10 @@ ); continue; } - if (target.type.name != 'uint') { + // Color masks are uint32 under the hood, so they are valid targets too. + if (target.type.name != 'uint' && target.type.name != 'Color') { color( - '${p.name}: passthroughForBitmask target must be uint.', + '${p.name}: passthroughForBitmask target must be uint or Color.', front: Styles.RED, ); continue; @@ -244,11 +300,113 @@ String get concreteCodeFilename => 'rive/${stripExtension(_filename)}.hpp'; String get localCppCodeFilename => '${stripExtension(_filename)}_base.cpp'; + /// Runtime types that include this mixin (scanned once all defs are loaded). + List<Definition> get _mixinConsumers => definitions.values + .where((d) => d.forRuntime && d._mixinsOf.contains(this)) + .toList(); + + /// A runtime mixin is emitted as a non-Core interface class (like + /// [ListConstraint]): it declares the host mask(s) as pure virtuals — which + /// the consuming type's own generated accessor satisfies — and provides the + /// shared channel accessor methods + key constants. A static [from] resolves + /// a Core object to the interface (or nullptr). Consuming types inherit it as + /// a second base, and core_registry dispatches the shared keys through + /// [from]. + Future<void> _generateMixinInterfaceHeader() async { + // Distinct host-provided masks required by this mixin's passthroughs. + final hostMasks = <String>{}; + for (final property in properties) { + if (property.isBitmaskPassthrough && + property.bitmaskTargetIsHostProvided) { + hostMasks.add(property.passthroughForBitmask!); + } + } + + StringBuffer code = StringBuffer(); + code.writeln('#include <cstdint>'); + code.writeln('namespace rive {'); + code.writeln('class Core;'); + code.writeln('class ${_name}Base {'); + code.writeln('public:'); + code.writeln('static ${_name}Base* from(Core* object);'); + // The host provides the packed mask; its own accessor overrides these. + for (final mask in hostMasks) { + code.writeln('virtual int $mask() const = 0;'); + code.writeln('virtual void $mask(int value) = 0;'); + } + for (final property in properties) { + code.writeln('static const uint16_t ${property.name}PropertyKey = ' + '${property.key!.intValue};'); + for (final altKey in property.key!.alternates) { + code.writeln('static const uint16_t ${altKey.stringValue}PropertyKey = ' + '${altKey.intValue};'); + } + if (property.isBitmaskPassthrough && + property.bitmaskTargetIsHostProvided && + property.type.name == 'uint') { + final mask = property.passthroughForBitmask!; + final bit = property.passthroughBit!; + final width = property.passthroughBitWidthOrDefault; + final fieldMask = ((1 << width) - 1) << bit; + final valueMask = (1 << width) - 1; + code.writeln('static const uint32_t ${property.name}BitOffset = $bit;'); + code.writeln('static const uint32_t ${property.name}FieldMask = ' + '${fieldMask}u;'); + code.writeln('uint32_t ${property.name}() const { return ' + '(static_cast<uint32_t>($mask()) >> $bit) & ${valueMask}u; }'); + code.writeln('void ${property.name}(uint32_t value) {'); + // Clamp to the field's range so a channel saturates instead of + // wrapping into the neighbouring byte. + code.writeln('if (value > ${valueMask}u) { value = ${valueMask}u; }'); + code.writeln('const int _cur = $mask();'); + code.writeln('const int _fieldMask = static_cast<int>(${fieldMask}u);'); + code.writeln('const int _next = static_cast<int>(' + '(_cur & ~_fieldMask) | ((value << $bit) & _fieldMask));'); + code.writeln('if (_cur != _next) { $mask(_next); }'); + code.writeln('}'); + } + } + code.writeln('};'); + code.writeln('}'); + + var file = File('$generatedHppPath$localCodeFilename'); + file.createSync(recursive: true); + var formattedCode = + await _formatter.formatAndGuard('${_name}Base', code.toString()); + file.writeAsStringSync(formattedCode, flush: true); + + // Emit the from() implementation switching over consuming types. + final consumers = _mixinConsumers; + StringBuffer cpp = StringBuffer(); + cpp.writeln('#include "rive/generated/$localCodeFilename"'); + cpp.writeln('#include "rive/core.hpp"'); + for (final consumer in consumers) { + cpp.writeln('#include "${consumer.concreteCodeFilename}"'); + } + cpp.writeln('using namespace rive;'); + cpp.writeln('${_name}Base* ${_name}Base::from(Core* object) {'); + cpp.writeln('switch (object->coreType()) {'); + for (final consumer in consumers) { + cpp.writeln('case ${consumer.name}Base::typeKey:'); + cpp.writeln('return object->as<${consumer.name}Base>();'); + } + cpp.writeln('} return nullptr; }'); + + var cppFile = File('$generatedCppPath$localCppCodeFilename'); + cppFile.createSync(recursive: true); + var formattedCpp = await _formatter.format(cpp.toString()); + cppFile.writeAsStringSync(formattedCpp, flush: true); + } + /// Generates cpp header code based on the Definition Future<void> generateCode() async { if (!_forRuntime) { return; } + if (_isMixin) { + await _generateMixinInterfaceHeader(); + return; + } bool defineContextExtension = _extensionOf?._name == null; StringBuffer code = StringBuffer(); @@ -266,6 +424,10 @@ property.type.snakeRuntimeCoreName + '.hpp'); } + // Runtime mixins are inherited as (non-Core) second bases. + for (final mixin in _mixinsOf) { + includes.add('rive/generated/${mixin.localCodeFilename}'); + } var sortedIncludes = includes.toList()..sort(); for (final include in sortedIncludes) { @@ -280,7 +442,9 @@ code.writeln('namespace rive {'); var superTypeName = defineContextExtension ? 'Core' : _extensionOf?._name; - code.writeln('class ${_name}Base : public $superTypeName {'); + final mixinBases = + _mixinsOf.map((mixin) => ', public ${mixin.name}Base').join(); + code.writeln('class ${_name}Base : public $superTypeName$mixinBases {'); code.writeln('protected:'); code.writeln('typedef $superTypeName Super;'); @@ -385,6 +549,11 @@ if (property.isBitmaskPassthrough) { continue; } + // A stored mask property (e.g. colorValue) that satisfies a mixin's + // host-provided pure virtual must be marked `override`. + final overridesMixinMask = _mixinsOf.any((m) => m.properties.any((mp) => + mp.bitmaskTargetIsHostProvided && + mp.passthroughForBitmask == property.name)); if (!property.getExportType().storesData) { code.writeln((property.isSetOverride ? '' : 'virtual ') + 'void ${property.name}' + @@ -407,8 +576,7 @@ (property.isSetOverride ? 'override' : '') + '= 0;'); } else if (property.isPassthrough) { - code.writeln( - 'virtual void set${property.capitalizedName}(' + code.writeln('virtual void set${property.capitalizedName}(' '${property.type.cppGetterName} value) = 0;'); code.writeln( 'virtual ${property.type.cppGetterName} ${property.name}() ' @@ -428,12 +596,16 @@ ? 'virtual' : 'inline') + ' ${property.type.cppGetterName} ${property.name}() const ' + - (property.isGetOverride ? 'override' : '') + + ((property.isGetOverride || overridesMixinMask) + ? 'override' + : '') + '{ return m_${property.capitalizedName}; }'); if (!property.isPureVirtual) { code.writeln( 'void ${property.name}(${property.type.cppName} value) ' + - (property.isSetOverride ? 'override' : '') + + ((property.isSetOverride || overridesMixinMask) + ? 'override' + : '') + '{' 'if(m_${property.capitalizedName} == value)' '{return;}' @@ -660,12 +832,18 @@ var runtimeDefinitions = definitions.values.where((definition) => definition.forRuntime); for (final definition in runtimeDefinitions) { - includes.add(definition.concreteCodeFilename); + // Mixins have no concrete class; include their (constants-only) base + // header directly so core_registry can reference the shared key + // constants. + includes.add(definition._isMixin + ? 'rive/generated/${definition.localCodeFilename}' + : definition.concreteCodeFilename); } var includeList = includes.toList()..sort(); for (final include in includeList) { ctxCode.writeln('#include "$include"'); } + ctxCode.writeln('namespace rive {class CoreRegistry {' 'public:'); ctxCode.writeln('static Core* makeCoreInstance(int typeKey) {' @@ -706,7 +884,22 @@ if (property.isWithRiveToolsOnly) { addPreprocessorStart(ctxCode, withRiveToolsPreprocessor); } - if (property.isBitmaskPassthrough) { + if (property.isBitmaskPassthrough && + property.bitmaskTargetIsHostProvided) { + // Shared channel defined in a mixin. Resolve the interface via + // from() and let it do the masked write on the host's mask. + final iface = '${property.definition.name}Base'; + ctxCode.writeln('case $iface::${property.name}PropertyKey:'); + for (final altKey in property.key!.alternates) { + ctxCode.writeln('case $iface' + '::${altKey.stringValue}PropertyKey:'); + } + ctxCode.writeln('{'); + ctxCode.writeln('if (auto* _c = $iface::from(object)) { ' + '_c->${property.name}(value); }'); + ctxCode.writeln('break;'); + ctxCode.writeln('}'); + } else if (property.isBitmaskPassthrough) { final mask = property.bitmaskTargetProperty!.name; final bit = property.passthroughBit!; final maskType = property.bitmaskTargetProperty!.type.cppName; @@ -720,29 +913,23 @@ } } ctxCode.writeln('{'); - ctxCode.writeln( - 'auto* _o = object->as<${defName}Base>();'); + ctxCode.writeln('auto* _o = object->as<${defName}Base>();'); ctxCode.writeln('if (_o) {'); - ctxCode.writeln( - 'const $maskType _cur = _o->$mask();'); + ctxCode.writeln('const $maskType _cur = _o->$mask();'); if (property.type.name == 'uint') { final width = property.passthroughBitWidthOrDefault; final fieldMask = ((1 << width) - 1) << bit; - ctxCode.writeln( - 'const $maskType _fieldMask = ' + ctxCode.writeln('const $maskType _fieldMask = ' 'static_cast<$maskType>(${fieldMask}u);'); - ctxCode.writeln( - 'const $maskType _next = static_cast<$maskType>((' + ctxCode.writeln('const $maskType _next = static_cast<$maskType>((' '_cur & ~_fieldMask) | ((value << $bit) & _fieldMask));'); } else { ctxCode.writeln( 'const $maskType _bm = static_cast<$maskType>(1u << $bit);'); - ctxCode.writeln( - 'const $maskType _next = static_cast<$maskType>((' + ctxCode.writeln('const $maskType _next = static_cast<$maskType>((' '_cur & ~_bm) | (value ? _bm : static_cast<$maskType>(0)));'); } - ctxCode.writeln( - 'if (_cur != _next) { _o->$mask(_next); }'); + ctxCode.writeln('if (_cur != _next) { _o->$mask(_next); }'); ctxCode.writeln('}'); ctxCode.writeln('break;'); ctxCode.writeln('}'); @@ -792,7 +979,13 @@ ctxCode.writeln('case ${property.definition.name}Base' '::${altKey.stringValue}PropertyKey:'); } - if (property.isBitmaskPassthrough) { + if (property.isBitmaskPassthrough && + property.bitmaskTargetIsHostProvided) { + final iface = '${property.definition.name}Base'; + ctxCode.writeln('if (auto* _c = $iface::from(object)) { ' + 'return _c->${property.name}(); }'); + ctxCode.writeln('return 0u;'); + } else if (property.isBitmaskPassthrough) { final mask = property.bitmaskTargetProperty!.name; final bit = property.passthroughBit!; final width = property.passthroughBitWidthOrDefault; @@ -825,9 +1018,9 @@ var properties = usedFieldTypes[fieldType]; if (properties != null) { for (final property in properties) { - if (property.isBitmaskPassthrough) { - continue; - } + // Bitmask passthroughs are not serialized on their own, but they DO + // have a runtime field type (their value type, e.g. uint) that data + // binding needs to resolve the target value — so include them here. if (property.isWithRiveToolsOnly) { addPreprocessorStart(ctxCode, withRiveToolsPreprocessor); } @@ -887,8 +1080,15 @@ ctxCode.writeln('case ${property.definition.name}Base' '::${altKey.stringValue}PropertyKey:'); } - ctxCode - .writeln('return object->is<${property.definition.name}Base>();'); + if (property.bitmaskTargetIsHostProvided) { + // Shared mixin key: supported by any consuming type. + ctxCode + .writeln('return ${property.definition.name}Base::from(object) ' + '!= nullptr;'); + } else { + ctxCode.writeln( + 'return object->is<${property.definition.name}Base>();'); + } if (property.isWithRiveToolsOnly) { addPreprocessorEnd(ctxCode); }
diff --git a/dev/core_generator/lib/src/property.dart b/dev/core_generator/lib/src/property.dart index 615f126..a95a5c5 100644 --- a/dev/core_generator/lib/src/property.dart +++ b/dev/core_generator/lib/src/property.dart
@@ -27,6 +27,12 @@ int? passthroughBit; int? passthroughBitWidth; Property? bitmaskTargetProperty; + + /// True when this is a bitmask passthrough defined in a mixin whose mask + /// ([passthroughForBitmask]) is provided by the host class that includes the + /// mixin (e.g. `colorValue` on SolidColor/GradientStop), not a same-def + /// sibling. core_registry dispatches these per consuming type. + bool bitmaskTargetIsHostProvided = false; bool isPureVirtual = false; FieldType? typeRuntime;
diff --git a/dev/defs/shapes/paint/color_channels.json b/dev/defs/shapes/paint/color_channels.json new file mode 100644 index 0000000..9493775 --- /dev/null +++ b/dev/defs/shapes/paint/color_channels.json
@@ -0,0 +1,67 @@ +{ + "name": "ColorChannels", + "key": { + "int": 101, + "string": "colorchannels" + }, + "abstract": true, + "isMixin": true, + "properties": { + "colorRed": { + "type": "uint", + "initialValue": "0", + "animates": true, + "key": { + "int": 118, + "string": "colorred" + }, + "description": "Red channel (0-255) of the host colorValue. Passthrough view.", + "bindable": true, + "passthroughForBitmask": "colorValue", + "passthroughBit": 16, + "passthroughBitWidth": 8 + }, + "colorGreen": { + "type": "uint", + "initialValue": "0", + "animates": true, + "key": { + "int": 136, + "string": "colorgreen" + }, + "description": "Green channel (0-255) of the host colorValue. Passthrough view.", + "bindable": true, + "passthroughForBitmask": "colorValue", + "passthroughBit": 8, + "passthroughBitWidth": 8 + }, + "colorBlue": { + "type": "uint", + "initialValue": "0", + "animates": true, + "key": { + "int": 210, + "string": "colorblue" + }, + "description": "Blue channel (0-255) of the host colorValue. Passthrough view.", + "bindable": true, + "passthroughForBitmask": "colorValue", + "passthroughBit": 0, + "passthroughBitWidth": 8 + }, + "colorAlpha": { + "type": "uint", + "initialValue": "0", + "animates": true, + "key": { + "int": 218, + "string": "coloralpha" + }, + "description": "Alpha channel (0-255) of the host colorValue. Passthrough view.", + "bindable": true, + "passthroughForBitmask": "colorValue", + "passthroughBit": 24, + "passthroughBitWidth": 8 + } + } +} \ No newline at end of file
diff --git a/dev/defs/shapes/paint/gradient_stop.json b/dev/defs/shapes/paint/gradient_stop.json index 4555199..d92be92 100644 --- a/dev/defs/shapes/paint/gradient_stop.json +++ b/dev/defs/shapes/paint/gradient_stop.json
@@ -5,6 +5,9 @@ "string": "gradientstop" }, "extends": "component.json", + "mixins": [ + "shapes/paint/color_channels.json" + ], "properties": { "colorValue": { "type": "Color",
diff --git a/dev/defs/shapes/paint/solid_color.json b/dev/defs/shapes/paint/solid_color.json index 7182495..002a319 100644 --- a/dev/defs/shapes/paint/solid_color.json +++ b/dev/defs/shapes/paint/solid_color.json
@@ -5,6 +5,9 @@ "string": "solidcolor" }, "extends": "component.json", + "mixins": [ + "shapes/paint/color_channels.json" + ], "properties": { "colorValue": { "type": "Color",
diff --git a/include/rive/generated/core_registry.hpp b/include/rive/generated/core_registry.hpp index 77b82e3..592bc69 100644 --- a/include/rive/generated/core_registry.hpp +++ b/include/rive/generated/core_registry.hpp
@@ -211,6 +211,7 @@ #include "rive/event.hpp" #include "rive/focus_data.hpp" #include "rive/foreground_layout_drawable.hpp" +#include "rive/generated/shapes/paint/color_channels_base.hpp" #include "rive/inputs/gamepad_input.hpp" #include "rive/inputs/keyboard_input.hpp" #include "rive/inputs/semantic_input.hpp" @@ -1466,6 +1467,38 @@ case TargetEffectBase::targetIdPropertyKey: object->as<TargetEffectBase>()->targetId(value); break; + case ColorChannelsBase::colorRedPropertyKey: + { + if (auto* _c = ColorChannelsBase::from(object)) + { + _c->colorRed(value); + } + break; + } + case ColorChannelsBase::colorGreenPropertyKey: + { + if (auto* _c = ColorChannelsBase::from(object)) + { + _c->colorGreen(value); + } + break; + } + case ColorChannelsBase::colorBluePropertyKey: + { + if (auto* _c = ColorChannelsBase::from(object)) + { + _c->colorBlue(value); + } + break; + } + case ColorChannelsBase::colorAlphaPropertyKey: + { + if (auto* _c = ColorChannelsBase::from(object)) + { + _c->colorAlpha(value); + } + break; + } case StrokeBase::capPropertyKey: object->as<StrokeBase>()->cap(value); break; @@ -3589,6 +3622,30 @@ return object->as<ShapePaintBase>()->blendModeValue(); case TargetEffectBase::targetIdPropertyKey: return object->as<TargetEffectBase>()->targetId(); + case ColorChannelsBase::colorRedPropertyKey: + if (auto* _c = ColorChannelsBase::from(object)) + { + return _c->colorRed(); + } + return 0u; + case ColorChannelsBase::colorGreenPropertyKey: + if (auto* _c = ColorChannelsBase::from(object)) + { + return _c->colorGreen(); + } + return 0u; + case ColorChannelsBase::colorBluePropertyKey: + if (auto* _c = ColorChannelsBase::from(object)) + { + return _c->colorBlue(); + } + return 0u; + case ColorChannelsBase::colorAlphaPropertyKey: + if (auto* _c = ColorChannelsBase::from(object)) + { + return _c->colorAlpha(); + } + return 0u; case StrokeBase::capPropertyKey: return object->as<StrokeBase>()->cap(); case StrokeBase::joinPropertyKey: @@ -4657,6 +4714,10 @@ case BlendStateTransitionBase::exitBlendAnimationIdPropertyKey: case ShapePaintBase::blendModeValuePropertyKey: case TargetEffectBase::targetIdPropertyKey: + case ColorChannelsBase::colorRedPropertyKey: + case ColorChannelsBase::colorGreenPropertyKey: + case ColorChannelsBase::colorBluePropertyKey: + case ColorChannelsBase::colorAlphaPropertyKey: case StrokeBase::capPropertyKey: case StrokeBase::joinPropertyKey: case FeatherBase::spaceValuePropertyKey: @@ -4735,6 +4796,8 @@ case TextBase::verticalAlignValuePropertyKey: case TextBase::textRunListSourcePropertyKey: case TextBase::verticalTrimValuePropertyKey: + case TextBase::verticalTrimTopValuePropertyKey: + case TextBase::verticalTrimBottomValuePropertyKey: case TextValueRunBase::styleIdPropertyKey: case ArtboardListMapRuleBase::artboardIdPropertyKey: case ArtboardListMapRuleBase::viewModelIdPropertyKey: @@ -4826,8 +4889,32 @@ case PointsCommonPathBase::isClosedPropertyKey: case RectangleBase::linkCornerRadiusPropertyKey: case ClippingShapeBase::isVisiblePropertyKey: + case FocusDataBase::canFocusPropertyKey: + case FocusDataBase::canTouchPropertyKey: + case FocusDataBase::canTraversePropertyKey: case CustomPropertyBooleanBase::propertyValuePropertyKey: case LayoutComponentBase::clipPropertyKey: + case SemanticDataBase::isExpandablePropertyKey: + case SemanticDataBase::isSelectablePropertyKey: + case SemanticDataBase::isCheckablePropertyKey: + case SemanticDataBase::isToggleablePropertyKey: + case SemanticDataBase::isRequirablePropertyKey: + case SemanticDataBase::isEnablablePropertyKey: + case SemanticDataBase::isFocusablePropertyKey: + case SemanticDataBase::isExpandedPropertyKey: + case SemanticDataBase::isSelectedPropertyKey: + case SemanticDataBase::isCheckedPropertyKey: + case SemanticDataBase::isMixedPropertyKey: + case SemanticDataBase::isToggledPropertyKey: + case SemanticDataBase::isRequiredPropertyKey: + case SemanticDataBase::isDisabledPropertyKey: + case SemanticDataBase::isFocusedPropertyKey: + case SemanticDataBase::isHiddenPropertyKey: + case SemanticDataBase::isLiveRegionPropertyKey: + case SemanticDataBase::isReadOnlyPropertyKey: + case SemanticDataBase::isModalPropertyKey: + case SemanticDataBase::isObscuredPropertyKey: + case SemanticDataBase::isMultilinePropertyKey: case DataBindPathBase::isRelativePropertyKey: case BindablePropertyBooleanBase::propertyValuePropertyKey: case TextModifierRangeBase::clampPropertyKey: @@ -5423,6 +5510,14 @@ return object->is<ShapePaintBase>(); case TargetEffectBase::targetIdPropertyKey: return object->is<TargetEffectBase>(); + case ColorChannelsBase::colorRedPropertyKey: + return ColorChannelsBase::from(object) != nullptr; + case ColorChannelsBase::colorGreenPropertyKey: + return ColorChannelsBase::from(object) != nullptr; + case ColorChannelsBase::colorBluePropertyKey: + return ColorChannelsBase::from(object) != nullptr; + case ColorChannelsBase::colorAlphaPropertyKey: + return ColorChannelsBase::from(object) != nullptr; case StrokeBase::capPropertyKey: return object->is<StrokeBase>(); case StrokeBase::joinPropertyKey:
diff --git a/include/rive/generated/shapes/paint/color_channels_base.hpp b/include/rive/generated/shapes/paint/color_channels_base.hpp new file mode 100644 index 0000000..b60fdcd --- /dev/null +++ b/include/rive/generated/shapes/paint/color_channels_base.hpp
@@ -0,0 +1,104 @@ +#ifndef _RIVE_COLOR_CHANNELS_BASE_HPP_ +#define _RIVE_COLOR_CHANNELS_BASE_HPP_ +#include <cstdint> +namespace rive +{ +class Core; +class ColorChannelsBase +{ +public: + static ColorChannelsBase* from(Core* object); + virtual int colorValue() const = 0; + virtual void colorValue(int value) = 0; + static const uint16_t colorRedPropertyKey = 118; + static const uint32_t colorRedBitOffset = 16; + static const uint32_t colorRedFieldMask = 16711680u; + uint32_t colorRed() const + { + return (static_cast<uint32_t>(colorValue()) >> 16) & 255u; + } + void colorRed(uint32_t value) + { + if (value > 255u) + { + value = 255u; + } + const int _cur = colorValue(); + const int _fieldMask = static_cast<int>(16711680u); + const int _next = static_cast<int>((_cur & ~_fieldMask) | + ((value << 16) & _fieldMask)); + if (_cur != _next) + { + colorValue(_next); + } + } + static const uint16_t colorGreenPropertyKey = 136; + static const uint32_t colorGreenBitOffset = 8; + static const uint32_t colorGreenFieldMask = 65280u; + uint32_t colorGreen() const + { + return (static_cast<uint32_t>(colorValue()) >> 8) & 255u; + } + void colorGreen(uint32_t value) + { + if (value > 255u) + { + value = 255u; + } + const int _cur = colorValue(); + const int _fieldMask = static_cast<int>(65280u); + const int _next = static_cast<int>((_cur & ~_fieldMask) | + ((value << 8) & _fieldMask)); + if (_cur != _next) + { + colorValue(_next); + } + } + static const uint16_t colorBluePropertyKey = 210; + static const uint32_t colorBlueBitOffset = 0; + static const uint32_t colorBlueFieldMask = 255u; + uint32_t colorBlue() const + { + return (static_cast<uint32_t>(colorValue()) >> 0) & 255u; + } + void colorBlue(uint32_t value) + { + if (value > 255u) + { + value = 255u; + } + const int _cur = colorValue(); + const int _fieldMask = static_cast<int>(255u); + const int _next = static_cast<int>((_cur & ~_fieldMask) | + ((value << 0) & _fieldMask)); + if (_cur != _next) + { + colorValue(_next); + } + } + static const uint16_t colorAlphaPropertyKey = 218; + static const uint32_t colorAlphaBitOffset = 24; + static const uint32_t colorAlphaFieldMask = 4278190080u; + uint32_t colorAlpha() const + { + return (static_cast<uint32_t>(colorValue()) >> 24) & 255u; + } + void colorAlpha(uint32_t value) + { + if (value > 255u) + { + value = 255u; + } + const int _cur = colorValue(); + const int _fieldMask = static_cast<int>(4278190080u); + const int _next = static_cast<int>((_cur & ~_fieldMask) | + ((value << 24) & _fieldMask)); + if (_cur != _next) + { + colorValue(_next); + } + } +}; +} // namespace rive + +#endif \ No newline at end of file
diff --git a/include/rive/generated/shapes/paint/gradient_stop_base.hpp b/include/rive/generated/shapes/paint/gradient_stop_base.hpp index d35be56..e73ae15 100644 --- a/include/rive/generated/shapes/paint/gradient_stop_base.hpp +++ b/include/rive/generated/shapes/paint/gradient_stop_base.hpp
@@ -3,9 +3,10 @@ #include "rive/component.hpp" #include "rive/core/field_types/core_color_type.hpp" #include "rive/core/field_types/core_double_type.hpp" +#include "rive/generated/shapes/paint/color_channels_base.hpp" namespace rive { -class GradientStopBase : public Component +class GradientStopBase : public Component, public ColorChannelsBase { protected: typedef Component Super; @@ -37,8 +38,8 @@ float m_Position = 0.0f; public: - inline int colorValue() const { return m_ColorValue; } - void colorValue(int value) + inline int colorValue() const override { return m_ColorValue; } + void colorValue(int value) override { if (m_ColorValue == value) {
diff --git a/include/rive/generated/shapes/paint/solid_color_base.hpp b/include/rive/generated/shapes/paint/solid_color_base.hpp index 7111c8f..36dbc9d 100644 --- a/include/rive/generated/shapes/paint/solid_color_base.hpp +++ b/include/rive/generated/shapes/paint/solid_color_base.hpp
@@ -2,9 +2,10 @@ #define _RIVE_SOLID_COLOR_BASE_HPP_ #include "rive/component.hpp" #include "rive/core/field_types/core_color_type.hpp" +#include "rive/generated/shapes/paint/color_channels_base.hpp" namespace rive { -class SolidColorBase : public Component +class SolidColorBase : public Component, public ColorChannelsBase { protected: typedef Component Super; @@ -34,8 +35,8 @@ int m_ColorValue = 0xFF747474; public: - inline int colorValue() const { return m_ColorValue; } - void colorValue(int value) + inline int colorValue() const override { return m_ColorValue; } + void colorValue(int value) override { if (m_ColorValue == value) {
diff --git a/src/data_bind/context/context_target_value.cpp b/src/data_bind/context/context_target_value.cpp index e205710..d599384 100644 --- a/src/data_bind/context/context_target_value.cpp +++ b/src/data_bind/context/context_target_value.cpp
@@ -81,6 +81,10 @@ { m_targetValue = new DataValueViewModel(); } + else if (dataBind->sourceOutputType() == DataType::number) + { + m_targetValue = new DataValueNumber(); + } else { m_targetValue = new DataValueInteger(); @@ -255,6 +259,16 @@ { auto value = CoreRegistry::getUint(dataBind->target(), dataBind->propertyKey()); + // Match whatever target value type initialize() created: a + // number source (e.g. a color channel bound to a VM number) + // reads back as a DataValueNumber; integer/enum sources stay + // DataValueInteger. Keying off the real type keeps the two + // methods in lockstep regardless of source-resolution timing. + if (m_targetValue != nullptr && + m_targetValue->is<DataValueNumber>()) + { + return updateValue<DataValueNumber, float>((float)value); + } return updateValue<DataValueInteger, int>(value); } }
diff --git a/src/data_bind/data_bind.cpp b/src/data_bind/data_bind.cpp index f469b5b..adae98f 100644 --- a/src/data_bind/data_bind.cpp +++ b/src/data_bind/data_bind.cpp
@@ -413,7 +413,11 @@ key == ScrollConstraintBase::velocityYPropertyKey || key == ScrollConstraintBase::scrollActivePropertyKey || key == ScrollConstraintBase::computedContentWidthPropertyKey || - key == ScrollConstraintBase::computedContentHeightPropertyKey) + key == ScrollConstraintBase::computedContentHeightPropertyKey || + key == ColorChannelsBase::colorAlphaPropertyKey || + key == ColorChannelsBase::colorRedPropertyKey || + key == ColorChannelsBase::colorBluePropertyKey || + key == ColorChannelsBase::colorGreenPropertyKey) { return false; }
diff --git a/src/generated/shapes/paint/color_channels_base.cpp b/src/generated/shapes/paint/color_channels_base.cpp new file mode 100644 index 0000000..380c114 --- /dev/null +++ b/src/generated/shapes/paint/color_channels_base.cpp
@@ -0,0 +1,16 @@ +#include "rive/generated/shapes/paint/color_channels_base.hpp" +#include "rive/core.hpp" +#include "rive/shapes/paint/solid_color.hpp" +#include "rive/shapes/paint/gradient_stop.hpp" +using namespace rive; +ColorChannelsBase* ColorChannelsBase::from(Core* object) +{ + switch (object->coreType()) + { + case SolidColorBase::typeKey: + return object->as<SolidColorBase>(); + case GradientStopBase::typeKey: + return object->as<GradientStopBase>(); + } + return nullptr; +}
diff --git a/src/shapes/paint/gradient_stop.cpp b/src/shapes/paint/gradient_stop.cpp index 098eea2..d510497 100644 --- a/src/shapes/paint/gradient_stop.cpp +++ b/src/shapes/paint/gradient_stop.cpp
@@ -21,9 +21,15 @@ void GradientStop::colorValueChanged() { - parent()->as<LinearGradient>()->markGradientDirty(); + if (parent() != nullptr && parent()->is<LinearGradient>()) + { + parent()->as<LinearGradient>()->markGradientDirty(); + } } void GradientStop::positionChanged() { - parent()->as<LinearGradient>()->markStopsDirty(); + if (parent() != nullptr && parent()->is<LinearGradient>()) + { + parent()->as<LinearGradient>()->markStopsDirty(); + } } \ No newline at end of file
diff --git a/tests/unit_tests/assets/color_passthrough_test.riv b/tests/unit_tests/assets/color_passthrough_test.riv new file mode 100644 index 0000000..9058178 --- /dev/null +++ b/tests/unit_tests/assets/color_passthrough_test.riv Binary files differ
diff --git a/tests/unit_tests/runtime/color_channels_test.cpp b/tests/unit_tests/runtime/color_channels_test.cpp new file mode 100644 index 0000000..99ece99 --- /dev/null +++ b/tests/unit_tests/runtime/color_channels_test.cpp
@@ -0,0 +1,178 @@ +#include <rive/core/field_types/core_uint_type.hpp> +#include <rive/generated/core_registry.hpp> +#include <rive/generated/shapes/paint/color_channels_base.hpp> +#include <rive/node.hpp> +#include <rive/shapes/paint/gradient_stop.hpp> +#include <rive/shapes/paint/solid_color.hpp> +#include <utils/serializing_factory.hpp> +#include <rive_file_reader.hpp> +#include <catch.hpp> +#include <rive/animation/state_machine_instance.hpp> + +// The per-channel color properties (colorRed/Green/Blue/Alpha) are shared by +// SolidColor and GradientStop via the ColorChannels mixin. They are passthrough +// views into the packed ARGB colorValue: they store nothing of their own and +// both components share the same property keys. + +using namespace rive; + +TEST_CASE("color channels read the right byte of colorValue", "[color]") +{ + SolidColor solid; + solid.colorValue((int)0xAABBCCDD); + CHECK(solid.colorAlpha() == 0xAAu); + CHECK(solid.colorRed() == 0xBBu); + CHECK(solid.colorGreen() == 0xCCu); + CHECK(solid.colorBlue() == 0xDDu); + + GradientStop stop; + stop.colorValue((int)0x11223344); + CHECK(stop.colorAlpha() == 0x11u); + CHECK(stop.colorRed() == 0x22u); + CHECK(stop.colorGreen() == 0x33u); + CHECK(stop.colorBlue() == 0x44u); +} + +TEST_CASE("setting a channel writes only its byte and recomposes colorValue", + "[color]") +{ + SolidColor solid; + solid.colorValue((int)0xAABBCCDD); + + solid.colorGreen(0x11u); + CHECK((uint32_t)solid.colorValue() == 0xAABB11DDu); + // Other channels untouched. + CHECK(solid.colorAlpha() == 0xAAu); + CHECK(solid.colorRed() == 0xBBu); + CHECK(solid.colorBlue() == 0xDDu); + + solid.colorAlpha(0x00u); + CHECK((uint32_t)solid.colorValue() == 0x00BB11DDu); +} + +TEST_CASE("channels clamp to 255 instead of wrapping", "[color]") +{ + SolidColor solid; + solid.colorValue((int)0x00000000); + + // A value wider than the 8-bit field saturates at 255 rather than + // overflowing into the neighbouring byte (300 & 0xFF would wrap to 44). + solid.colorRed(300u); + CHECK(solid.colorRed() == 0xFFu); + CHECK((uint32_t)solid.colorValue() == 0x00FF0000u); + + // Same guarantee through the registry (data binding / animation path). + CoreRegistry::setUint(&solid, + ColorChannelsBase::colorAlphaPropertyKey, + 1000u); + CHECK(CoreRegistry::getUint(&solid, + ColorChannelsBase::colorAlphaPropertyKey) == + 0xFFu); + CHECK((uint32_t)solid.colorValue() == 0xFFFF0000u); +} + +TEST_CASE("ColorChannelsBase::from resolves consumers, null otherwise", + "[color]") +{ + SolidColor solid; + GradientStop stop; + Node node; + + CHECK(ColorChannelsBase::from(&solid) != nullptr); + CHECK(ColorChannelsBase::from(&stop) != nullptr); + // A Core object that does not include the mixin resolves to null. + CHECK(ColorChannelsBase::from(&node) == nullptr); + + // The resolved interface reads/writes the host mask. + solid.colorValue((int)0xFF000000); + ColorChannelsBase::from(&solid)->colorRed(0x80u); + CHECK((uint32_t)solid.colorValue() == 0xFF800000u); +} + +TEST_CASE("shared channel keys dispatch through CoreRegistry for both types", + "[color]") +{ + SolidColor solid; + GradientStop stop; + + // The SAME property keys route to both concrete types. + CoreRegistry::setUint(&solid, + ColorChannelsBase::colorRedPropertyKey, + 0x34u); + CoreRegistry::setUint(&stop, ColorChannelsBase::colorRedPropertyKey, 0x34u); + CHECK( + CoreRegistry::getUint(&solid, ColorChannelsBase::colorRedPropertyKey) == + 0x34u); + CHECK( + CoreRegistry::getUint(&stop, ColorChannelsBase::colorRedPropertyKey) == + 0x34u); + CHECK(solid.colorRed() == 0x34u); + CHECK(stop.colorRed() == 0x34u); + + // A channel write is a masked read-modify-write of colorValue. + solid.colorValue((int)0x00000000); + CoreRegistry::setUint(&solid, + ColorChannelsBase::colorAlphaPropertyKey, + 0xCDu); + CHECK((uint32_t)solid.colorValue() == 0xCD000000u); + CHECK(CoreRegistry::getUint(&solid, + ColorChannelsBase::colorAlphaPropertyKey) == + 0xCDu); +} + +TEST_CASE("objectSupportsProperty is true for channels on consumers only", + "[color]") +{ + SolidColor solid; + GradientStop stop; + Node node; + + CHECK(CoreRegistry::objectSupportsProperty( + &solid, + ColorChannelsBase::colorRedPropertyKey)); + CHECK(CoreRegistry::objectSupportsProperty( + &stop, + ColorChannelsBase::colorAlphaPropertyKey)); + CHECK_FALSE(CoreRegistry::objectSupportsProperty( + &node, + ColorChannelsBase::colorRedPropertyKey)); +} + +TEST_CASE("channel keys report a uint field type for data binding", "[color]") +{ + // propertyFieldId must resolve the channels (they are uint-valued) so data + // binding can build the right target value instead of crashing on -1. + CHECK(CoreRegistry::propertyFieldId( + ColorChannelsBase::colorRedPropertyKey) == +CoreUintType::id); + CHECK(CoreRegistry::propertyFieldId( + ColorChannelsBase::colorAlphaPropertyKey) == +CoreUintType::id); +} + +TEST_CASE("Silver test of passthrough properties", "[silver]") +{ + SerializingFactory silver; + auto file = ReadRiveFile("assets/color_passthrough_test.riv", &silver); + auto artboard = file->artboardDefault(); + REQUIRE(artboard != nullptr); + + silver.frameSize(artboard->width(), artboard->height()); + + auto stateMachine = artboard->stateMachineAt(0); + + auto vmi = file->createDefaultViewModelInstance(artboard.get()); + + stateMachine->bindViewModelInstance(vmi); + stateMachine->advanceAndApply(0.1f); + auto renderer = silver.makeRenderer(); + artboard->draw(renderer.get()); + + int frames = (int)(3.0f / 0.25f); + for (int i = 0; i < frames; i++) + { + silver.addFrame(); + stateMachine->advanceAndApply(0.25f); + artboard->draw(renderer.get()); + } + + CHECK(silver.matches("color_passthrough_test")); +}
diff --git a/tests/unit_tests/silvers/color_passthrough_test.sriv b/tests/unit_tests/silvers/color_passthrough_test.sriv new file mode 100644 index 0000000..b5e11e5 --- /dev/null +++ b/tests/unit_tests/silvers/color_passthrough_test.sriv Binary files differ