From 697edc905511a854b353f5f4a63e9bf5d749e8d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:28:45 +0000 Subject: [PATCH 1/4] Keep formula values of variables of any type A computed variable holds a ${= ...} formula whatever its declared type; sanitizing turned a list or map variable's formula into an empty value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UErtKMSFkZWHUh8DrQMcZW --- lib/src/api/models/variables_model.dart | 8 ++++++-- test/computed_variable_value_test.dart | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 test/computed_variable_value_test.dart diff --git a/lib/src/api/models/variables_model.dart b/lib/src/api/models/variables_model.dart index eb860565..7e703dcd 100644 --- a/lib/src/api/models/variables_model.dart +++ b/lib/src/api/models/variables_model.dart @@ -228,8 +228,12 @@ class VariableData String? sanitizeValueForVariableType(Object? value, VariableType type) { if (value == null) return null; // if (value.isEmpty) return ''; - // Do not sanitize if the value contains a variable path. - if (value.toString().contains(variablePathPattern)) return value.toString(); + // Do not sanitize if the value contains a variable path or a formula: a + // computed variable's value is a `${= ...}` formula whatever its type. + if (value.toString().contains(variablePathPattern) || + value.toString().contains(r'${=')) { + return value.toString(); + } switch (type) { case VariableType.text: case VariableType.image: diff --git a/test/computed_variable_value_test.dart b/test/computed_variable_value_test.dart new file mode 100644 index 00000000..bb4bd10d --- /dev/null +++ b/test/computed_variable_value_test.dart @@ -0,0 +1,15 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a formula value survives sanitizing for every type', () { + const String formula = r"${= find(trips, 'id', selected)}"; + for (final VariableType type in VariableType.values) { + expect( + VariableData(id: 'v', name: 'v', type: type, value: formula).value, + formula, + reason: type.name, + ); + } + }); +} From 218f346767e327472bb8a4cab205c21cb1e01cf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:34:35 +0000 Subject: [PATCH 2/4] Add spacing to row/column nodes RowColumnMixin.spacing (default 0) is the gap between consecutive children along the main axis, like Flutter's Row/Column spacing and Figma auto-layout item spacing. RowColumnNode takes it in its constructor and exposes it as a layout field. JSON omits it when 0 and JSON without it loads as 0 for every RowColumnMixin node. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UErtKMSFkZWHUh8DrQMcZW --- lib/src/api/mixins.dart | 12 ++++ lib/src/api/nodes/accordion_node.g.dart | 3 + .../api/nodes/auto_placeholder_node.g.dart | 3 + lib/src/api/nodes/canvas_node.g.dart | 3 + lib/src/api/nodes/expansion_tile_node.g.dart | 3 + lib/src/api/nodes/row_column_node.dart | 17 +++++ lib/src/api/nodes/row_column_node.g.dart | 3 + test/row_column_spacing_test.dart | 62 +++++++++++++++++++ 8 files changed, 106 insertions(+) create mode 100644 test/row_column_spacing_test.dart diff --git a/lib/src/api/mixins.dart b/lib/src/api/mixins.dart index b182ef92..ecd40515 100644 --- a/lib/src/api/mixins.dart +++ b/lib/src/api/mixins.dart @@ -1109,6 +1109,16 @@ mixin RowColumnMixin on BaseNode { /// for details on available alignments. late CrossAxisAlignmentC crossAxisAlignment; + /// The gap between consecutive children along the main axis, in logical + /// pixels. Corresponds to the `spacing` property of [Row] and [Column] in + /// Flutter and to item spacing in Figma auto-layout. + /// + /// Unlike margins, the spacing is subtracted from the main axis extent + /// before free space is distributed to flexible children, so expanded + /// children with equal flex receive equal sizes. + @JsonKey(defaultValue: 0.0) + double spacing = 0; + /// Returns the main axis based on whether this is a row or column. AxisC get mainAxis => (rowColumnType == RowColumnType.row) ? AxisC.horizontal : AxisC.vertical; @@ -1122,10 +1132,12 @@ mixin RowColumnMixin on BaseNode { required RowColumnType rowColumnType, required MainAxisAlignmentC mainAxisAlignment, required CrossAxisAlignmentC crossAxisAlignment, + double spacing = 0, }) { this.rowColumnType = rowColumnType; this.mainAxisAlignment = mainAxisAlignment; this.crossAxisAlignment = crossAxisAlignment; + this.spacing = spacing; } @override diff --git a/lib/src/api/nodes/accordion_node.g.dart b/lib/src/api/nodes/accordion_node.g.dart index 77f4f620..4f2e8e74 100644 --- a/lib/src/api/nodes/accordion_node.g.dart +++ b/lib/src/api/nodes/accordion_node.g.dart @@ -52,6 +52,7 @@ Map _$AccordionNodeOmitDefaults( json.remove('mainAxisAlignment'); if (isJsonDefault(instance.crossAxisAlignment, CrossAxisAlignmentC.center)) json.remove('crossAxisAlignment'); + if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); return json; } @@ -179,6 +180,7 @@ AccordionNode _$AccordionNodeFromJson(Map json) => {} ..tooltip = json['tooltip'] as String? ?? '' ..zoomable = json['zoomable'] as bool? ?? false + ..spacing = (json['spacing'] as num?)?.toDouble() ?? 0.0 ..type = json['type'] as String; Map _$AccordionNodeToJson( @@ -219,6 +221,7 @@ Map _$AccordionNodeToJson( 'mainAxisAlignment': _$MainAxisAlignmentCEnumMap[instance.mainAxisAlignment]!, 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, + 'spacing': instance.spacing, 'type': instance.type, 'properties': instance.properties.toJson(), }; diff --git a/lib/src/api/nodes/auto_placeholder_node.g.dart b/lib/src/api/nodes/auto_placeholder_node.g.dart index 29e36b8f..223ded76 100644 --- a/lib/src/api/nodes/auto_placeholder_node.g.dart +++ b/lib/src/api/nodes/auto_placeholder_node.g.dart @@ -70,6 +70,7 @@ Map _$AutoPlaceholderNodeOmitDefaults( json.remove('mainAxisAlignment'); if (isJsonDefault(instance.crossAxisAlignment, CrossAxisAlignmentC.center)) json.remove('crossAxisAlignment'); + if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); return json; } @@ -236,6 +237,7 @@ AutoPlaceholderNode _$AutoPlaceholderNodeFromJson(Map json) => : InkWellModel.fromJson( Map.from(json['inkWell'] as Map), ) + ..spacing = (json['spacing'] as num?)?.toDouble() ?? 0.0 ..type = json['type'] as String; Map _$AutoPlaceholderNodeToJson( @@ -289,6 +291,7 @@ Map _$AutoPlaceholderNodeToJson( 'mainAxisAlignment': _$MainAxisAlignmentCEnumMap[instance.mainAxisAlignment]!, 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, + 'spacing': instance.spacing, 'type': instance.type, 'ephemeral': instance.ephemeral, }; diff --git a/lib/src/api/nodes/canvas_node.g.dart b/lib/src/api/nodes/canvas_node.g.dart index 70aeb784..f0ed0f4f 100644 --- a/lib/src/api/nodes/canvas_node.g.dart +++ b/lib/src/api/nodes/canvas_node.g.dart @@ -71,6 +71,7 @@ Map _$CanvasNodeOmitDefaults( json.remove('mainAxisAlignment'); if (isJsonDefault(instance.crossAxisAlignment, CrossAxisAlignmentC.center)) json.remove('crossAxisAlignment'); + if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); if (isJsonDefault(instance.isScrollable, false)) json.remove('isScrollable'); if (isJsonDefault(instance.scrollDirection, AxisC.vertical)) json.remove('scrollDirection'); @@ -290,6 +291,7 @@ CanvasNode _$CanvasNodeFromJson(Map json) => : InkWellModel.fromJson( Map.from(json['inkWell'] as Map), ) + ..spacing = (json['spacing'] as num?)?.toDouble() ?? 0.0 ..type = json['type'] as String; Map _$CanvasNodeToJson( @@ -344,6 +346,7 @@ Map _$CanvasNodeToJson( 'mainAxisAlignment': _$MainAxisAlignmentCEnumMap[instance.mainAxisAlignment]!, 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, + 'spacing': instance.spacing, 'isScrollable': instance.isScrollable, 'scrollDirection': _$AxisCEnumMap[instance.scrollDirection]!, 'reverse': instance.reverse, diff --git a/lib/src/api/nodes/expansion_tile_node.g.dart b/lib/src/api/nodes/expansion_tile_node.g.dart index 50dde0fe..cd8906cc 100644 --- a/lib/src/api/nodes/expansion_tile_node.g.dart +++ b/lib/src/api/nodes/expansion_tile_node.g.dart @@ -46,6 +46,7 @@ Map _$ExpansionTileNodeOmitDefaults( json.remove('componentSchema'); if (isJsonDefault(instance.reactions, const [])) json.remove('reactions'); if (isJsonDefault(instance.children, [])) json.remove('children'); + if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); return json; } @@ -205,6 +206,7 @@ ExpansionTileNode _$ExpansionTileNodeFromJson(Map json) => _$CrossAxisAlignmentCEnumMap, json['crossAxisAlignment'], ) + ..spacing = (json['spacing'] as num?)?.toDouble() ?? 0.0 ..type = json['type'] as String; Map _$ExpansionTileNodeToJson( @@ -245,6 +247,7 @@ Map _$ExpansionTileNodeToJson( 'mainAxisAlignment': _$MainAxisAlignmentCEnumMap[instance.mainAxisAlignment]!, 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, + 'spacing': instance.spacing, 'type': instance.type, 'isExpanded': instance.isExpanded, 'listTileChild': ?instance.listTileChild, diff --git a/lib/src/api/nodes/row_column_node.dart b/lib/src/api/nodes/row_column_node.dart index 2094d2f7..f88bd510 100644 --- a/lib/src/api/nodes/row_column_node.dart +++ b/lib/src/api/nodes/row_column_node.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; +import '../field_access.dart'; import '../mixins.dart'; import '../models/models.dart'; @@ -34,6 +35,7 @@ class RowColumnNode extends ParentNode required RowColumnType rowColumnType, MainAxisAlignmentC mainAxisAlignment = MainAxisAlignmentC.center, CrossAxisAlignmentC crossAxisAlignment = CrossAxisAlignmentC.center, + double spacing = 0, super.retainedOuterBoxLocal, super.visible, super.enabled, @@ -86,6 +88,7 @@ class RowColumnNode extends ParentNode rowColumnType: rowColumnType, mainAxisAlignment: mainAxisAlignment, crossAxisAlignment: crossAxisAlignment, + spacing: spacing, ); setScrollableMixin( @@ -101,6 +104,20 @@ class RowColumnNode extends ParentNode ); } + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'spacing': NumFieldAccess( + 'Spacing', + 'Gap between consecutive children along the main axis.', + () => spacing, + (value) => spacing = value, + defaultValue: () => 0, + min: 0, + requiresLayout: true, + ), + }; + /// Creates a [RowColumnNode] from a JSON data. factory RowColumnNode.fromJson(Map json) => _$RowColumnNodeFromJson(json); diff --git a/lib/src/api/nodes/row_column_node.g.dart b/lib/src/api/nodes/row_column_node.g.dart index 4385e68c..2debfa64 100644 --- a/lib/src/api/nodes/row_column_node.g.dart +++ b/lib/src/api/nodes/row_column_node.g.dart @@ -68,6 +68,7 @@ Map _$RowColumnNodeOmitDefaults( json.remove('mainAxisAlignment'); if (isJsonDefault(instance.crossAxisAlignment, CrossAxisAlignmentC.center)) json.remove('crossAxisAlignment'); + if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); if (isJsonDefault(instance.isScrollable, false)) json.remove('isScrollable'); if (isJsonDefault(instance.reverse, false)) json.remove('reverse'); if (isJsonDefault(instance.primary, true)) json.remove('primary'); @@ -119,6 +120,7 @@ RowColumnNode _$RowColumnNodeFromJson(Map json) => json['crossAxisAlignment'], ) ?? CrossAxisAlignmentC.center, + spacing: (json['spacing'] as num?)?.toDouble() ?? 0.0, visible: json['visible'] as bool? ?? true, enabled: json['enabled'] as bool? ?? true, rotationDegrees: json['rotation'] == null @@ -328,6 +330,7 @@ Map _$RowColumnNodeToJson( 'mainAxisAlignment': _$MainAxisAlignmentCEnumMap[instance.mainAxisAlignment]!, 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, + 'spacing': instance.spacing, 'isScrollable': instance.isScrollable, 'reverse': instance.reverse, 'primary': instance.primary, diff --git a/test/row_column_spacing_test.dart b/test/row_column_spacing_test.dart new file mode 100644 index 00000000..1f7323d8 --- /dev/null +++ b/test/row_column_spacing_test.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +RowColumnNode _row({double spacing = 0}) => RowColumnNode( + id: 'row', + name: 'Row', + basicBoxLocal: NodeBox(0, 0, 1072, 100), + children: const ['a', 'b', 'c'], + rowColumnType: RowColumnType.row, + spacing: spacing, +); + +void main() { + group('RowColumnNode.spacing', () { + test('defaults to 0 and is omitted from JSON', () { + final node = _row(); + expect(node.spacing, 0); + expect(node.toJson().containsKey('spacing'), isFalse); + }); + + test('round-trips a non-zero spacing', () { + final json = jsonDecode(jsonEncode(_row(spacing: 20).toJson())) as Map; + expect(json['spacing'], 20); + final decoded = RowColumnNode.fromJson(json); + expect(decoded.spacing, 20); + expect(decoded.toJson()['spacing'], 20); + }); + + test('loads JSON written without spacing', () { + final json = _row().toJson()..remove('spacing'); + expect(RowColumnNode.fromJson(json).spacing, 0); + }); + + test('accepts integer spacing in JSON', () { + final json = _row().toJson()..['spacing'] = 12; + expect(RowColumnNode.fromJson(json).spacing, 12.0); + }); + + test('is exposed as a layout field', () { + final node = _row(spacing: 8); + final field = node.fields['spacing']!; + expect(field.getValue(), 8); + expect(field.requiresLayout, isTrue); + field.setValue(16.0); + expect(node.spacing, 16); + }); + + test('other RowColumnMixin nodes load old JSON without spacing', () { + final placeholder = AutoPlaceholderNode( + id: 'placeholder', + name: 'Placeholder', + basicBoxLocal: NodeBox(0, 0, 100, 100), + children: const [], + ); + final json = placeholder.toJson(); + expect(json.containsKey('spacing'), isFalse); + expect(AutoPlaceholderNode.fromJson(json).spacing, 0); + }); + }); +} From 59fe00d01930d5f40a946993665cea1b15c7d7ad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:55:07 +0000 Subject: [PATCH 3/4] Add wrap and runSpacing to row/column nodes RowColumnMixin.wrap (default false) makes a row/column lay its children out like Flutter's Wrap: a row wraps horizontally, a column vertically, with spacing between children of a run and RowColumnMixin.runSpacing (default 0) between runs. The doc comment sets the behavior: the main-axis alignment aligns each run, the cross-axis alignment aligns children in their run and the runs in the free space (stretch/baseline act as start), flex children keep their design size, formula-hidden children take no space or gap, and a hugging cross axis is as thick as the runs. RowColumnNode takes both in its constructor and exposes them as layout fields; JSON omits them at their defaults and old JSON loads them as defaults for every RowColumnMixin node. A wrapping RowColumnNode scrolls along its cross axis (through its runs), as its main axis is where it wraps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UErtKMSFkZWHUh8DrQMcZW --- lib/src/api/mixins.dart | 34 ++++++++ lib/src/api/nodes/accordion_node.g.dart | 6 ++ .../api/nodes/auto_placeholder_node.g.dart | 6 ++ lib/src/api/nodes/canvas_node.g.dart | 6 ++ lib/src/api/nodes/expansion_tile_node.g.dart | 6 ++ lib/src/api/nodes/row_column_node.dart | 29 ++++++- lib/src/api/nodes/row_column_node.g.dart | 6 ++ test/row_column_wrap_test.dart | 79 +++++++++++++++++++ 8 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 test/row_column_wrap_test.dart diff --git a/lib/src/api/mixins.dart b/lib/src/api/mixins.dart index ecd40515..7c00015e 100644 --- a/lib/src/api/mixins.dart +++ b/lib/src/api/mixins.dart @@ -1119,6 +1119,36 @@ mixin RowColumnMixin on BaseNode { @JsonKey(defaultValue: 0.0) double spacing = 0; + /// Whether the children wrap onto further runs (lines) when they do not + /// fit along the main axis, like Flutter's [Wrap] and Figma auto-layout + /// wrap. A wrapping row lays its children out like + /// `Wrap(direction: Axis.horizontal)` and a wrapping column like + /// `Wrap(direction: Axis.vertical)`. + /// + /// While wrapping: + /// - [spacing] is the gap between consecutive children within a run and + /// [runSpacing] the gap between runs. Children hidden by a visibility + /// formula take no space and no gaps. + /// - [mainAxisAlignment] aligns the children within each run (Flutter's + /// `Wrap.alignment`). + /// - [crossAxisAlignment] aligns the children within their run + /// (`Wrap.crossAxisAlignment`; stretch and baseline act as start) and + /// also aligns the runs in the cross-axis free space + /// (`Wrap.runAlignment`: start, center or end), as in Figma. + /// - Children cannot be flexible: an expanded (or flex) child is laid out + /// at its own size, as a fixed child, like inside a Flutter [Wrap]. + /// A child that fills the main axis takes a whole run. + /// - A shrink-wrapping cross axis hugs the runs; a shrink-wrapping main + /// axis hugs the widest run. + @JsonKey(defaultValue: false) + bool wrap = false; + + /// The gap between consecutive runs along the cross axis, in logical + /// pixels, when [wrap] is on. Corresponds to `Wrap.runSpacing` in + /// Flutter. Has no effect when [wrap] is off. + @JsonKey(defaultValue: 0.0) + double runSpacing = 0; + /// Returns the main axis based on whether this is a row or column. AxisC get mainAxis => (rowColumnType == RowColumnType.row) ? AxisC.horizontal : AxisC.vertical; @@ -1133,11 +1163,15 @@ mixin RowColumnMixin on BaseNode { required MainAxisAlignmentC mainAxisAlignment, required CrossAxisAlignmentC crossAxisAlignment, double spacing = 0, + bool wrap = false, + double runSpacing = 0, }) { this.rowColumnType = rowColumnType; this.mainAxisAlignment = mainAxisAlignment; this.crossAxisAlignment = crossAxisAlignment; this.spacing = spacing; + this.wrap = wrap; + this.runSpacing = runSpacing; } @override diff --git a/lib/src/api/nodes/accordion_node.g.dart b/lib/src/api/nodes/accordion_node.g.dart index 4f2e8e74..f2bc76e5 100644 --- a/lib/src/api/nodes/accordion_node.g.dart +++ b/lib/src/api/nodes/accordion_node.g.dart @@ -53,6 +53,8 @@ Map _$AccordionNodeOmitDefaults( if (isJsonDefault(instance.crossAxisAlignment, CrossAxisAlignmentC.center)) json.remove('crossAxisAlignment'); if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); + if (isJsonDefault(instance.wrap, false)) json.remove('wrap'); + if (isJsonDefault(instance.runSpacing, 0.0)) json.remove('runSpacing'); return json; } @@ -181,6 +183,8 @@ AccordionNode _$AccordionNodeFromJson(Map json) => ..tooltip = json['tooltip'] as String? ?? '' ..zoomable = json['zoomable'] as bool? ?? false ..spacing = (json['spacing'] as num?)?.toDouble() ?? 0.0 + ..wrap = json['wrap'] as bool? ?? false + ..runSpacing = (json['runSpacing'] as num?)?.toDouble() ?? 0.0 ..type = json['type'] as String; Map _$AccordionNodeToJson( @@ -222,6 +226,8 @@ Map _$AccordionNodeToJson( 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, 'spacing': instance.spacing, + 'wrap': instance.wrap, + 'runSpacing': instance.runSpacing, 'type': instance.type, 'properties': instance.properties.toJson(), }; diff --git a/lib/src/api/nodes/auto_placeholder_node.g.dart b/lib/src/api/nodes/auto_placeholder_node.g.dart index 223ded76..68fbcbd2 100644 --- a/lib/src/api/nodes/auto_placeholder_node.g.dart +++ b/lib/src/api/nodes/auto_placeholder_node.g.dart @@ -71,6 +71,8 @@ Map _$AutoPlaceholderNodeOmitDefaults( if (isJsonDefault(instance.crossAxisAlignment, CrossAxisAlignmentC.center)) json.remove('crossAxisAlignment'); if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); + if (isJsonDefault(instance.wrap, false)) json.remove('wrap'); + if (isJsonDefault(instance.runSpacing, 0.0)) json.remove('runSpacing'); return json; } @@ -238,6 +240,8 @@ AutoPlaceholderNode _$AutoPlaceholderNodeFromJson(Map json) => Map.from(json['inkWell'] as Map), ) ..spacing = (json['spacing'] as num?)?.toDouble() ?? 0.0 + ..wrap = json['wrap'] as bool? ?? false + ..runSpacing = (json['runSpacing'] as num?)?.toDouble() ?? 0.0 ..type = json['type'] as String; Map _$AutoPlaceholderNodeToJson( @@ -292,6 +296,8 @@ Map _$AutoPlaceholderNodeToJson( 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, 'spacing': instance.spacing, + 'wrap': instance.wrap, + 'runSpacing': instance.runSpacing, 'type': instance.type, 'ephemeral': instance.ephemeral, }; diff --git a/lib/src/api/nodes/canvas_node.g.dart b/lib/src/api/nodes/canvas_node.g.dart index f0ed0f4f..5f4c9a09 100644 --- a/lib/src/api/nodes/canvas_node.g.dart +++ b/lib/src/api/nodes/canvas_node.g.dart @@ -72,6 +72,8 @@ Map _$CanvasNodeOmitDefaults( if (isJsonDefault(instance.crossAxisAlignment, CrossAxisAlignmentC.center)) json.remove('crossAxisAlignment'); if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); + if (isJsonDefault(instance.wrap, false)) json.remove('wrap'); + if (isJsonDefault(instance.runSpacing, 0.0)) json.remove('runSpacing'); if (isJsonDefault(instance.isScrollable, false)) json.remove('isScrollable'); if (isJsonDefault(instance.scrollDirection, AxisC.vertical)) json.remove('scrollDirection'); @@ -292,6 +294,8 @@ CanvasNode _$CanvasNodeFromJson(Map json) => Map.from(json['inkWell'] as Map), ) ..spacing = (json['spacing'] as num?)?.toDouble() ?? 0.0 + ..wrap = json['wrap'] as bool? ?? false + ..runSpacing = (json['runSpacing'] as num?)?.toDouble() ?? 0.0 ..type = json['type'] as String; Map _$CanvasNodeToJson( @@ -347,6 +351,8 @@ Map _$CanvasNodeToJson( 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, 'spacing': instance.spacing, + 'wrap': instance.wrap, + 'runSpacing': instance.runSpacing, 'isScrollable': instance.isScrollable, 'scrollDirection': _$AxisCEnumMap[instance.scrollDirection]!, 'reverse': instance.reverse, diff --git a/lib/src/api/nodes/expansion_tile_node.g.dart b/lib/src/api/nodes/expansion_tile_node.g.dart index cd8906cc..e2a4ca38 100644 --- a/lib/src/api/nodes/expansion_tile_node.g.dart +++ b/lib/src/api/nodes/expansion_tile_node.g.dart @@ -47,6 +47,8 @@ Map _$ExpansionTileNodeOmitDefaults( if (isJsonDefault(instance.reactions, const [])) json.remove('reactions'); if (isJsonDefault(instance.children, [])) json.remove('children'); if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); + if (isJsonDefault(instance.wrap, false)) json.remove('wrap'); + if (isJsonDefault(instance.runSpacing, 0.0)) json.remove('runSpacing'); return json; } @@ -207,6 +209,8 @@ ExpansionTileNode _$ExpansionTileNodeFromJson(Map json) => json['crossAxisAlignment'], ) ..spacing = (json['spacing'] as num?)?.toDouble() ?? 0.0 + ..wrap = json['wrap'] as bool? ?? false + ..runSpacing = (json['runSpacing'] as num?)?.toDouble() ?? 0.0 ..type = json['type'] as String; Map _$ExpansionTileNodeToJson( @@ -248,6 +252,8 @@ Map _$ExpansionTileNodeToJson( 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, 'spacing': instance.spacing, + 'wrap': instance.wrap, + 'runSpacing': instance.runSpacing, 'type': instance.type, 'isExpanded': instance.isExpanded, 'listTileChild': ?instance.listTileChild, diff --git a/lib/src/api/nodes/row_column_node.dart b/lib/src/api/nodes/row_column_node.dart index f88bd510..c7d527df 100644 --- a/lib/src/api/nodes/row_column_node.dart +++ b/lib/src/api/nodes/row_column_node.dart @@ -17,10 +17,14 @@ class RowColumnNode extends ParentNode @override final bool supportsPadding = true; + /// A row/column scrolls along its main axis. One that [wrap]s scrolls + /// along its cross axis instead, through its runs: its main axis is where + /// it wraps, so it needs a bounded extent there. @JsonKey(includeFromJson: false) @override - AxisC get scrollDirection => - rowColumnType == RowColumnType.row ? AxisC.horizontal : AxisC.vertical; + AxisC get scrollDirection => (rowColumnType == RowColumnType.row) != wrap + ? AxisC.horizontal + : AxisC.vertical; @override set scrollDirection(AxisC value) {} @@ -36,6 +40,8 @@ class RowColumnNode extends ParentNode MainAxisAlignmentC mainAxisAlignment = MainAxisAlignmentC.center, CrossAxisAlignmentC crossAxisAlignment = CrossAxisAlignmentC.center, double spacing = 0, + bool wrap = false, + double runSpacing = 0, super.retainedOuterBoxLocal, super.visible, super.enabled, @@ -89,6 +95,8 @@ class RowColumnNode extends ParentNode mainAxisAlignment: mainAxisAlignment, crossAxisAlignment: crossAxisAlignment, spacing: spacing, + wrap: wrap, + runSpacing: runSpacing, ); setScrollableMixin( @@ -116,6 +124,23 @@ class RowColumnNode extends ParentNode min: 0, requiresLayout: true, ), + 'wrap': BoolFieldAccess( + 'Wrap', + 'Whether children wrap onto further runs when they do not fit.', + () => wrap, + (value) => wrap = value, + defaultValue: () => false, + requiresLayout: true, + ), + 'runSpacing': NumFieldAccess( + 'Run Spacing', + 'Gap between runs along the cross axis when wrapping.', + () => runSpacing, + (value) => runSpacing = value, + defaultValue: () => 0, + min: 0, + requiresLayout: true, + ), }; /// Creates a [RowColumnNode] from a JSON data. diff --git a/lib/src/api/nodes/row_column_node.g.dart b/lib/src/api/nodes/row_column_node.g.dart index 2debfa64..ccfdfcd2 100644 --- a/lib/src/api/nodes/row_column_node.g.dart +++ b/lib/src/api/nodes/row_column_node.g.dart @@ -69,6 +69,8 @@ Map _$RowColumnNodeOmitDefaults( if (isJsonDefault(instance.crossAxisAlignment, CrossAxisAlignmentC.center)) json.remove('crossAxisAlignment'); if (isJsonDefault(instance.spacing, 0.0)) json.remove('spacing'); + if (isJsonDefault(instance.wrap, false)) json.remove('wrap'); + if (isJsonDefault(instance.runSpacing, 0.0)) json.remove('runSpacing'); if (isJsonDefault(instance.isScrollable, false)) json.remove('isScrollable'); if (isJsonDefault(instance.reverse, false)) json.remove('reverse'); if (isJsonDefault(instance.primary, true)) json.remove('primary'); @@ -121,6 +123,8 @@ RowColumnNode _$RowColumnNodeFromJson(Map json) => ) ?? CrossAxisAlignmentC.center, spacing: (json['spacing'] as num?)?.toDouble() ?? 0.0, + wrap: json['wrap'] as bool? ?? false, + runSpacing: (json['runSpacing'] as num?)?.toDouble() ?? 0.0, visible: json['visible'] as bool? ?? true, enabled: json['enabled'] as bool? ?? true, rotationDegrees: json['rotation'] == null @@ -331,6 +335,8 @@ Map _$RowColumnNodeToJson( 'crossAxisAlignment': _$CrossAxisAlignmentCEnumMap[instance.crossAxisAlignment]!, 'spacing': instance.spacing, + 'wrap': instance.wrap, + 'runSpacing': instance.runSpacing, 'isScrollable': instance.isScrollable, 'reverse': instance.reverse, 'primary': instance.primary, diff --git a/test/row_column_wrap_test.dart b/test/row_column_wrap_test.dart new file mode 100644 index 00000000..ded54921 --- /dev/null +++ b/test/row_column_wrap_test.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +RowColumnNode _row({bool wrap = false, double runSpacing = 0}) => + RowColumnNode( + id: 'row', + name: 'Row', + basicBoxLocal: NodeBox(0, 0, 330, 100), + children: const ['a', 'b', 'c'], + rowColumnType: RowColumnType.row, + spacing: 8, + wrap: wrap, + runSpacing: runSpacing, + ); + +void main() { + group('RowColumnNode wrap', () { + test('defaults to no wrap and is omitted from JSON', () { + final node = _row(); + expect(node.wrap, isFalse); + expect(node.runSpacing, 0); + final json = node.toJson(); + expect(json.containsKey('wrap'), isFalse); + expect(json.containsKey('runSpacing'), isFalse); + }); + + test('round-trips wrap and runSpacing', () { + final json = + jsonDecode(jsonEncode(_row(wrap: true, runSpacing: 12).toJson())) + as Map; + expect(json['wrap'], isTrue); + expect(json['runSpacing'], 12); + final decoded = RowColumnNode.fromJson(json); + expect(decoded.wrap, isTrue); + expect(decoded.runSpacing, 12); + expect(decoded.spacing, 8); + }); + + test('loads JSON written without wrap; accepts integer runSpacing', () { + final json = _row().toJson()..['runSpacing'] = 4; + final decoded = RowColumnNode.fromJson(json); + expect(decoded.wrap, isFalse); + expect(decoded.runSpacing, 4.0); + }); + + test('is exposed as layout fields', () { + final node = _row(); + expect(node.fields['wrap']!.requiresLayout, isTrue); + expect(node.fields['runSpacing']!.requiresLayout, isTrue); + node.fields['wrap']!.setValue(true); + node.fields['runSpacing']!.setValue(6.0); + expect(node.wrap, isTrue); + expect(node.runSpacing, 6); + }); + + test('a wrapping row/column scrolls along its cross axis', () { + expect(_row().scrollDirection, AxisC.horizontal); + expect(_row(wrap: true).scrollDirection, AxisC.vertical); + final column = _row(wrap: true)..rowColumnType = RowColumnType.column; + expect(column.scrollDirection, AxisC.horizontal); + }); + + test('other RowColumnMixin nodes load old JSON without wrap', () { + final placeholder = AutoPlaceholderNode( + id: 'placeholder', + name: 'Placeholder', + basicBoxLocal: NodeBox(0, 0, 100, 100), + children: const [], + ); + final json = placeholder.toJson(); + expect(json.containsKey('wrap'), isFalse); + final decoded = AutoPlaceholderNode.fromJson(json); + expect(decoded.wrap, isFalse); + expect(decoded.runSpacing, 0); + }); + }); +} From 964dbd18080f71bc3db0db995944b6c6f06e435c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:28:12 +0000 Subject: [PATCH 4/4] Changelog: unreleased changes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UErtKMSFkZWHUh8DrQMcZW --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb1e325..c489c255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## Unreleased + +### Layout + +- Row/column `spacing`: a gap between children along the main axis, taken + out before expanded children share the free space. +- Row/column `wrap` and `runSpacing`: children wrap onto further runs, like + Flutter's `Wrap`. + +### Variables + +- A variable whose value is a `${= ...}` formula keeps it for every type + (computed variables). + ## 6.1.0 ### States and conditions