From 5fd10ff12000699151baea619ff7743c2da89328 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:54:25 +0000 Subject: [PATCH 01/12] Motion, timer/key/swipe triggers, stacked lists, variable shadowing fix - NodeMotion draws a node's motion (offset, scale, opacity, rotation, 3D tilt, transitions and spin) with bound values resolved per build; the design canvas shows it at rest. - Canvases run timer reactions every interval and key press reactions for their key; frames and rectangles run swipe reactions (and wheel scroll). - Stacked lists draw their items on top of each other, first on top. - Set variable resolves its variable by id, and lookups by name take the screen's own variable over a shared one of the same name, as bindings do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik --- lib/codelessly_sdk.dart | 2 + lib/src/functions/functions_repository.dart | 5 +- .../passive_canvas_transformer.dart | 85 ++++++- .../passive_list_view_transformer.dart | 53 +++++ .../passive_transformer_manager.dart | 2 + .../widget_node_transformer_manager.dart | 72 ++++++ lib/src/ui/codelessly_context.dart | 11 +- lib/src/ui/node_motion.dart | 206 +++++++++++++++++ lib/src/ui/swipe_detector.dart | 77 +++++++ test/node_motion_test.dart | 214 ++++++++++++++++++ test/stacked_list_test.dart | 52 +++++ test/timer_key_swipe_triggers_test.dart | 147 ++++++++++++ test/variable_shadowing_test.dart | 73 ++++++ 13 files changed, 995 insertions(+), 4 deletions(-) create mode 100644 lib/src/ui/node_motion.dart create mode 100644 lib/src/ui/swipe_detector.dart create mode 100644 test/node_motion_test.dart create mode 100644 test/stacked_list_test.dart create mode 100644 test/timer_key_swipe_triggers_test.dart create mode 100644 test/variable_shadowing_test.dart diff --git a/lib/codelessly_sdk.dart b/lib/codelessly_sdk.dart index ffaef123..857c8ddb 100644 --- a/lib/codelessly_sdk.dart +++ b/lib/codelessly_sdk.dart @@ -44,6 +44,8 @@ export 'src/ui/related_documents.dart'; export 'src/ui/show_more.dart'; export 'src/ui/zoomable_box.dart'; export 'src/ui/skeleton.dart'; +export 'src/ui/node_motion.dart'; +export 'src/ui/swipe_detector.dart'; export 'src/ui/stripe_painter.dart'; export 'src/utils/cupertino_icons_by_codepoint.dart'; export 'src/utils/extensions.dart'; diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 86de7a90..868c1de8 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -1134,8 +1134,9 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. final ScopedValues scopedValues = ActionChain.scopedValuesOf(context); final CodelesslyContext codelesslyContext = context .read(); - final variableNotifier = codelesslyContext.findVariableByName( - action.variable.name, + final variableNotifier = codelesslyContext.findVariable( + id: action.variable.id, + name: action.variable.name, ); if (variableNotifier == null) return false; diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 9fe25753..49a6d3fd 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -671,6 +671,7 @@ class _PassiveCanvasWidgetState extends State { codelessly?.removeNavigationListener('canvas-${widget.node.id}'); _gateUser?.removeListener(_enforceSignIn); _stopWatchingValues(); + _stopTimers(); super.dispose(); } @@ -758,9 +759,76 @@ class _PassiveCanvasWidgetState extends State { super.didUpdateWidget(oldWidget); if (widget.node != oldWidget.node) { onLoadActions = collectOnLoadActions(); + if (_timersStarted) _startTimers(); } } + final List _timers = []; + bool _timersStarted = false; + + /// Runs each timer reaction every [TriggerModel.intervalMs] while the + /// canvas is shown. A tick is skipped while the previous one's actions + /// are still running, so slow actions don't pile up. + void _startTimers() { + _stopTimers(); + _timersStarted = true; + for (final Reaction reaction in widget.node.reactions.whereTriggerType( + TriggerType.timer, + )) { + if (!reaction.action.enabled) continue; + bool busy = false; + _timers.add( + Timer.periodic( + Duration(milliseconds: reaction.trigger.effectiveIntervalMs), + (_) async { + if (!mounted || busy) return; + busy = true; + try { + await FunctionsRepository.triggerAction( + context, + TriggerType.timer, + reactions: [reaction], + ); + } finally { + busy = false; + } + }, + ), + ); + } + } + + void _stopTimers() { + for (final Timer timer in _timers) { + timer.cancel(); + } + _timers.clear(); + } + + /// Runs the key press reactions for [event]'s key. Returns whether one + /// matched. + KeyEventResult _onKey(FocusNode _, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + final String pressed = normalizeKeyLabel(event.logicalKey.keyLabel); + final List matching = [ + for (final Reaction reaction in widget.node.reactions.whereTriggerType( + TriggerType.keyPress, + )) + if (reaction.action.enabled && + normalizeKeyLabel(reaction.trigger.key ?? '') == pressed) + reaction, + ]; + if (matching.isEmpty) return KeyEventResult.ignored; + FunctionsRepository.triggerAction( + context, + TriggerType.keyPress, + reactions: matching, + ); + return KeyEventResult.handled; + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -779,6 +847,7 @@ class _PassiveCanvasWidgetState extends State { triggerOnLoadActions(context); _watchValues(context); + _startTimers(); } List collectOnLoadActions() => widget.node.reactions @@ -850,7 +919,21 @@ class _PassiveCanvasWidgetState extends State { statusBarIconBrightness: brightness, statusBarBrightness: brightness, ), - child: Builder(builder: widget.builder), + child: widget.settings.isPreview || + !widget.node.reactions.any( + (reaction) => reaction.trigger.type == TriggerType.keyPress, + ) + ? Builder(builder: widget.builder) + : Focus( + autofocus: true, + onKeyEvent: _onKey, + child: Builder(builder: widget.builder), + ), ); } } + +/// A key label in the form key press triggers compare: lower case, without +/// spaces, so `Arrow Right`, `ArrowRight` and `arrowright` all match. +String normalizeKeyLabel(String label) => + label.replaceAll(' ', '').toLowerCase(); diff --git a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart index 84c03ae9..b7914477 100644 --- a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart @@ -302,6 +302,20 @@ class PassiveListViewWidget extends StatelessWidget { itemBuilder: builder, ); + // A stacked list draws its items on top of each other, the first on + // top, inside the list's own box. It never scrolls. + if (node.properties.stacked) { + final int count = itemCount ?? 0; + return box( + canShrinkWrap: false, + (context, _) => StackedItems( + itemCount: count, + clipBehavior: node.clipsContent ? Clip.hardEdge : Clip.none, + itemBuilder: itemBuilder, + ), + ); + } + // An endless list with items of free extent ends after a viewport's // worth of items that take no space, so they cannot be built without // limit; see [EndlessList]. A fixed item extent is bounded by Flutter. @@ -462,3 +476,42 @@ class ListViewItemSeparator extends StatelessWidget { } } } + + +/// The items of a stacked list: drawn on top of each other in the list's +/// box, each at its top center, the first item on top. +class StackedItems extends StatelessWidget { + /// Creates a [StackedItems]. + const StackedItems({ + super.key, + required this.itemCount, + required this.itemBuilder, + this.clipBehavior = Clip.none, + }); + + /// How many items. + final int itemCount; + + /// Builds an item. + final IndexedWidgetBuilder itemBuilder; + + /// Whether items moved outside the box are clipped. + final Clip clipBehavior; + + @override + Widget build(BuildContext context) => Stack( + clipBehavior: clipBehavior, + children: [ + // Built last to first, so the first item paints over the rest and + // takes taps first. + for (int index = itemCount - 1; index >= 0; index--) + Positioned.fill( + key: ValueKey(index), + child: Align( + alignment: Alignment.topCenter, + child: itemBuilder(context, index), + ), + ), + ], + ); +} diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 9d6e473b..a2d3ed08 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -124,11 +124,13 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { } if (settings.withReactions) { widget = wrapWithReaction(context, styled, widget); + widget = wrapWithSwipes(context, styled, widget); widget = wrapWithSemantics(styled, widget); widget = applyTooltipAndZoom(context, styled, widget); } if (settings.withRotation) { widget = applyWidgetRotation(context, styled, widget); + widget = applyWidgetMotion(context, styled, widget); } if (settings.withConstraints) { widget = applyWidgetConstraints(styled, widget); diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 87c40bd2..54cbcd0b 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -143,6 +143,56 @@ abstract class WidgetNodeTransformerManager ); } + /// The motion [node] shows now: its [BaseNode.motion] with the values + /// bound through `motion.` variables resolved from [scopedValues]. + /// Null when the node doesn't move. + static MotionModel? resolveMotion( + BaseNode node, { + required ScopedValues scopedValues, + }) { + MotionModel? motion = node.motion; + for (final String property in MotionModel.properties) { + final String key = MotionModel.variableKey(property); + if (!node.variables.containsKey(key)) continue; + final Object? value = PropertyValueDelegate.getPropertyValue( + node, + key, + scopedValues: scopedValues, + ); + final double? number = switch (value) { + num n => n.toDouble(), + String s => double.tryParse(s.trim()), + bool b => b ? 1 : 0, + _ => null, + }; + if (number == null || !number.isFinite) continue; + motion = (motion ?? MotionModel.none).withValue(property, number); + } + return motion; + } + + /// Draws [widget] with [node]'s motion: its transform, transitions and + /// spin. [animate] false (the design canvas) shows the values without + /// animating or spinning. + Widget applyWidgetMotion( + BuildContext context, + BaseNode node, + Widget widget, { + ScopedValues? scopedValues, + bool animate = true, + }) { + if (node.motion == null && + !node.variables.keys.any((key) => key.startsWith('motion.'))) { + return widget; + } + final MotionModel? motion = resolveMotion( + node, + scopedValues: scopedValues ?? ScopedValues.of(context), + ); + if (motion == null) return widget; + return NodeMotion(motion: motion, animate: animate, child: widget); + } + /// Convenience method to handle widget margins. Widget applyWidgetMargins(BaseNode node, Widget widget) { if (node.margin == EdgeInsetsModel.zero) return widget; @@ -317,6 +367,28 @@ abstract class WidgetNodeTransformerManager } } + /// Runs [node]'s swipe reactions when it is swiped, or scrolled with a + /// mouse wheel (scrolling down swipes up, as content moves up). + Widget wrapWithSwipes(BuildContext context, BaseNode node, Widget widget) { + if (node is! ReactionMixin) return widget; + final List swipes = (node as ReactionMixin).reactions + .where( + (reaction) => + TriggerType.swipes.contains(reaction.trigger.type) && + reaction.action.enabled, + ) + .toList(); + if (swipes.isEmpty) return widget; + return SwipeDetector( + onSwipe: (TriggerType direction) => FunctionsRepository.triggerAction( + context, + direction, + reactions: swipes, + ), + child: widget, + ); + } + BorderRadius? getBorderRadius(BaseNode node) { if (node is CornerMixin) { if (node.cornerRadius.linked && diff --git a/lib/src/ui/codelessly_context.dart b/lib/src/ui/codelessly_context.dart index 48c8c976..2509b7a4 100644 --- a/lib/src/ui/codelessly_context.dart +++ b/lib/src/ui/codelessly_context.dart @@ -203,8 +203,17 @@ class CodelesslyContext with ChangeNotifier, Equatable { /// Returns a reverse-lookup of the [VariableData] associated with a given /// [name]. + /// + /// When a screen variable and a shared (app or page) variable have the same + /// name, the screen's own variable wins, as it does when bindings read + /// [variableNamesMap]: shared variables are loaded first, so the last + /// variable with the name is the one in use. Observable? findVariableByName(String? name) => variables.values - .firstWhereOrNull((variable) => variable.value.name == name); + .lastWhereOrNull((variable) => variable.value.name == name); + + /// The variable with [id], or else the one in use with [name]. + Observable? findVariable({String? id, String? name}) => + (id == null ? null : variables[id]) ?? findVariableByName(name); /// Allows to easily [value] of a variable with a given [name]. /// Returns false if the variable does not exist. diff --git a/lib/src/ui/node_motion.dart b/lib/src/ui/node_motion.dart new file mode 100644 index 00000000..ba5c828b --- /dev/null +++ b/lib/src/ui/node_motion.dart @@ -0,0 +1,206 @@ +import 'dart:math' as math; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +/// The Flutter curve for a [MotionCurve]. +Curve flutterCurveOf(MotionCurve curve) => switch (curve) { + MotionCurve.linear => Curves.linear, + MotionCurve.easeInOut => Curves.easeInOut, + MotionCurve.easeOut => Curves.easeOut, + MotionCurve.easeIn => Curves.easeIn, + MotionCurve.easeOutBack => Curves.easeOutBack, + MotionCurve.easeOutCubic => Curves.easeOutCubic, +}; + +/// The transform [motion] draws, without spin, around [origin] in a box of +/// [size]. [spinTurns] adds a spin angle. +Matrix4 motionMatrix(MotionModel motion, {double spinTurns = 0}) { + final matrix = Matrix4.identity(); + if (motion.perspective > 0 && (motion.rotateX != 0 || motion.rotateY != 0)) { + matrix.setEntry(3, 2, 0.0012 * motion.perspective); + } + matrix + ..translateByDouble(motion.translateX, motion.translateY, 0, 1) + ..scaleByDouble(motion.scale, motion.scale, 1, 1) + ..rotateZ(motion.rotate * math.pi / 180 + spinTurns * 2 * math.pi) + ..rotateX(motion.rotateX * math.pi / 180) + ..rotateY(motion.rotateY * math.pi / 180); + return matrix; +} + +/// Draws [child] with a node's [motion]: a transform over its layout that +/// animates whenever its values change, plus an optional continuous spin. +/// +/// [motion] holds the values to show now, with bound variables already +/// resolved. When they change the widget animates to them over +/// [MotionModel.durationMs]. When [animate] is false (the design canvas) it +/// jumps to them and doesn't spin. +class NodeMotion extends StatefulWidget { + /// Creates a [NodeMotion]. + const NodeMotion({ + super.key, + required this.motion, + required this.child, + this.animate = true, + }); + + /// The values to show. + final MotionModel motion; + + /// Whether changes animate and spin runs. + final bool animate; + + /// The node's widget. + final Widget child; + + @override + State createState() => _NodeMotionState(); +} + +class _NodeMotionState extends State with TickerProviderStateMixin { + late final AnimationController _transition = AnimationController(vsync: this) + ..addListener(_rebuild); + late MotionModel _from = widget.motion; + late MotionModel _to = widget.motion; + + Ticker? _spinTicker; + Duration _lastTick = Duration.zero; + + /// Current spin speed and angle, in turns per second and turns. + double _speed = 0; + double _turns = 0; + + void _rebuild() => setState(() {}); + + @override + void initState() { + super.initState(); + _syncSpin(); + } + + @override + void didUpdateWidget(NodeMotion oldWidget) { + super.didUpdateWidget(oldWidget); + final MotionModel next = widget.motion; + if (!_sameTransform(next, _to)) { + final ms = widget.animate ? next.durationMs : 0; + if (ms <= 0) { + _transition.stop(); + _from = next; + _to = next; + } else { + // Start from wherever the running transition has got to. + _from = _current; + _to = next; + _transition + ..duration = Duration(milliseconds: ms) + ..forward(from: 0); + } + } else { + // Settings that don't animate (duration, curve, spin) apply at once. + _from = _from.copyWith(spin: next.spin); + _to = next; + } + _syncSpin(); + } + + static bool _sameTransform(MotionModel a, MotionModel b) => + a.translateX == b.translateX && + a.translateY == b.translateY && + a.scale == b.scale && + a.opacity == b.opacity && + a.rotate == b.rotate && + a.rotateX == b.rotateX && + a.rotateY == b.rotateY && + a.perspective == b.perspective && + a.originX == b.originX && + a.originY == b.originY; + + MotionModel get _current { + if (!_transition.isAnimating && _transition.value == 0) return _to; + final double t = flutterCurveOf(_to.curve).transform(_transition.value); + double lerp(String p) => + _from.valueOf(p) + (_to.valueOf(p) - _from.valueOf(p)) * t; + return _to.copyWith( + translateX: lerp('translateX'), + translateY: lerp('translateY'), + scale: lerp('scale'), + opacity: lerp('opacity').clamp(0, 1).toDouble(), + rotate: lerp('rotate'), + rotateX: lerp('rotateX'), + rotateY: lerp('rotateY'), + ); + } + + void _syncSpin() { + final bool wantsTicker = + widget.animate && (widget.motion.spin != 0 || _speed != 0); + if (wantsTicker && _spinTicker == null) { + _lastTick = Duration.zero; + _spinTicker = createTicker(_onSpinTick)..start(); + } else if (!widget.animate && _spinTicker != null) { + _spinTicker!.dispose(); + _spinTicker = null; + _speed = 0; + } + } + + void _onSpinTick(Duration elapsed) { + final double dt = (elapsed - _lastTick).inMicroseconds / 1e6; + _lastTick = elapsed; + if (dt <= 0 || dt > 0.25) return; + final double target = widget.motion.spin; + final int ramp = widget.motion.spinRampMs; + if (ramp <= 0) { + _speed = target; + } else { + // Ease toward the target speed, like a belt drive. + final double k = math.min(1, dt * 3000 / ramp); + _speed += (target - _speed) * k; + if ((target - _speed).abs() < 0.002) _speed = target; + } + _turns = (_turns + _speed * dt) % 1; + if (_speed == 0 && target == 0) { + _spinTicker?.dispose(); + _spinTicker = null; + } + setState(() {}); + } + + @override + void dispose() { + _transition.dispose(); + _spinTicker?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final MotionModel m = _transition.isAnimating ? _current : _to; + if (!m.hasTransform && _turns == 0) return widget.child; + Widget child = widget.child; + if (m.opacity < 1) { + // A fully transparent node also stops taking taps, like a hidden one. + child = IgnorePointer( + ignoring: m.opacity == 0, + child: Opacity(opacity: m.opacity, child: child), + ); + } + if (m.translateX == 0 && + m.translateY == 0 && + m.scale == 1 && + m.rotate == 0 && + m.rotateX == 0 && + m.rotateY == 0 && + _turns == 0) { + return child; + } + return Transform( + alignment: Alignment(m.originX, m.originY), + transform: motionMatrix(m, spinTurns: _turns), + child: child, + ); + } +} diff --git a/lib/src/ui/swipe_detector.dart b/lib/src/ui/swipe_detector.dart new file mode 100644 index 00000000..6de431e4 --- /dev/null +++ b/lib/src/ui/swipe_detector.dart @@ -0,0 +1,77 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +/// Reports swipes on [child] as [TriggerType.swipeLeft], [swipeRight], +/// [swipeUp] or [swipeDown]. A drag counts as a swipe when it travels +/// [minDistance] or is flung faster than [minVelocity]. A mouse wheel +/// scroll counts too: scrolling down swipes up, the way content moves. +class SwipeDetector extends StatefulWidget { + /// Creates a [SwipeDetector]. + const SwipeDetector({ + super.key, + required this.onSwipe, + required this.child, + this.minDistance = 40, + this.minVelocity = 300, + }); + + /// Called with the swipe's direction. + final ValueChanged onSwipe; + + /// The swiped widget. + final Widget child; + + /// How far a drag travels to count as a swipe, in logical pixels. + final double minDistance; + + /// How fast a shorter drag is flung to count, in logical pixels a second. + final double minVelocity; + + @override + State createState() => _SwipeDetectorState(); +} + +class _SwipeDetectorState extends State { + Offset _travel = Offset.zero; + DateTime _lastWheel = DateTime(0); + + static TriggerType _direction(Offset delta) => delta.dx.abs() > delta.dy.abs() + ? (delta.dx < 0 ? TriggerType.swipeLeft : TriggerType.swipeRight) + : (delta.dy < 0 ? TriggerType.swipeUp : TriggerType.swipeDown); + + void _onEnd(DragEndDetails details) { + final Offset velocity = details.velocity.pixelsPerSecond; + if (_travel.distance >= widget.minDistance) { + widget.onSwipe(_direction(_travel)); + } else if (velocity.distance >= widget.minVelocity) { + widget.onSwipe(_direction(velocity)); + } + _travel = Offset.zero; + } + + void _onWheel(PointerSignalEvent event) { + if (event is! PointerScrollEvent) return; + final DateTime now = DateTime.now(); + // A wheel sends many small scrolls; one notch is one swipe. + if (now.difference(_lastWheel).inMilliseconds < 150) return; + _lastWheel = now; + final Offset delta = event.scrollDelta; + if (delta == Offset.zero) return; + widget.onSwipe(_direction(-delta)); + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerSignal: _onWheel, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (_) => _travel = Offset.zero, + onPanUpdate: (DragUpdateDetails d) => _travel += d.delta, + onPanEnd: _onEnd, + child: widget.child, + ), + ); + } +} diff --git a/test/node_motion_test.dart b/test/node_motion_test.dart new file mode 100644 index 00000000..7077b2d4 --- /dev/null +++ b/test/node_motion_test.dart @@ -0,0 +1,214 @@ +import 'dart:math' as math; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +RectangleNode box({ + MotionModel? motion, + Map variables = const {}, +}) => + RectangleNode( + id: 'box', + name: 'Box', + basicBoxLocal: NodeBox(0, 0, 100, 100), + ) + ..motion = motion + ..variables.addAll(variables); + +Matrix4? transformOf(WidgetTester tester) { + final finder = find.descendant( + of: find.byType(NodeMotion), + matching: find.byType(Transform), + ); + if (finder.evaluate().isEmpty) return null; + return tester.widget(finder.first).transform; +} + +Future pumpMotion( + WidgetTester tester, + MotionModel motion, { + bool animate = true, +}) => tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: NodeMotion( + motion: motion, + animate: animate, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), +); + +void main() { + test('Motion round-trips through a node and omits defaults', () { + const motion = MotionModel(rotateX: 30, durationMs: 400, spin: 0.555); + final json = box(motion: motion).toJson(); + expect(json['motion'], {'rotateX': 30.0, 'durationMs': 400, 'spin': 0.555}); + final restored = RectangleNode.fromJson(json); + expect(restored.motion, motion); + expect(box().toJson().containsKey('motion'), isFalse); + }); + + test('Bound motion values come from variables and formulas', () { + final node = box( + motion: const MotionModel(rotateX: 5), + variables: { + MotionModel.variableKey('rotateX'): r'${= 2 * 30}', + MotionModel.variableKey('translateY'): r'${= 1 > 2 ? 0 : -12}', + MotionModel.variableKey('spin'): r'${= true ? 0.75 : 0}', + }, + ); + final resolved = WidgetNodeTransformerManager.resolveMotion( + node, + scopedValues: ScopedValues.empty(), + )!; + expect(resolved.rotateX, 60); + expect(resolved.translateY, -12); + expect(resolved.spin, 0.75); + }); + + test('A node with only bound values still moves', () { + final node = box( + variables: {MotionModel.variableKey('scale'): r'${= 0.5}'}, + ); + final resolved = WidgetNodeTransformerManager.resolveMotion( + node, + scopedValues: ScopedValues.empty(), + ); + expect(resolved?.scale, 0.5); + }); + + testWidgets('Changing a value animates to it over the duration', ( + tester, + ) async { + const start = MotionModel(durationMs: 400, curve: MotionCurve.linear); + await pumpMotion(tester, start); + expect(transformOf(tester), isNull); + + await pumpMotion(tester, start.copyWith(translateY: 100)); + await tester.pump(const Duration(milliseconds: 200)); + expect(transformOf(tester)!.getTranslation().y, closeTo(50, 1)); + await tester.pump(const Duration(milliseconds: 250)); + expect(transformOf(tester)!.getTranslation().y, closeTo(100, 0.01)); + }); + + testWidgets('A change mid-transition continues from where it is', ( + tester, + ) async { + const start = MotionModel(durationMs: 400, curve: MotionCurve.linear); + await pumpMotion(tester, start); + await pumpMotion(tester, start.copyWith(translateX: 100)); + await tester.pump(const Duration(milliseconds: 200)); + await pumpMotion(tester, start.copyWith(translateX: 0)); + await tester.pump(const Duration(milliseconds: 1)); + expect(transformOf(tester)!.getTranslation().x, closeTo(50, 1.5)); + }); + + testWidgets('Without a duration, or on the design canvas, values jump', ( + tester, + ) async { + await pumpMotion(tester, const MotionModel()); + await pumpMotion(tester, const MotionModel(translateX: 40)); + expect(transformOf(tester)!.getTranslation().x, 40); + + await pumpMotion( + tester, + const MotionModel(durationMs: 500), + animate: false, + ); + await pumpMotion( + tester, + const MotionModel(durationMs: 500, translateX: 80), + animate: false, + ); + expect(transformOf(tester)!.getTranslation().x, 80); + }); + + testWidgets('Spin ramps up, turns, and winds down when stopped', ( + tester, + ) async { + double angle() { + final m = transformOf(tester); + if (m == null) return 0; + return math.atan2(m.entry(1, 0), m.entry(0, 0)); + } + + await pumpMotion(tester, const MotionModel(spin: 1, spinRampMs: 1000)); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + final early = angle(); + // Still ramping: far slower than a full turn per second. + expect(early.abs(), lessThan(0.1)); + + for (var i = 0; i < 120; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(angle(), isNot(closeTo(early, 0.01))); + + await pumpMotion(tester, const MotionModel(spin: 0, spinRampMs: 300)); + for (var i = 0; i < 200; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + final stopped = angle(); + await tester.pump(const Duration(milliseconds: 100)); + expect(angle(), stopped); + }); + + testWidgets('The design canvas never spins', (tester) async { + await pumpMotion(tester, const MotionModel(spin: 1), animate: false); + await tester.pump(const Duration(milliseconds: 500)); + expect(transformOf(tester), isNull); + }); + + test('Tilts get perspective, flat transforms don\'t', () { + expect(motionMatrix(const MotionModel(rotateX: 20)).entry(3, 2), isNot(0)); + expect(motionMatrix(const MotionModel(translateX: 20)).entry(3, 2), 0); + expect( + motionMatrix(const MotionModel(rotateX: 20, perspective: 0)).entry(3, 2), + 0, + ); + }); + + testWidgets('Opacity animates, and a faded-out node takes no taps', ( + tester, + ) async { + var taps = 0; + Future pump(double opacity) => tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: NodeMotion( + motion: MotionModel( + opacity: opacity, + durationMs: 200, + curve: MotionCurve.linear, + ), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => taps++, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ), + ); + await pump(1); + await pump(0); + await tester.pump(const Duration(milliseconds: 100)); + expect( + tester.widget(find.byType(Opacity)).opacity, + closeTo(0.5, 0.05), + ); + await tester.pump(const Duration(milliseconds: 150)); + await tester.tapAt(tester.getCenter(find.byType(NodeMotion))); + expect(taps, 0); + await pump(1); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tapAt(tester.getCenter(find.byType(NodeMotion))); + expect(taps, 1); + }); +} diff --git a/test/stacked_list_test.dart b/test/stacked_list_test.dart new file mode 100644 index 00000000..98c1e934 --- /dev/null +++ b/test/stacked_list_test.dart @@ -0,0 +1,52 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Stacked survives JSON and is off by default', () { + final props = ListViewProperties(stacked: true); + expect(ListViewProperties.fromJson(props.toJson()).stacked, isTrue); + expect(ListViewProperties().toJson().containsKey('stacked'), isFalse); + expect(props.copyWith().stacked, isTrue); + }); + + testWidgets('Stacked items overlap, and the first one is on top', ( + tester, + ) async { + final items = ['a', 'b', 'c']; + final taps = []; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200, + height: 200, + child: StackedItems( + itemCount: items.length, + itemBuilder: (context, index) => GestureDetector( + onTap: () => taps.add(items[index]), + child: Container( + width: 100, + height: 100, + color: const Color(0xFF000000), + child: Text(items[index]), + ), + ), + ), + ), + ), + ), + ); + final Offset a = tester.getTopLeft(find.text('a')); + expect(tester.getTopLeft(find.text('b')), a); + expect(tester.getTopLeft(find.text('c')), a); + // Placed at the top center of the box. + final Offset box = tester.getTopLeft(find.byType(StackedItems)); + expect(a, box + const Offset(50, 0)); + + await tester.tap(find.text('a')); + expect(taps, ['a']); + }); +} diff --git a/test/timer_key_swipe_triggers_test.dart b/test/timer_key_swipe_triggers_test.dart new file mode 100644 index 00000000..b361182b --- /dev/null +++ b/test/timer_key_swipe_triggers_test.dart @@ -0,0 +1,147 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/functions/action_chain.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +Reaction reaction(TriggerModel trigger, String function) => Reaction( + trigger: trigger, + action: CallFunctionAction(name: function), +); + +CanvasNode canvas(List reactions) => CanvasNode( + id: 'canvas', + name: 'canvas', + basicBoxLocal: NodeBox(0, 0, 390, 844), + children: const ['body'], + properties: CanvasProperties(bodyId: 'body'), + reactions: reactions, +); + +Future> pumpCanvas( + WidgetTester tester, + List reactions, +) async { + final calls = []; + final codelesslyContext = CodelesslyContext.empty() + ..functions = { + for (final name in ['tick', 'next', 'previous']) + name: (context, reference, params) => calls.add(name), + }; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: ActionChainHost( + child: PassiveCanvasWidget( + node: canvas(reactions), + settings: WidgetBuildSettings(debugLabel: 'test'), + builder: (_) => const SizedBox.expand(), + ), + ), + ), + ), + ); + await tester.pump(); + return calls; +} + +void main() { + test('Triggers keep their interval and key through JSON', () { + final timer = TriggerModel(type: TriggerType.timer, intervalMs: 250); + expect(timer.toJson(), {'type': 'timer', 'intervalMs': 250}); + expect(TriggerModel.fromJson(timer.toJson()), timer); + final key = TriggerModel(type: TriggerType.keyPress, key: 'Arrow Right'); + expect(TriggerModel.fromJson(key.toJson()).key, 'Arrow Right'); + expect(TriggerModel(type: TriggerType.timer).effectiveIntervalMs, 1000); + }); + + test('Canvases offer timer and key press, shapes offer swipes', () { + expect( + canvas([]).triggerTypes, + containsAll([TriggerType.timer, TriggerType.keyPress]), + ); + final frame = FrameNode( + id: 'f', + name: 'f', + basicBoxLocal: NodeBox(0, 0, 1, 1), + children: const [], + ); + expect(frame.triggerTypes, containsAll(TriggerType.swipes)); + }); + + test('Key labels match however they are spelled', () { + expect(normalizeKeyLabel('Arrow Right'), normalizeKeyLabel('ArrowRight')); + expect(normalizeKeyLabel('arrowright'), 'arrowright'); + }); + + testWidgets('A timer reaction runs every interval until the canvas goes', ( + tester, + ) async { + final calls = await pumpCanvas(tester, [ + reaction(TriggerModel(type: TriggerType.timer, intervalMs: 200), 'tick'), + ]); + await tester.pump(const Duration(milliseconds: 650)); + expect(calls, ['tick', 'tick', 'tick']); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + expect(calls, hasLength(3)); + }); + + testWidgets('A key press reaction runs for its key only', (tester) async { + final calls = await pumpCanvas(tester, [ + reaction( + TriggerModel(type: TriggerType.keyPress, key: 'Arrow Right'), + 'next', + ), + reaction( + TriggerModel(type: TriggerType.keyPress, key: 'ArrowLeft'), + 'previous', + ), + ]); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.pump(); + expect(calls, ['next', 'previous']); + }); + + testWidgets('Swipes report their direction; taps are not swipes', ( + tester, + ) async { + final swipes = []; + var taps = 0; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SwipeDetector( + onSwipe: swipes.add, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => taps++, + child: const SizedBox(width: 300, height: 300), + ), + ), + ), + ), + ); + final center = tester.getCenter(find.byType(SizedBox)); + await tester.dragFrom(center, const Offset(-120, 10)); + await tester.dragFrom(center, const Offset(0, 120)); + await tester.tap(find.byType(SizedBox)); + await tester.pump(); + expect(swipes, [TriggerType.swipeLeft, TriggerType.swipeDown]); + expect(taps, 1); + + final pointer = TestPointer(1, PointerDeviceKind.mouse); + await tester.sendEventToBinding(pointer.hover(center)); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, 100))); + await tester.pump(); + expect(swipes.last, TriggerType.swipeUp); + }); +} diff --git a/test/variable_shadowing_test.dart b/test/variable_shadowing_test.dart new file mode 100644 index 00000000..755279e4 --- /dev/null +++ b/test/variable_shadowing_test.dart @@ -0,0 +1,73 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + // Shared (app) variables load before a screen's own, so a screen variable + // of the same name shadows the app one. + CodelesslyContext contextWithShadowing() => CodelesslyContext.empty() + ..variables['app'] = Observable( + VariableData( + id: 'app', + name: 'front', + type: VariableType.integer, + value: '0', + ), + ) + ..variables['screen'] = Observable( + VariableData( + id: 'screen', + name: 'front', + type: VariableType.integer, + value: '0', + ), + ); + + test('Lookups by name find the screen variable that bindings read', () { + final c = contextWithShadowing(); + expect(c.findVariableByName('front')!.value.id, 'screen'); + expect(c.variableNamesMap()['front']!.id, 'screen'); + expect(c.findVariable(id: 'app', name: 'front')!.value.id, 'app'); + }); + + testWidgets('Set variable writes the variable the screen reads', ( + tester, + ) async { + final c = contextWithShadowing(); + late BuildContext captured; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: c, + child: Builder( + builder: (context) { + captured = context; + return const SizedBox(); + }, + ), + ), + ); + FunctionsRepository.setVariableFromAction( + captured, + SetVariableAction( + variable: VariableData( + id: 'screen', + name: 'front', + type: VariableType.integer, + ), + newValue: r'${= front + 1}', + ), + ); + expect(c.variables['screen']!.value.value, '1'); + expect(c.variables['app']!.value.value, '0'); + // Read back through a binding, as a text would. + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${front}', + scopedValues: ScopedValues.of(captured), + ), + 1, + ); + }); +} From 269921675937e79198763deb9fa82c219f5fd7a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:59:38 +0000 Subject: [PATCH 02/12] CodelesslyWidget.initialScreen opens a screen by canvas id or name A site can link to any screen (//) instead of only the entry one; names match loosely, so record-form opens "Record form". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik --- lib/src/ui/codelessly_widget.dart | 37 +++++++++++++++++++++++- test/initial_screen_test.dart | 48 +++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 test/initial_screen_test.dart diff --git a/lib/src/ui/codelessly_widget.dart b/lib/src/ui/codelessly_widget.dart index 41c09985..2d1ee07a 100644 --- a/lib/src/ui/codelessly_widget.dart +++ b/lib/src/ui/codelessly_widget.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:codelessly_api/codelessly_api.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -58,6 +59,13 @@ class CodelesslyWidget extends StatefulWidget { /// This cannot be null if no controller is provided. final String? layoutID; + /// The screen to open first instead of the entry screen, when no + /// [layoutID] is given: a canvas id, or a canvas name matched ignoring + /// case, spaces, dashes and underscores (so `record-form` opens the + /// canvas named "Record form"). Unknown screens fall back to the entry + /// screen. A site uses this for links such as `//`. + final String? initialScreen; + /// The [Codelessly] instance to use. /// /// By default, if this is null, the global instance is used, which is @@ -161,6 +169,7 @@ class CodelesslyWidget extends StatefulWidget { CodelesslyWidget({ super.key, this.layoutID, + this.initialScreen, this.controller, this.codelessly, this.config, @@ -222,6 +231,27 @@ class CodelesslyWidget extends StatefulWidget { /// Retrieves the canvas ID of [layoutID] for a layout of [width] logical /// pixels. Returns null if the layout does not exist or has no canvas for /// that width. + /// The layout holding the screen [screen] names: a canvas id, or a canvas + /// name compared without case, spaces, dashes or underscores. Null when + /// [screen] is null or names no canvas. + static String? layoutIdForScreen(SDKPublishModel model, String? screen) { + if (screen == null || screen.trim().isEmpty) return null; + String normalize(String value) => + value.toLowerCase().replaceAll(RegExp(r'[\s_-]+'), ''); + final String wanted = normalize(Uri.decodeComponent(screen)); + for (final SDKPublishLayout layout in model.layouts.values) { + for (final MapEntry> canvas + in layout.canvases.entries) { + final BaseNode? node = canvas.value[canvas.key]; + if (canvas.key == screen || + (node != null && normalize(node.name) == wanted)) { + return layout.id; + } + } + } + return null; + } + static String? canvasIDForWidth( String? layoutID, SDKPublishModel model, @@ -509,7 +539,12 @@ class _CodelesslyWidgetState extends State { final SDKPublishModel model = snapshot.data!; effectiveLayoutID = - _effectiveController.layoutID ?? model.entryLayoutId; + _effectiveController.layoutID ?? + CodelesslyWidget.layoutIdForScreen( + model, + widget.initialScreen, + ) ?? + model.entryLayoutId; if (effectiveLayoutID == null) { final errorLog = ErrorLog( diff --git a/test/initial_screen_test.dart b/test/initial_screen_test.dart new file mode 100644 index 00000000..db19221f --- /dev/null +++ b/test/initial_screen_test.dart @@ -0,0 +1,48 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'feedback_actions_test.dart' show body, canvas, layoutOf; + +void main() { + SDKPublishModel model() { + final m = SDKPublishModel( + projectId: 'project', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + for (final layout in [ + layoutOf('home-layout', [ + canvas('home', 'homeBody')..name = 'Crates', + body('homeBody', 'home', []), + ]), + layoutOf('form-layout', [ + canvas('form', 'formBody')..name = 'Record form', + body('formBody', 'form', []), + ]), + ]) { + m.layouts[layout.id] = layout; + } + return m; + } + + test('A screen is found by canvas id or by its name, loosely', () { + final m = model(); + expect(CodelesslyWidget.layoutIdForScreen(m, 'form'), 'form-layout'); + expect(CodelesslyWidget.layoutIdForScreen(m, 'record-form'), 'form-layout'); + expect( + CodelesslyWidget.layoutIdForScreen(m, 'Record%20Form'), + 'form-layout', + ); + expect(CodelesslyWidget.layoutIdForScreen(m, 'CRATES'), 'home-layout'); + }); + + test('No or unknown screen gives no layout, so the entry one is used', () { + final m = model(); + expect(CodelesslyWidget.layoutIdForScreen(m, null), isNull); + expect(CodelesslyWidget.layoutIdForScreen(m, ''), isNull); + expect(CodelesslyWidget.layoutIdForScreen(m, 'ledger'), isNull); + }); +} From d133a366b7fb99619dac575472c4ab2893ee65aa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:21:06 +0000 Subject: [PATCH 03/12] Formula functions, wrapping lists, style bindings, per-row list previews - Formulas: padLeft/padRight, substring, replace, split, indexOf, matches, slug, seconds/duration, slice/take/skip/reverse/findIndex, index params in lambdas, multi-key sortBy and sortByDesc, object literals with merge/set, money decimals, luminance. - Lists can wrap their items onto further lines. - StyleBindings: effects, stroke weight, icon, font weight and corner radius follow variables and formulas, on the canvas and at runtime. - Bindings inside a list row read that row's item on the design canvas. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik --- .../passive_button_transformer.dart | 3 +- .../passive_list_view_transformer.dart | 179 +++++- .../passive_transformer_manager.dart | 6 +- lib/src/transformers/transformers.dart | 1 + .../transformers/utils/style_bindings.dart | 264 +++++++++ lib/src/transformers/utils/text_utils.dart | 12 +- lib/src/transformers/utils/variable_span.dart | 50 +- .../transformers/utils/viewport_sizing.dart | 109 ++++ lib/src/utils/extensions.dart | 10 + lib/src/utils/formula.dart | 554 +++++++++++++++--- test/design_canvas_list_items_test.dart | 152 +++++ test/formula_strings_objects_test.dart | 367 ++++++++++++ test/style_bindings_test.dart | 349 +++++++++++ test/wrap_list_test.dart | 222 +++++++ 14 files changed, 2188 insertions(+), 90 deletions(-) create mode 100644 lib/src/transformers/utils/style_bindings.dart create mode 100644 test/design_canvas_list_items_test.dart create mode 100644 test/formula_strings_objects_test.dart create mode 100644 test/style_bindings_test.dart create mode 100644 test/wrap_list_test.dart diff --git a/lib/src/transformers/node_transformers/passive_button_transformer.dart b/lib/src/transformers/node_transformers/passive_button_transformer.dart index 4e3337d2..62815cb6 100644 --- a/lib/src/transformers/node_transformers/passive_button_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_button_transformer.dart @@ -131,7 +131,8 @@ class PassiveButtonWidget extends StatelessWidget { final List spans = buttonLabelTemplateOf(node) .splitMap( variableSyntaxIdentifierRegex, - onMatch: (match) => VariableSpan(variable: match[0]!, node: node), + onMatch: (match) => + VariableSpan(variable: match[0]!, node: node, context: context), onNonMatch: (text) => TextSpan(text: text), ) .toList(); diff --git a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart index b7914477..1694a712 100644 --- a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart @@ -75,21 +75,59 @@ class PassiveListViewWidget extends StatelessWidget { } /// Sizes the list by [ViewportLayout] around [builder]. + /// + /// A wrapping list is sized by [WrapNodeBox] instead, and the flag given + /// to [builder] says whether its runs scroll along the cross axis. Widget box(ViewportContentBuilder builder, {required bool canShrinkWrap}) => - ViewportNodeBox( - node: node, + node.properties.isWrapping + ? WrapNodeBox(node: node, builder: builder) + : ViewportNodeBox( + node: node, + scrollDirection: node.scrollDirection, + canShrinkWrap: canShrinkWrap, + builder: builder, + ); + + /// Builds one item, placed as the editor lays it out; see [ListItemBox]. + /// The item of a wrapping list keeps its own size; see [WrapItemBox]. + Widget buildItem(BaseNode item, BuildContext context) { + final Widget child = manager.buildWidgetByID( + item.id, + context, + settings: settings, + ); + if (node.properties.isWrapping) { + return WrapItemBox( + item: item, scrollDirection: node.scrollDirection, - canShrinkWrap: canShrinkWrap, - builder: builder, + itemExtent: node.properties.itemExtent, + child: child, ); + } + return ListItemBox( + item: item, + scrollDirection: node.scrollDirection, + itemExtent: node.properties.itemExtent, + child: child, + ); + } - /// Builds one item, placed as the editor lays it out; see [ListItemBox]. - Widget buildItem(BaseNode item, BuildContext context) => ListItemBox( - item: item, - scrollDirection: node.scrollDirection, - itemExtent: node.properties.itemExtent, - child: manager.buildWidgetByID(item.id, context, settings: settings), - ); + /// The items of a wrapping list; [scrolls] comes from [WrapNodeBox]. + Widget wrapped(bool scrolls, int itemCount, IndexedWidgetBuilder builder) => + WrappedItems( + itemCount: itemCount, + itemBuilder: builder, + direction: node.scrollDirection.flutterAxis, + spacing: node.properties.wrapSpacing, + runSpacing: node.properties.wrapRunSpacing, + alignment: node.properties.wrapAlignment.flutterWrapAlignment, + padding: node.padding.flutterEdgeInsets, + scrolls: scrolls, + physics: node.physics.flutterScrollPhysics(false), + keyboardDismissBehavior: + node.keyboardDismissBehavior.flutterKeyboardDismissBehavior, + clipBehavior: node.clipsContent ? Clip.hardEdge : Clip.none, + ); @override Widget build(BuildContext context) { @@ -196,6 +234,25 @@ class PassiveListViewWidget extends StatelessWidget { final int itemCount = items.itemCount; + // A wrapping list builds every item at once, so it shows the + // loaded items without paging further. + if (node.properties.isWrapping) { + final int loaded = snapshot.docs.length; + return wrapped(shrinkWrap, loaded, (context, index) { + final doc = snapshot.docs[index]; + return IndexedItemProvider( + key: ValueKey(index), + item: IndexedItem(index, {'id': doc.id, ...doc.data()}), + child: Builder( + builder: (context) => KeyedSubtree( + key: ValueKey(index), + child: buildItem(item, context), + ), + ), + ); + }); + } + // Item Count always exists. return ListViewBuilder( primary: node.primary, @@ -316,6 +373,15 @@ class PassiveListViewWidget extends StatelessWidget { ); } + // A wrapping list flows its items onto further lines, all of them built, + // so an endless one shows nothing. + if (node.properties.isWrapping) { + return box( + canShrinkWrap: true, + (context, scrolls) => wrapped(scrolls, itemCount ?? 0, itemBuilder), + ); + } + // An endless list with items of free extent ends after a viewport's // worth of items that take no space, so they cannot be built without // limit; see [EndlessList]. A fixed item extent is bounded by Flutter. @@ -477,7 +543,6 @@ class ListViewItemSeparator extends StatelessWidget { } } - /// The items of a stacked list: drawn on top of each other in the list's /// box, each at its top center, the first item on top. class StackedItems extends StatelessWidget { @@ -515,3 +580,93 @@ class StackedItems extends StatelessWidget { ], ); } + +/// The items of a wrapping list: laid out by a [Wrap] along [direction], +/// flowing onto further lines (runs) when a line is full, each item at its +/// own size. +/// +/// With [scrolls], the runs scroll along the cross axis when they don't fit +/// the box; otherwise the box grows with them. +class WrappedItems extends StatelessWidget { + /// Creates a [WrappedItems]. + const WrappedItems({ + super.key, + required this.itemCount, + required this.itemBuilder, + this.direction = Axis.horizontal, + this.spacing = 0, + this.runSpacing = 0, + this.alignment = WrapAlignment.start, + this.padding = EdgeInsets.zero, + this.scrolls = false, + this.physics, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.clipBehavior = Clip.none, + }); + + /// How many items. + final int itemCount; + + /// Builds an item. + final IndexedWidgetBuilder itemBuilder; + + /// The wrap direction: the list's scroll direction. + final Axis direction; + + /// The gap between two items of a line. + final double spacing; + + /// The gap between two lines. + final double runSpacing; + + /// How the items of a line are placed along it. + final WrapAlignment alignment; + + /// The list's padding, around the runs. + final EdgeInsets padding; + + /// Whether the runs scroll along the cross axis. + final bool scrolls; + + /// The physics of that scroll. + final ScrollPhysics? physics; + + /// See [ScrollView.keyboardDismissBehavior]. + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// Whether the runs are clipped to the box. + final Clip clipBehavior; + + @override + Widget build(BuildContext context) { + final Widget wrap = Wrap( + direction: direction, + spacing: spacing, + runSpacing: runSpacing, + alignment: alignment, + children: [ + for (int index = 0; index < itemCount; index++) + itemBuilder(context, index), + ], + ); + if (scrolls) { + return DraggableScrollConfiguration( + child: SingleChildScrollView( + primary: false, + scrollDirection: direction == Axis.horizontal + ? Axis.vertical + : Axis.horizontal, + padding: padding, + physics: physics, + keyboardDismissBehavior: keyboardDismissBehavior, + clipBehavior: clipBehavior, + child: wrap, + ), + ); + } + final Widget padded = Padding(padding: padding, child: wrap); + return clipBehavior == Clip.none + ? padded + : ClipRect(clipBehavior: clipBehavior, child: padded); + } +} diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index a2d3ed08..cc4141b8 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -178,7 +178,8 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { /// Returns [node] with the style properties that conditions, bound /// variables or formulas, or set value actions give it at runtime: `fill` /// (a text's color for text nodes), `stroke`, `opacity`, `width` and - /// `height`. Returns [node] itself when + /// `height`, and the properties of [StyleBindings] (effects, stroke + /// weight, corner radius, font weight, icon). Returns [node] itself when /// nothing changes it. BaseNode applyStyleOverrides(BuildContext context, BaseNode node) { final ScopedValues scopedValues = ScopedValues.of(context); @@ -241,6 +242,9 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { ).toJson(); } + // Effects, stroke weight, corner radius, font weight and icon. + StyleBindings.addTo(patch, node, scopedValues: scopedValues); + // The states shown around the node (a property-override variant, its // own or an ancestor's interaction states) win over bindings. final Map? statePatch = PropertyOverrideScope.patchFor( diff --git a/lib/src/transformers/transformers.dart b/lib/src/transformers/transformers.dart index 84cd7172..0089709c 100644 --- a/lib/src/transformers/transformers.dart +++ b/lib/src/transformers/transformers.dart @@ -15,6 +15,7 @@ export 'utils/property_overrides.dart'; export 'utils/property_value_delegate.dart'; export 'utils/scoped_values.dart'; export 'utils/stroke_painter.dart'; +export 'utils/style_bindings.dart'; export 'utils/text_utils.dart'; export 'utils/variable_span.dart'; export 'utils/viewport_sizing.dart'; diff --git a/lib/src/transformers/utils/style_bindings.dart b/lib/src/transformers/utils/style_bindings.dart new file mode 100644 index 00000000..228b6422 --- /dev/null +++ b/lib/src/transformers/utils/style_bindings.dart @@ -0,0 +1,264 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:collection/collection.dart'; + +import 'property_value_delegate.dart'; +import 'scoped_values.dart'; + +/// The style properties, beyond `fill`, `stroke`, `opacity`, `width` and +/// `height`, that conditions, bound variables or formulas can set at runtime. +/// +/// Each is a key of [BaseNode.variables] whose value is a variable path or a +/// formula, e.g. `variables['strokeWeight'] = '${= selected ? 2 : 1}'`: +/// +/// * [effectsVisible] (bool): shows or hides every effect of the node. +/// * [effectVisible] `effect-visible-` (bool): shows or hides one +/// effect. Wins over [effectsVisible]. +/// * [effectsColor] (color): the color of every drop and inner shadow. +/// * [shadowColor] `shadow-color-` (color): the color of one +/// shadow. Wins over [effectsColor]. +/// * [effectsOffsetX], [effectsOffsetY], [effectsSpread] (number): the +/// offset and spread of every drop and inner shadow. +/// * [effectsBlur] (number): the blur radius of every effect. +/// * [strokeWeight] (number): the node's stroke width. +/// * [cornerRadius] (number): the radius of every corner. A corner radius +/// JSON value (e.g. `[8, true, 'circular']`) is accepted too. +/// * [fontWeight] (number 100-900, or `w700`, `bold`, `normal`): the weight +/// of every span of a text node. +/// * [icon] (string): the name of the icon an icon node shows, in the +/// naming of its icon model (`star`, `star_border`, ...). Its family and +/// style stay. An icon model JSON map replaces the icon as a whole. +abstract final class StyleBindings { + /// Shows or hides every effect of the node. + static const String effectsVisible = 'effectsVisible'; + + /// The color of every drop and inner shadow. + static const String effectsColor = 'effectsColor'; + + /// The x offset of every drop and inner shadow. + static const String effectsOffsetX = 'effectsOffsetX'; + + /// The y offset of every drop and inner shadow. + static const String effectsOffsetY = 'effectsOffsetY'; + + /// The blur radius of every effect. + static const String effectsBlur = 'effectsBlur'; + + /// The spread of every drop and inner shadow. + static const String effectsSpread = 'effectsSpread'; + + /// The stroke width of the node. + static const String strokeWeight = 'strokeWeight'; + + /// The radius of every corner of the node. + static const String cornerRadius = 'cornerRadius'; + + /// The font weight of every span of a text node. + static const String fontWeight = 'fontWeight'; + + /// The name of the icon an icon node shows. + static const String icon = 'icon'; + + /// Shows or hides the effect [effectId]. + static String effectVisible(String effectId) => 'effect-visible-$effectId'; + + /// The color of the shadow [effectId]. + static String shadowColor(String effectId) => 'shadow-color-$effectId'; + + /// Adds to [patch], a partial node JSON, the values that the bindings of + /// these properties give [node], evaluated with [scopedValues]. A property + /// whose binding is missing, evaluates to null or gives the node's own + /// value adds nothing. + /// + /// Text spans already in [patch] (a text's bound color) keep their + /// changes. + static void addTo( + Map patch, + BaseNode node, { + required ScopedValues scopedValues, + }) { + T? value(String property) => + PropertyValueDelegate.getPropertyValue( + node, + property, + scopedValues: scopedValues, + ); + + if (node is BlendMixin && node.effects.isNotEmpty) { + final List effects = _effects(node.effects, value); + if (!const ListEquality().equals(effects, node.effects)) { + patch['effects'] = [for (final effect in effects) effect.toJson()]; + } + } + + if (node is GeometryMixin) { + final double? weight = value(strokeWeight); + if (weight != null && weight != node.strokeWeight) { + patch['strokeWeight'] = weight < 0 ? 0.0 : weight; + } + } + + if (node is CornerMixin) { + final CornerRadius? radius = _cornerRadius(value(cornerRadius)); + if (radius != null && radius != node.cornerRadius) { + patch['cornerRadius'] = radius.toJson(); + } + } + + if (node is TextNode) { + final FontWeightNumeric? weight = parseFontWeight( + value(fontWeight), + ); + if (weight != null && + node.textMixedProps.any((prop) => prop.fontName.weight != weight)) { + final List spans = + patch['textMixedProps'] as List? ?? + [for (final prop in node.textMixedProps) prop.toJson()]; + patch['textMixedProps'] = [ + for (final (int index, Map span) in spans.cast().indexed) + { + ...span, + // A span's JSON leaves out a default font name. + 'fontName': node.textMixedProps[index].fontName + .copyWith(weight: weight) + .toJson(), + }, + ]; + } + } + + if (node is IconNode) { + final IconModel? bound = _icon( + node.properties.icon.icon, + value(icon), + ); + if (bound != null && bound != node.properties.icon.icon) { + final Map? properties = patch['properties'] as Map?; + final Map current = properties ?? node.properties.toJson(); + patch['properties'] = { + ...current, + 'icon': { + ...(current['icon'] as Map? ?? node.properties.icon.toJson()), + 'icon': bound.toJson(), + }, + }; + } + } + } + + static List _effects( + List effects, + T? Function(String property) value, + ) { + final bool? allVisible = value(effectsVisible); + final ColorRGBA? color = value(effectsColor); + final double? offsetX = value(effectsOffsetX); + final double? offsetY = value(effectsOffsetY); + final double? blur = value(effectsBlur); + final double? spread = value(effectsSpread); + + return [ + for (final Effect effect in effects) + () { + final bool isShadow = + effect.type == EffectType.dropShadow || + effect.type == EffectType.innerShadow; + final bool visible = + value(effectVisible(effect.id)) ?? + allVisible ?? + effect.visible; + if (!isShadow) { + return effect.copyWith( + visible: visible, + radius: blur == null || blur < 0 ? null : blur, + ); + } + final Vec offset = effect.offset ?? const Vec(0, 0); + return effect.copyWith( + visible: visible, + color: value(shadowColor(effect.id)) ?? color, + offset: offsetX == null && offsetY == null + ? null + : Vec(offsetX ?? offset.x, offsetY ?? offset.y), + radius: blur == null || blur < 0 ? null : blur, + spread: spread, + ); + }(), + ]; + } + + static CornerRadius? _cornerRadius(Object? value) { + if (value == null) return null; + if (value is num || value is String) { + final double? radius = value is num + ? value.toDouble() + : double.tryParse(value as String); + if (radius == null) return null; + return CornerRadius.all( + RadiusModel.circular(radius < 0 ? 0 : radius), + linked: true, + ); + } + try { + return CornerRadius.fromJson(value); + } catch (_) { + return null; + } + } + + /// The font weight [value] names: a number from 100 to 900 (rounded to the + /// nearest hundred), `w700`, or a name such as `bold`, `normal` or + /// `semibold`. Null when it names none. + static FontWeightNumeric? parseFontWeight(Object? value) { + if (value == null) return null; + if (value is FontWeightNumeric) return value; + num? number = value is num ? value : null; + if (value is String) { + final String text = value.trim().toLowerCase().replaceAll( + RegExp('[ _-]'), + '', + ); + number = + num.tryParse(text) ?? + num.tryParse(text.startsWith('w') ? text.substring(1) : '') ?? + switch (text) { + 'thin' || 'hairline' => 100, + 'extralight' || 'ultralight' => 200, + 'light' => 300, + 'normal' || 'regular' || 'plain' => 400, + 'medium' => 500, + 'semibold' || 'demibold' => 600, + 'bold' => 700, + 'extrabold' || 'ultrabold' => 800, + 'black' || 'heavy' => 900, + _ => null, + }; + } + if (number == null || number.isNaN) return null; + final int index = ((number / 100).round() - 1).clamp(0, 8); + return FontWeightNumeric.values[index]; + } + + /// The icon [value] names: [current] with the name [value] gives it, or + /// the icon an icon model JSON [value] describes. + static IconModel? _icon(IconModel? current, Object? value) { + if (value == null) return null; + if (value is Map) { + try { + return IconModel.fromJson(Map.from(value)); + } catch (_) { + return null; + } + } + final String name = value.toString().trim(); + if (name.isEmpty || current == null || name == current.name) { + return null; + } + return switch (current) { + // Google serves every Material icon at version 1; a later version of + // one icon need not exist for another. + final MaterialIcon icon => icon.copyWith(name: name, version: 1), + final MaterialSymbol icon => icon.copyWith(name: name), + _ => null, + }; + } +} diff --git a/lib/src/transformers/utils/text_utils.dart b/lib/src/transformers/utils/text_utils.dart index b677d159..7ee6809a 100644 --- a/lib/src/transformers/utils/text_utils.dart +++ b/lib/src/transformers/utils/text_utils.dart @@ -100,8 +100,12 @@ class TextUtils { final spans = characters .splitMap( variableSyntaxIdentifierRegex, - onMatch: (match) => - VariableSpan(variable: match[0]!, node: node, style: style), + onMatch: (match) => VariableSpan( + variable: match[0]!, + node: node, + style: style, + context: context, + ), onNonMatch: (text) => TextSpan(text: text, style: style), ) .toList(); @@ -346,7 +350,9 @@ class TextUtils { // Not supported yet but good to have. final List shadows = effects - .where((element) => element.type == EffectType.dropShadow) + .where( + (element) => element.type == EffectType.dropShadow && element.visible, + ) .map( (d) => Shadow( offset: Offset(d.offset!.x.toDouble(), d.offset!.y.toDouble()), diff --git a/lib/src/transformers/utils/variable_span.dart b/lib/src/transformers/utils/variable_span.dart index c1feb7df..13278852 100644 --- a/lib/src/transformers/utils/variable_span.dart +++ b/lib/src/transformers/utils/variable_span.dart @@ -15,6 +15,33 @@ abstract final class VariablePreview { /// fall back to placeholders. static ScopedValues? Function(BaseNode? node)? scopeResolver; + /// The values that [node] sees on the design canvas where it is built in + /// [context], or null to fall back to placeholders. + /// + /// [scopeResolver] only knows the node, and a list's item template is one + /// node that every row of the list builds: it can only give the sample + /// item of the nearest data-bound list (its first). The row being built + /// is known from [context]: its [IndexedItemProvider] gives that row's + /// own item, as at runtime, so each row previews its own data. + /// + /// A row without an item of its own (a list that only sets an item count, + /// or a row past the end of the data) keeps the sample item but reads + /// its own `index`. + static ScopedValues? scopeOf(BaseNode? node, {BuildContext? context}) { + final ScopedValues? values = scopeResolver?.call(node); + if (values == null || context == null) return values; + final IndexedItem? row = IndexedItemProvider.maybeOf(context); + if (row == null || row == values.indexedItem) return values; + return ScopedValues.preview( + variables: values.variables, + codelesslyContext: values.codelesslyContext, + screenSize: values.screenSize, + indexedItem: row.item != null || values.indexedItem == null + ? row + : IndexedItem(row.index, values.indexedItem!.item), + ); + } + /// Accessors that read a property of a value rather than name it, so the /// placeholder for `${item.title.initial}` is named after `title`. static const Set _accessorNames = { @@ -36,9 +63,15 @@ abstract final class VariablePreview { static const Set _literals = {'true', 'false', 'null'}; - /// The text the design canvas shows for [expression], one `${...}` match. - static String textFor(String expression, {BaseNode? node, TextStyle? style}) { - final ScopedValues? scopedValues = scopeResolver?.call(node); + /// The text the design canvas shows for [expression], one `${...}` match, + /// in [node] built in [context] (see [scopeOf]). + static String textFor( + String expression, { + BaseNode? node, + TextStyle? style, + BuildContext? context, + }) { + final ScopedValues? scopedValues = scopeOf(node, context: context); if (scopedValues != null) { final String value = PropertyValueDelegate.substituteVariables( expression, @@ -131,15 +164,22 @@ class VariableSpan extends TextSpan { /// The variable expression, such as `${user.name}`. final String variable; - /// Creates a [VariableSpan] for the [variable] expression of [node]. + /// Creates a [VariableSpan] for the [variable] expression of [node], built + /// in [context]: a list row's context gives the row's own item. VariableSpan({ required this.variable, BaseNode? node, + BuildContext? context, super.style, super.mouseCursor, super.onEnter, super.onExit, }) : super( - text: VariablePreview.textFor(variable, node: node, style: style), + text: VariablePreview.textFor( + variable, + node: node, + style: style, + context: context, + ), ); } diff --git a/lib/src/transformers/utils/viewport_sizing.dart b/lib/src/transformers/utils/viewport_sizing.dart index 74083b90..3992dd6a 100644 --- a/lib/src/transformers/utils/viewport_sizing.dart +++ b/lib/src/transformers/utils/viewport_sizing.dart @@ -224,6 +224,115 @@ class ListItemBox extends StatelessWidget { } } +/// Sizes a wrapping list node (see `ListViewProperties.wrap`) and builds its +/// content. The flag given to [builder] says whether the runs must scroll +/// along the cross axis: true when the box has a set extent there. +/// +/// Unlike a viewport, a [Wrap] can measure its items on both axes, so on +/// each axis: +/// +/// * expanded: fill when the incoming extent is bounded, else the design +/// size. +/// * shrinkWrap: size to the runs (on the wrap axis, the longest line that +/// fits the incoming extent; on the cross axis, all the runs). +/// * fixed, locked, flexible: the design size. +class WrapNodeBox extends StatelessWidget { + /// The list node. + final ListViewNode node; + + /// Builds the content inside the resolved box. + final ViewportContentBuilder builder; + + /// Creates a [WrapNodeBox]. + const WrapNodeBox({super.key, required this.node, required this.builder}); + + /// The extent of [node] along one axis whose fit is [fit] and design size + /// [design], under an incoming extent that is [bounded] or not. Null + /// sizes to the runs. + static double? extent(SizeFit fit, double design, bool bounded) => + switch (fit) { + SizeFit.expanded => bounded ? double.infinity : design, + SizeFit.shrinkWrap => null, + SizeFit.fixed || SizeFit.locked || SizeFit.flexible => design, + }; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final double? width = extent( + node.horizontalFit, + node.basicBoxLocal.width, + constraints.hasBoundedWidth, + ); + final double? height = extent( + node.verticalFit, + node.basicBoxLocal.height, + constraints.hasBoundedHeight, + ); + final double? cross = node.scrollDirection == AxisC.horizontal + ? height + : width; + return SizedBox( + width: width, + height: height, + child: builder(context, cross != null), + ); + }, + ); + } +} + +/// Sizes the item [child] of a wrapping list: the item keeps its own size +/// on both axes. An expanded or flexible item, which has nothing to fill in +/// a run, takes its design size; with [itemExtent] the item takes that +/// extent along the wrap direction. +class WrapItemBox extends StatelessWidget { + /// The item node. + final BaseNode item; + + /// The list's scroll axis: the wrap direction. + final AxisC scrollDirection; + + /// The list's fixed item extent, if any. + final double? itemExtent; + + /// The built item. + final Widget child; + + /// Creates a [WrapItemBox]. + const WrapItemBox({ + super.key, + required this.item, + required this.scrollDirection, + required this.itemExtent, + required this.child, + }); + + static bool _fills(SizeFit fit) => + fit == SizeFit.expanded || fit == SizeFit.flexible; + + @override + Widget build(BuildContext context) { + final bool vertical = scrollDirection == AxisC.vertical; + double? width = _fills(item.horizontalFit) + ? item.outerBoxLocal.width + : null; + double? height = _fills(item.verticalFit) + ? item.outerBoxLocal.height + : null; + if (itemExtent != null) { + if (vertical) { + height = itemExtent; + } else { + width = itemExtent; + } + } + if (width == null && height == null) return child; + return SizedBox(width: width, height: height, child: child); + } +} + /// A grid delegate for a fixed cross axis count that keeps cells at least as /// wide as the editor drew them. /// diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 8cc1c817..de5cefd2 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -491,6 +491,16 @@ extension CMainAxisAlignmentHelper on MainAxisAlignmentC { return flutter.MainAxisAlignment.spaceEvenly; } } + + /// The [flutter.WrapAlignment] of the same name. + flutter.WrapAlignment get flutterWrapAlignment => switch (this) { + MainAxisAlignmentC.start => flutter.WrapAlignment.start, + MainAxisAlignmentC.end => flutter.WrapAlignment.end, + MainAxisAlignmentC.center => flutter.WrapAlignment.center, + MainAxisAlignmentC.spaceBetween => flutter.WrapAlignment.spaceBetween, + MainAxisAlignmentC.spaceAround => flutter.WrapAlignment.spaceAround, + MainAxisAlignmentC.spaceEvenly => flutter.WrapAlignment.spaceEvenly, + }; } extension CCrossAxisAlignmentHelper on CrossAxisAlignmentC { diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 99ac5af5..41977a69 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -1,15 +1,28 @@ import 'dart:math' as math; +/// The body of a formula: text and quoted strings, with braces only in +/// balanced pairs nested up to [depth] deep, such as the object literal in +/// `merge(r, {tags: {a: 1}})`. Braces inside quoted strings don't count. +String _formulaBody(int depth) { + const String atom = r'''[^{}'"]|'[^']*'|"[^"]*"'''; + String body = '(?:$atom)*'; + for (int i = 0; i < depth; i++) { + body = '(?:$atom|\\{$body\\})*'; + } + return body; +} + /// Matches a formula written as `${= expression}` in a text. /// -/// The expression can't contain braces, except inside quoted strings. +/// The expression can contain braces in balanced pairs, three levels deep, +/// for object literals such as `${= merge(r, {favorite: true})}`. final RegExp formulaRegex = RegExp( - r'''(?(?:[^{}'"]|'[^']*'|"[^"]*")*)\}''', + r'(?' + _formulaBody(3) + r')\}', ); /// Matches a text that is exactly one formula, e.g. `${= qty * price}`. final RegExp exactFormulaRegex = RegExp( - r'''^\s*(?(?:[^{}'"]|'[^']*'|"[^"]*")*)\}\s*$''', + r'^\s*(?' + _formulaBody(3) + r')\}\s*$', ); /// Resolves a variable path such as `storage.cart` or `item.price` to its @@ -19,8 +32,9 @@ typedef FormulaPathResolver = Object? Function(String path); /// Evaluates small expressions used in `${= ...}` formulas. /// /// Supports: -/// - numbers, 'single' or "double" quoted strings, true, false, null and -/// `[a, b]` lists. +/// - numbers, 'single' or "double" quoted strings, true, false, null, +/// `[a, b]` lists and `{id: 'a', 'full name': n + 1}` objects, whose keys +/// are names or quoted text. /// - variable paths like `storage.cart.length` or `item.price`, resolved with /// the same rules as `${path}`, followed by `[index]` or `.key`. /// - `+ - * / %`, comparisons `== != < <= > >=`, `&& || !` and @@ -29,15 +43,22 @@ typedef FormulaPathResolver = Object? Function(String path); /// length, if, plural, join, upper, lower, trim, contains, startsWith, /// endsWith, where, without, number, text, isEmpty and isNotEmpty. See /// [FormulaEvaluator.functions]. +/// - text functions: padLeft, padRight, substring, replace, split, indexOf, +/// matches and slug; durations: seconds ('3:45' → 225) and duration +/// (225 → '3:45'); colors: luminance. /// - list functions that take a predicate or key: filter, where, count, -/// find, any, all, map, sortBy, unique, sum and join. The predicate is a -/// lambda, `item => expression`, or a field name, optionally with a value -/// to match, e.g. `filter(tasks, t => contains(lower(t.title), q))`, +/// find, findIndex, any, all, map, sortBy, sortByDesc, unique, sum and +/// join. The predicate is a lambda, `item => expression`, or a field name, +/// optionally with a value to match, e.g. +/// `filter(tasks, t => contains(lower(t.title), q))`, /// `filter(tasks, 'done')` or `count(tasks, 'done', false)`. A lambda's /// parameter is read like a variable inside its body (`t.title`, /// `t.tags[0]`, `t.tags.length`) and hides a variable of the same name; -/// the body can read every other variable. Lambdas are only allowed as -/// function arguments. +/// the body can read every other variable. A lambda can take the item's +/// index as a second parameter, `(t, i) => i < 3`. Lambdas are only +/// allowed as function arguments. +/// - other list functions: slice, take, skip and reverse (which also work +/// on text); object functions: merge and set, which return new objects. /// - date functions: now, today, parseDate, formatDate, addMinutes, addHours, /// addDays, addMonths, diffMinutes, diffHours, diffDays, year, month, day, /// hour, minute and weekday. Dates are ISO 8601 text such as @@ -160,7 +181,7 @@ class FormulaEvaluator { static const List functionDocs = [ FormulaFunctionDoc( 'money', - 'money(value, symbol)', + 'money(value, symbol, decimals) or money(value, decimals)', r'money(price) → $1,234.50', ), FormulaFunctionDoc( @@ -222,6 +243,57 @@ class FormulaEvaluator { 'endsWith(text, suffix)', 'endsWith("photo.png", ".png") → true', ), + FormulaFunctionDoc( + 'padLeft', + 'padLeft(value, width, char)', + 'padLeft(4, 2) → 04 (numbers pad with 0, text with spaces)', + ), + FormulaFunctionDoc( + 'padRight', + 'padRight(value, width, char)', + 'padRight("A", 3, ".") → A..', + ), + FormulaFunctionDoc( + 'substring', + 'substring(text, start, end)', + 'substring("Vinyl", 0, 3) → Vin', + ), + FormulaFunctionDoc( + 'replace', + 'replace(text, from, to)', + 'replace("a-b-c", "-", " ") → a b c', + ), + FormulaFunctionDoc( + 'split', + 'split(text, separator)', + 'split("rock, jazz", ", ") → [rock, jazz]', + ), + FormulaFunctionDoc( + 'indexOf', + 'indexOf(text or list, value)', + 'indexOf(genres, "jazz") → 1 (-1 when missing)', + ), + FormulaFunctionDoc( + 'matches', + 'matches(text, pattern)', + r'matches(code, "^\d{4}$") → true', + ), + FormulaFunctionDoc( + 'slug', + 'slug(text)', + 'slug("Kind of Blue!") → kind-of-blue', + ), + FormulaFunctionDoc( + 'seconds', + 'seconds("m:ss" or "h:mm:ss")', + 'seconds("3:45") → 225', + ), + FormulaFunctionDoc('duration', 'duration(seconds)', 'duration(225) → 3:45'), + FormulaFunctionDoc( + 'luminance', + 'luminance(color)', + 'luminance("#FFFFFF") → 1', + ), FormulaFunctionDoc( 'filter', 'filter(list, item => condition) or filter(list, field, value)', @@ -254,8 +326,40 @@ class FormulaEvaluator { ), FormulaFunctionDoc( 'sortBy', - 'sortBy(list, field or item => key, "desc")', - 'sortBy(tasks, "due") (missing keys last)', + 'sortBy(list, field or item => key, "desc", more keys…)', + 'sortBy(records, r => r.artist, r => r.year, "desc") (missing keys last)', + ), + FormulaFunctionDoc( + 'sortByDesc', + 'sortByDesc(list, field or item => key, more keys…)', + 'sortByDesc(records, "year")', + ), + FormulaFunctionDoc( + 'findIndex', + 'findIndex(list, item => condition) or findIndex(list, field, value)', + 'findIndex(records, r => r.id == selectedId) → 2 (-1 when none)', + ), + FormulaFunctionDoc( + 'slice', + 'slice(list or text, start, end)', + 'slice(tracks, 1, 3) → items 1 and 2 (negative counts from the end)', + ), + FormulaFunctionDoc('take', 'take(list or text, count)', 'take(tracks, 3)'), + FormulaFunctionDoc('skip', 'skip(list or text, count)', 'skip(tracks, 3)'), + FormulaFunctionDoc( + 'reverse', + 'reverse(list or text)', + 'reverse([1, 2, 3]) → [3, 2, 1]', + ), + FormulaFunctionDoc( + 'merge', + 'merge(object, patch, …)', + 'merge(r, {favorite: !r.favorite})', + ), + FormulaFunctionDoc( + 'set', + 'set(object or list, key or index, value)', + 'set(r, "rating", 5)', ), FormulaFunctionDoc( 'unique', @@ -442,18 +546,191 @@ Object? _member(Object? target, Object? key) { String _fixed(Object? value, Object? digits) => _num(value).toStringAsFixed(_num(digits ?? 2).toInt().clamp(0, 20)); -String _money(Object? value, Object? symbol) { +/// Formats [value] as money with [symbol] (`$` by default) and [decimals] +/// (2 by default). A number in place of the symbol is the decimals, so +/// `money(38, 0)` is "$38". Decimals `'auto'` drop them for whole amounts: +/// "$38" but "$38.50". +String _money(Object? value, Object? symbol, Object? decimals) { + if (symbol is num) (symbol, decimals) = (null, symbol); + final bool auto = + decimals is String && decimals.trim().toLowerCase() == 'auto'; final num amount = _num(value); if (amount.isNaN) return ''; + int digits = auto || decimals == null ? 2 : _intArg(decimals).clamp(0, 20); + if (auto && (amount * 100).round() % 100 == 0) digits = 0; final String sign = amount < 0 ? '-' : ''; - final String text = amount.abs().toStringAsFixed(2); - final int dot = text.indexOf('.'); + final String text = amount.abs().toStringAsFixed(digits); + final int dot = text.contains('.') ? text.indexOf('.') : text.length; final String whole = text .substring(0, dot) .replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (_) => ','); return '$sign${symbol ?? r'$'}$whole${text.substring(dot)}'; } +/// [value] as text padded to [width] with the first character of [fill]: +/// zeros for numbers and spaces for text by default. A negative number keeps +/// its sign in front of zeros, so `padLeft(-4, 3)` is "-04". +String _pad(Object? value, Object? width, Object? fill, {required bool left}) { + final String text = FormulaEvaluator.stringify(value); + final int size = _intArg(width); + final String char = fill == null + ? (value is num ? '0' : ' ') + : FormulaEvaluator.stringify(fill); + if (char.isEmpty) return text; + if (left && char[0] == '0' && text.startsWith('-')) { + return '-${text.substring(1).padLeft(size - 1, '0')}'; + } + return left ? text.padLeft(size, char[0]) : text.padRight(size, char[0]); +} + +/// The part of [value]'s text from [start] up to [end], clamped to the text. +String _substring(Object? value, Object? start, Object? end) { + final String text = FormulaEvaluator.stringify(value); + final int from = _intArg(start).clamp(0, text.length); + final int to = end == null ? text.length : _intArg(end).clamp(0, text.length); + return to <= from ? '' : text.substring(from, to); +} + +/// [start] and [end] as bounds of something [length] long, counting +/// negative values from the end, like JavaScript's `slice`. +(int, int) _bounds(int length, Object? start, Object? end) { + int bound(Object? value, int fallback) { + if (value == null) return fallback; + final int i = _intArg(value); + return (i < 0 ? length + i : i).clamp(0, length); + } + + final int from = bound(start, 0); + return (from, math.max(from, bound(end, length))); +} + +/// The items of a list, or characters of a text, from [start] up to [end]. +Object? _slice(Object? value, Object? start, Object? end) { + if (value is String) { + final (int from, int to) = _bounds(value.length, start, end); + return value.substring(from, to); + } + final List items = _values(value, null).toList(); + final (int from, int to) = _bounds(items.length, start, end); + return items.sublist(from, to); +} + +/// [value], a list or text, reversed. +Object? _reverse(Object? value) => value is String + ? value.split('').reversed.join() + : _values(value, null).toList().reversed.toList(); + +/// Lowercase [value] with every run of other characters than letters and +/// digits turned into one dash, e.g. "Kind of Blue!" is "kind-of-blue". +String _slug(Object? value) => + FormulaEvaluator.stringify(value) + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '-') + .replaceAll(RegExp(r'^-+|-+$'), ''); + +/// Seconds in a duration written `m:ss` or `h:mm:ss` (or plain seconds), +/// e.g. "3:45" is 225. Null when it isn't one. +int? _seconds(Object? value) { + if (value is num) return value.isFinite ? value.round() : null; + if (value is! String) return null; + String text = value.trim(); + final bool negative = text.startsWith('-'); + if (negative) text = text.substring(1); + final List parts = text.split(':'); + if (parts.length > 3 || parts.any((p) => !RegExp(r'^\d+$').hasMatch(p))) { + return null; + } + int total = 0; + for (int i = 0; i < parts.length; i++) { + final int n = int.parse(parts[i]); + if (i > 0 && (parts[i].length != 2 || n >= 60)) return null; + total = total * 60 + n; + } + return negative ? -total : total; +} + +/// [value] seconds written `m:ss`, or `h:mm:ss` from an hour, e.g. 225 is +/// "3:45" and 3725 is "1:02:05". Text such as "3:45" is read with [_seconds]. +String? _duration(Object? value) { + if (value == null) return null; + final num? total = value is String && value.contains(':') + ? _seconds(value) + : _toNum(value); + if (total == null || !total.isFinite) return null; + final int all = total.abs().round(); + String two(int n) => n.toString().padLeft(2, '0'); + final String sign = total < 0 && all > 0 ? '-' : ''; + final int hours = all ~/ 3600; + final int minutes = all % 3600 ~/ 60; + return hours > 0 + ? '$sign$hours:${two(minutes)}:${two(all % 60)}' + : '$sign$minutes:${two(all % 60)}'; +} + +/// The relative luminance of a color, from 0 (black) to 1 (white), as WCAG +/// defines it. Reads `#RGB`, `#RRGGBB` and `#AARRGGBB` text (the `#` is +/// optional) and `{r, g, b}` objects with channels from 0 to 1. Null when +/// [value] isn't a color. +num? _luminance(Object? value) { + final List channels; + if (value is Map) { + final List rgb = [ + for (final key in ['r', 'g', 'b']) _toNum(value[key]), + ]; + if (rgb.any((c) => c == null)) return null; + channels = [for (final c in rgb) c!.toDouble().clamp(0, 1)]; + } else { + String hex = FormulaEvaluator.stringify(value).trim(); + if (hex.startsWith('#')) hex = hex.substring(1); + if (hex.length == 3) hex = hex.split('').map((c) => c * 2).join(); + if (hex.length == 8) hex = hex.substring(2); + if (!RegExp(r'^[0-9a-fA-F]{6}$').hasMatch(hex)) return null; + final int rgb = int.parse(hex, radix: 16); + channels = [ + for (final shift in [16, 8, 0]) ((rgb >> shift) & 0xff) / 255, + ]; + } + final List linear = [ + for (final c in channels) + c <= 0.03928 ? c / 12.92 : math.pow((c + 0.055) / 1.055, 2.4).toDouble(), + ]; + return _round( + 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2], + 4, + ); +} + +/// The index of [value] in a text or list, or -1 when it isn't there. +int _indexOf(Object? target, Object? value) => switch (target) { + final String s => s.indexOf(FormulaEvaluator.stringify(value)), + final Iterable l => l.toList().indexWhere((v) => _looseEquals(v, value)), + _ => -1, +}; + +/// The objects [maps] combined into a new one, later keys winning. Values +/// that aren't objects are skipped. +Map _merge(Iterable maps) => { + for (final map in maps) + if (map is Map) + for (final MapEntry(:key, :value) in map.entries) key.toString(): value, +}; + +/// A copy of [target] with [key] set to [value]: an object's field, or a +/// list's item at an index (an index out of range changes nothing). +Object? _set(Object? target, Object? key, Object? value) { + if (target is List) { + final num? index = _toNum(key); + if (index == null || index < 0 || index >= target.length) { + return [...target]; + } + return [...target]..[index.toInt()] = value; + } + return { + ..._merge([target]), + key.toString(): value, + }; +} + num _round(Object? value, Object? digits) { final num v = _num(value); final int d = _num(digits ?? 0).toInt(); @@ -464,18 +741,20 @@ num _round(Object? value, Object? digits) { /// Values of [list], or of each item's [selector] when given: a field name /// such as `'price'` or `'author.name'`, or a lambda such as `t => t.price`. +/// A lambda gets each item's index as its second parameter. Iterable _values(Object? list, Object? selector) { if (list is! Iterable) return const []; if (selector == null) return list; - return list.map((item) => _select(item, selector)); + return list.indexed.map((entry) => _select(entry.$2, selector, entry.$1)); } /// [item]'s value for [selector]: the item itself when null, the lambda's -/// result for a lambda, else the field [selector] names. A field name with -/// dots, such as `author.name`, reads nested fields. -Object? _select(Object? item, Object? selector) { +/// result for a lambda (given the item and its [index]), else the field +/// [selector] names. A field name with dots, such as `author.name`, reads +/// nested fields. +Object? _select(Object? item, Object? selector, [int? index]) { if (selector == null) return item; - if (selector is _Closure) return selector(item); + if (selector is _Closure) return selector(item, index); final Object? value = _member(item, selector); if (value != null || selector is! String || !selector.contains('.')) { return value; @@ -486,37 +765,76 @@ Object? _select(Object? item, Object? selector) { /// Whether [item] matches a predicate given as function arguments [rest]: /// `(lambda)` or `(field)` match when truthy, `(lambda or field, value)` /// when the selected value equals value. No predicate matches truthy items. -bool _matches(Object? item, List rest) => rest.length < 2 - ? _truthy(_select(item, _arg(rest, 0))) - : _looseEquals(_select(item, rest[0]), rest[1]); +bool _matches(Object? item, List rest, [int? index]) => rest.length < 2 + ? _truthy(_select(item, _arg(rest, 0), index)) + : _looseEquals(_select(item, rest[0], index), rest[1]); + +/// Items of `args[0]` with their indexes, in order. +Iterable<(int, Object?)> _indexed(List args) => + _values(_arg(args, 0), null).indexed; /// Items of `args[0]` that match the predicate in the rest of [args]. Iterable _matching(List args) { final List rest = args.skip(1).toList(); - return _values(_arg(args, 0), null).where((item) => _matches(item, rest)); + return _indexed(args) + .where((entry) => _matches(entry.$2, rest, entry.$1)) + .map((e) => e.$2); } -/// Items of `args[0]` sorted by the key `args[1]`, descending when `args[2]` -/// is `'desc'`. Equal keys keep their order and missing keys go last. -List _sortBy(List args) { +/// The index of the first item of `args[0]` that matches the predicate in +/// the rest of [args], or -1. +int _findIndex(List args) { + final List rest = args.skip(1).toList(); + for (final (int i, Object? item) in _indexed(args)) { + if (_matches(item, rest, i)) return i; + } + return -1; +} + +/// Whether [value] is a sort order: true for `'desc'`, false for `'asc'`, +/// null for anything else. +bool? _sortOrder(Object? value) => switch (value) { + final String order => switch (order.trim().toLowerCase()) { + 'desc' || 'descending' => true, + 'asc' || 'ascending' => false, + _ => null, + }, + _ => null, +}; + +/// Items of `args[0]` sorted by the keys in the rest of [args], each a field +/// name or lambda; later keys break ties of earlier ones. A key is followed +/// by `'desc'` (or `'asc'`) to sort it descending, and every key is +/// descending by default when [descending]. `args[1]` is always a key, so +/// `sortBy(list, null, 'desc')` sorts plain values descending. Equal keys +/// keep their order and missing keys go last. +List _sortBy(List args, {bool descending = false}) { final List items = _values(_arg(args, 0), null).toList(); - final Object? selector = _arg(args, 1); - final bool descending = switch (_arg(args, 2)) { - final String order => order.trim().toLowerCase().startsWith('desc'), - _ => false, - }; - final List<(int, Object?, Object?)> keyed = [ + final List<(Object?, bool)> keys = [(_arg(args, 1), descending)]; + for (final arg in args.skip(2)) { + if (_sortOrder(arg) case final bool desc) { + keys.last = (keys.last.$1, desc); + } else { + keys.add((arg, descending)); + } + } + final List<(int, Object?, List)> keyed = [ for (int i = 0; i < items.length; i++) - (i, items[i], _select(items[i], selector)), + (i, items[i], [for (final key in keys) _select(items[i], key.$1, i)]), ]; keyed.sort((x, y) { - final bool xMissing = x.$3 == null || x.$3 == ''; - final bool yMissing = y.$3 == null || y.$3 == ''; - if (xMissing || yMissing) { - return xMissing == yMissing ? x.$1 - y.$1 : (xMissing ? 1 : -1); + for (int k = 0; k < keys.length; k++) { + final Object? a = x.$3[k]; + final Object? b = y.$3[k]; + final bool aMissing = a == null || a == ''; + final bool bMissing = b == null || b == ''; + if (aMissing || bMissing) { + if (aMissing == bMissing) continue; + return aMissing ? 1 : -1; + } + final int order = _compare(a, b) ?? 0; + if (order != 0) return keys[k].$2 ? -order : order; } - final int order = _compare(x.$3, y.$3) ?? 0; - if (order != 0) return descending ? -order : order; return x.$1 - y.$1; }); return [for (final entry in keyed) entry.$2]; @@ -527,8 +845,8 @@ List _sortBy(List args) { List _unique(List args) { final List keys = []; final List items = []; - for (final item in _values(_arg(args, 0), null)) { - final Object? key = _select(item, _arg(args, 1)); + for (final (int i, Object? item) in _indexed(args)) { + final Object? key = _select(item, _arg(args, 1), i); if (keys.any((seen) => _looseEquals(seen, key))) continue; keys.add(key); items.add(item); @@ -536,26 +854,34 @@ List _unique(List args) { return items; } -/// A lambda such as `t => t.price`, ready to call with an item. +/// A lambda such as `t => t.price` or `(t, i) => i + 1`, ready to call +/// with an item and its index. class _Closure { final String param; + final String? indexParam; final _Node body; final FormulaPathResolver outer; - const _Closure(this.param, this.body, this.outer); + const _Closure(this.param, this.indexParam, this.body, this.outer); static final RegExp _part = RegExp(r'\.([a-zA-Z_][a-zA-Z0-9_]*)|\[(\d+)\]'); - Object? call(Object? value) => body.eval((path) { - if (path == param) return value; - if (path.startsWith(param) && - (path.startsWith('.', param.length) || - path.startsWith('[', param.length))) { - Object? result = value; - for (final Match part in _part.allMatches(path, param.length)) { - result = _member(result, part[1] ?? int.parse(part[2]!)); + Object? call(Object? value, [int? index]) => body.eval((path) { + for (final (String? name, Object? bound) in [ + (param, value), + (indexParam, index), + ]) { + if (name == null) continue; + if (path == name) return bound; + if (path.startsWith(name) && + (path.startsWith('.', name.length) || + path.startsWith('[', name.length))) { + Object? result = bound; + for (final Match part in _part.allMatches(path, name.length)) { + result = _member(result, part[1] ?? int.parse(part[2]!)); + } + return result; } - return result; } return outer(path); }); @@ -787,7 +1113,7 @@ Object? _arg(List args, int index) => index < args.length ? args[index] : null; final Map _functions = { - 'money': (a) => _money(_arg(a, 0), _arg(a, 1)), + 'money': (a) => _money(_arg(a, 0), _arg(a, 1), _arg(a, 2)), 'fixed': (a) => _fixed(_arg(a, 0), _arg(a, 1)), 'round': (a) => _round(_arg(a, 0), _arg(a, 1)), 'floor': (a) => _num(_arg(a, 0)).floor(), @@ -820,6 +1146,34 @@ final Map _functions = { 'upper': (a) => FormulaEvaluator.stringify(_arg(a, 0)).toUpperCase(), 'lower': (a) => FormulaEvaluator.stringify(_arg(a, 0)).toLowerCase(), 'trim': (a) => FormulaEvaluator.stringify(_arg(a, 0)).trim(), + 'padLeft': (a) => _pad(_arg(a, 0), _arg(a, 1), _arg(a, 2), left: true), + 'padRight': (a) => _pad(_arg(a, 0), _arg(a, 1), _arg(a, 2), left: false), + 'substring': (a) => _substring(_arg(a, 0), _arg(a, 1), _arg(a, 2)), + 'replace': (a) { + final String text = FormulaEvaluator.stringify(_arg(a, 0)); + final String from = FormulaEvaluator.stringify(_arg(a, 1)); + return from.isEmpty + ? text + : text.replaceAll(from, FormulaEvaluator.stringify(_arg(a, 2))); + }, + 'split': (a) { + final String text = FormulaEvaluator.stringify(_arg(a, 0)); + if (text.isEmpty) return []; + return text.split(FormulaEvaluator.stringify(_arg(a, 1) ?? ',')); + }, + 'indexOf': (a) => _indexOf(_arg(a, 0), _arg(a, 1)), + 'matches': (a) { + try { + return RegExp(FormulaEvaluator.stringify(_arg(a, 1))) + .hasMatch(FormulaEvaluator.stringify(_arg(a, 0))); + } on FormatException { + return false; + } + }, + 'slug': (a) => _slug(_arg(a, 0)), + 'seconds': (a) => _seconds(_arg(a, 0)), + 'duration': (a) => _duration(_arg(a, 0)), + 'luminance': (a) => _luminance(_arg(a, 0)), 'startsWith': (a) => switch (_arg(a, 0)) { final String s => s.startsWith(FormulaEvaluator.stringify(_arg(a, 1))), _ => false, @@ -837,13 +1191,21 @@ final Map _functions = { 'where': (a) => _matching(a).toList(), 'filter': (a) => _matching(a).toList(), 'find': (a) => _matching(a).firstOrNull, + 'findIndex': _findIndex, 'any': (a) => _matching(a).isNotEmpty, 'all': (a) { final List rest = a.skip(1).toList(); - return _values(_arg(a, 0), null).every((item) => _matches(item, rest)); + return _indexed(a).every((entry) => _matches(entry.$2, rest, entry.$1)); }, 'map': (a) => _values(_arg(a, 0), _arg(a, 1)).toList(), 'sortBy': _sortBy, + 'sortByDesc': (a) => _sortBy(a, descending: true), + 'slice': (a) => _slice(_arg(a, 0), _arg(a, 1), _arg(a, 2)), + 'take': (a) => _slice(_arg(a, 0), 0, math.max(0, _intArg(_arg(a, 1)))), + 'skip': (a) => _slice(_arg(a, 0), math.max(0, _intArg(_arg(a, 1))), null), + 'reverse': (a) => _reverse(_arg(a, 0)), + 'merge': _merge, + 'set': (a) => _set(_arg(a, 0), _arg(a, 1), _arg(a, 2)), 'unique': _unique, 'without': (a) => [ for (final item in _values(_arg(a, 0), null)) @@ -972,34 +1334,57 @@ class _Call extends _Node { } } -/// A lambda argument, `param => body`. Evaluates to a [_Closure] that list -/// functions call once per item. +/// A lambda argument, `param => body` or `(param, index) => body`. +/// Evaluates to a [_Closure] that list functions call once per item. class _Lambda extends _Node { final String param; + final String? indexParam; final _Node body; - const _Lambda(this.param, this.body); + const _Lambda(this.param, this.indexParam, this.body); @override - Object? eval(FormulaPathResolver resolve) => _Closure(param, body, resolve); + Object? eval(FormulaPathResolver resolve) => + _Closure(param, indexParam, body, resolve); - /// The body's paths, except those of the parameter, which aren't + /// The body's paths, except those of the parameters, which aren't /// variables. @override void collectPaths(Set paths) { final Set bodyPaths = {}; body.collectPaths(bodyPaths); + bool isParam(String path, String? name) => + name != null && + (path == name || + path.startsWith('$name.') || + path.startsWith('$name[')); paths.addAll( bodyPaths.where( - (path) => - path != param && - !path.startsWith('$param.') && - !path.startsWith('$param['), + (path) => !isParam(path, param) && !isParam(path, indexParam), ), ); } } +/// An object literal, `{id: 'a', 'full name': n + 1}`. +class _MapLiteral extends _Node { + final List<(String, _Node)> entries; + + const _MapLiteral(this.entries); + + @override + Object? eval(FormulaPathResolver resolve) => { + for (final (String key, _Node value) in entries) key: value.eval(resolve), + }; + + @override + void collectPaths(Set paths) { + for (final (_, _Node value) in entries) { + value.collectPaths(paths); + } + } +} + class _Unary extends _Node { final String op; final _Node operand; @@ -1102,7 +1487,9 @@ class _Parser { static final RegExp _number = RegExp(r'\d+(\.\d+)?|\.\d+'); static final RegExp _identifier = RegExp(r'[a-zA-Z_][a-zA-Z0-9_]*'); static final RegExp _lambdaHead = RegExp( - r'(?:([a-zA-Z_][a-zA-Z0-9_]*)|\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\))\s*=>', + r'(?:([a-zA-Z_][a-zA-Z0-9_]*)' + r'|\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:,\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*)?\))' + r'\s*=>', ); _Node parseAll() { @@ -1137,13 +1524,14 @@ class _Parser { } /// A function argument: an expression, or a lambda `item => expression` - /// (also written `(item) => expression`). + /// (also written `(item) => expression`, or `(item, index) => expression` + /// to read the item's index). _Node _argument() { _skip(); final Match? head = _lambdaHead.matchAsPrefix(source, pos); if (head == null) return _ternary(); pos = head.end; - return _Lambda(head[1] ?? head[2]!, _ternary()); + return _Lambda(head[1] ?? head[2]!, head[3], _ternary()); } _Node _ternary() { @@ -1232,6 +1620,22 @@ class _Parser { } } + /// An object literal's key: a name or quoted text. + String _key() { + _skip(); + if (pos < source.length && (source[pos] == "'" || source[pos] == '"')) { + final int end = source.indexOf(source[pos], pos + 1); + if (end < 0) throw FormatException('Unclosed text', source, pos); + final String key = source.substring(pos + 1, end); + pos = end + 1; + return key; + } + final Match? name = _identifier.matchAsPrefix(source, pos); + if (name == null) throw FormatException('Expected a key', source, pos); + pos = name.end; + return name[0]!; + } + _Node _primary() { _skip(); if (pos >= source.length) { @@ -1258,6 +1662,20 @@ class _Parser { return _ListLiteral(items); } + if (char == '{') { + pos++; + final List<(String, _Node)> entries = []; + if (!_eat('}')) { + do { + final String key = _key(); + _expect(':'); + entries.add((key, _ternary())); + } while (_eat(',')); + _expect('}'); + } + return _MapLiteral(entries); + } + if (char == "'" || char == '"') { final int end = source.indexOf(char, pos + 1); if (end < 0) throw FormatException('Unclosed text', source, pos); diff --git a/test/design_canvas_list_items_test.dart b/test/design_canvas_list_items_test.dart new file mode 100644 index 00000000..bb0cc036 --- /dev/null +++ b/test/design_canvas_list_items_test.dart @@ -0,0 +1,152 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +const List> crateItems = [ + {'id': 'jazz', 'name': 'Jazz'}, + {'id': 'soul', 'name': 'Soul'}, + {'id': 'hiphop', 'name': 'Hip hop'}, +]; + +/// The crates a data-bound list shows, as a list variable's default value. +final VariableData crates = VariableData( + id: 'crates', + name: 'crates', + type: VariableType.list, + value: crateItems, +); + +/// What an editor's resolver gives a node inside the list: it knows only the +/// node, which every row shares, so its item is the list's first. +ScopedValues editorScope() => ScopedValues.preview( + variables: {'crates': crates}, + indexedItem: IndexedItem(0, crateItems.first), +); + +List crateList({bool stacked = false}) => [ + ListViewNode( + id: 'list', + name: 'list', + basicBoxLocal: NodeBox(0, 0, 300, 200), + properties: ListViewProperties(stacked: stacked), + variables: {'data': r'${crates}'}, + children: ['label'], + )..parentID = kRootNode, + TextNode( + id: 'label', + name: 'label', + basicBoxLocal: NodeBox(0, 0, 200, 24), + horizontalFit: SizeFit.shrinkWrap, + characters: r'${item.name}', + textMixedProps: [StartEndProp(start: 0, end: 12, fontSize: 16)], + )..parentID = 'list', +]; + +Future pumpCanvasList(WidgetTester tester, List nodes) async { + final registry = NodeRegistry() + ..setNodes({for (final node in nodes) node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + // The render canvas gives its screen the variable defaults. + ChangeNotifierProvider.value( + value: CodelesslyContext.empty().copyWith( + variables: {crates.id: Observable(crates)}, + ), + child: MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + nodes.first, + context, + settings: const WidgetBuildSettings( + debugLabel: 'design canvas', + isPreview: true, + withReactions: false, + ), + ), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); +} + +void main() { + tearDown(() => VariablePreview.scopeResolver = null); + + group('VariablePreview.scopeOf', () { + late BuildContext row; + Future pumpRow(WidgetTester tester, IndexedItem? item) async { + Widget child = Builder( + builder: (context) { + row = context; + return const SizedBox(); + }, + ); + if (item != null) child = IndexedItemProvider(item: item, child: child); + await tester.pumpWidget(child); + } + + testWidgets('gives a row its own item', (tester) async { + VariablePreview.scopeResolver = (_) => editorScope(); + await pumpRow(tester, IndexedItem(2, {'name': 'Hip hop'})); + final ScopedValues? values = VariablePreview.scopeOf(null, context: row); + expect(values?.indexedItem, IndexedItem(2, {'name': 'Hip hop'})); + // The canvas's variables stay. + expect(values?.variables.keys, contains('crates')); + }); + + testWidgets('keeps the resolver scope outside a list', (tester) async { + final ScopedValues scope = editorScope(); + VariablePreview.scopeResolver = (_) => scope; + await pumpRow(tester, null); + expect(VariablePreview.scopeOf(null, context: row), same(scope)); + expect(VariablePreview.scopeOf(null), same(scope)); + }); + + testWidgets('a row without data keeps the sample item at its own index', ( + tester, + ) async { + VariablePreview.scopeResolver = (_) => editorScope(); + await pumpRow(tester, IndexedItem(4, null)); + final IndexedItem? item = VariablePreview.scopeOf( + null, + context: row, + )?.indexedItem; + expect(item?.index, 4); + expect(item?.item, {'id': 'jazz', 'name': 'Jazz'}); + }); + + testWidgets('is null without a resolver', (tester) async { + await pumpRow(tester, IndexedItem(1, {'name': 'Soul'})); + expect(VariablePreview.scopeOf(null, context: row), isNull); + }); + }); + + group('a data-bound list on the design canvas', () { + testWidgets('previews each item in its own row', (tester) async { + VariablePreview.scopeResolver = (_) => editorScope(); + await pumpCanvasList(tester, crateList()); + expect(find.text('Jazz', findRichText: true), findsOne); + expect(find.text('Soul', findRichText: true), findsOne); + expect(find.text('Hip hop', findRichText: true), findsOne); + }); + + testWidgets('a stacked list previews each item too', (tester) async { + VariablePreview.scopeResolver = (_) => editorScope(); + await pumpCanvasList(tester, crateList(stacked: true)); + expect(find.text('Jazz', findRichText: true), findsOne); + expect(find.text('Soul', findRichText: true), findsOne); + expect(find.text('Hip hop', findRichText: true), findsOne); + }); + }); +} diff --git a/test/formula_strings_objects_test.dart b/test/formula_strings_objects_test.dart new file mode 100644 index 00000000..0fd4b288 --- /dev/null +++ b/test/formula_strings_objects_test.dart @@ -0,0 +1,367 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final List> records = [ + {'id': 'a', 'artist': 'Miles Davis', 'year': 1959, 'favorite': false}, + {'id': 'b', 'artist': 'Bill Evans', 'year': 1961, 'favorite': true}, + {'id': 'c', 'artist': 'Miles Davis', 'year': 1970, 'favorite': false}, + {'id': 'd', 'artist': 'Bill Evans', 'year': 1956, 'favorite': false}, + ]; + final List> tracks = [ + {'title': 'So What', 'length': '9:22'}, + {'title': 'Freddie Freeloader', 'length': '9:46'}, + {'title': 'Blue in Green', 'length': '5:37'}, + ]; + final Map vars = { + 'records': records, + 'tracks': tracks, + 'selected': 'c', + 'count': 4, + 'genres': ['rock', 'jazz', 'soul'], + 'record': records.first, + }; + + Object? resolve(String path) { + Object? value = vars; + for (final part in path.split('.')) { + value = value is Map ? value[part] : null; + } + return value; + } + + Object? eval(String expression) => + FormulaEvaluator.evaluate(expression, resolve); + + group('text', () { + test('padLeft and padRight', () { + expect(eval('padLeft(4, 2)'), '04'); + expect(eval('padLeft(count, 3)'), '004'); + expect(eval('padLeft(12, 2)'), '12'); + expect(eval('padLeft(-4, 3)'), '-04'); + expect(eval('padLeft("a", 3)'), ' a'); + expect(eval('padLeft(7, 3, "*")'), '**7'); + expect(eval('padRight("A", 3, ".")'), 'A..'); + expect(eval('padRight(5, 3)'), '500'); + }); + + test('substring clamps to the text', () { + expect(eval('substring("Vinyl", 0, 3)'), 'Vin'); + expect(eval('substring("Vinyl", 2)'), 'nyl'); + expect(eval('substring("Vinyl", 3, 99)'), 'yl'); + expect(eval('substring("Vinyl", 4, 2)'), ''); + expect(eval('substring(null, 0, 2)'), ''); + }); + + test('replace replaces every occurrence', () { + expect(eval('replace("a-b-c", "-", " ")'), 'a b c'); + expect(eval('replace("abc", "", "x")'), 'abc'); + expect(eval('replace("a.b", ".", "")'), 'ab'); + }); + + test('split', () { + expect(eval('split("rock, jazz", ", ")'), ['rock', 'jazz']); + expect(eval('split("a,b")'), ['a', 'b']); + expect(eval('split("", ",")'), isEmpty); + expect(eval('length(split("a b c", " "))'), 3); + }); + + test('indexOf on text and lists', () { + expect(eval('indexOf("hello", "l")'), 2); + expect(eval('indexOf("hello", "z")'), -1); + expect(eval('indexOf(genres, "jazz")'), 1); + expect(eval('indexOf(genres, "pop")'), -1); + expect(eval('indexOf([1, 2, 3], "3")'), 2); + expect(eval('indexOf(null, 1)'), -1); + }); + + test('matches tests a regular expression', () { + expect(eval(r'matches("2026", "^\d{4}$")'), isTrue); + expect(eval(r'matches("20x6", "^\d{4}$")'), isFalse); + expect(eval('matches("abc", "(")'), isFalse); + }); + + test('slug', () { + expect(eval('slug("Kind of Blue!")'), 'kind-of-blue'); + expect(eval('slug(" -- Miles Davis & Co. --")'), 'miles-davis-co'); + expect(eval('slug("")'), ''); + }); + }); + + group('durations', () { + test('seconds reads m:ss and h:mm:ss', () { + expect(eval('seconds("3:45")'), 225); + expect(eval('seconds("0:07")'), 7); + expect(eval('seconds("1:02:05")'), 3725); + expect(eval('seconds("75:00")'), 4500); + expect(eval('seconds("225")'), 225); + expect(eval('seconds(90)'), 90); + }); + + test('seconds is null when invalid', () { + expect(eval('seconds("3:75")'), isNull); + expect(eval('seconds("3:5")'), isNull); + expect(eval('seconds("abc")'), isNull); + expect(eval('seconds("")'), isNull); + expect(eval('seconds("1:2:3:4")'), isNull); + expect(eval('seconds(null)'), isNull); + }); + + test('duration writes m:ss and h:mm:ss', () { + expect(eval('duration(225)'), '3:45'); + expect(eval('duration(5)'), '0:05'); + expect(eval('duration(3600)'), '1:00:00'); + expect(eval('duration(3725)'), '1:02:05'); + expect(eval('duration(224.6)'), '3:45'); + expect(eval('duration("3:45")'), '3:45'); + expect(eval('duration(null)'), isNull); + expect(eval('duration("x")'), isNull); + }); + + test('total running time of tracks', () { + expect(eval('duration(sum(tracks, t => seconds(t.length)))'), '24:45'); + }); + }); + + group('lists', () { + test('slice, take, skip and reverse', () { + expect(eval('slice([1, 2, 3, 4], 1, 3)'), [2, 3]); + expect(eval('slice([1, 2, 3, 4], 2)'), [3, 4]); + expect(eval('slice([1, 2, 3, 4], -2)'), [3, 4]); + expect(eval('slice([1, 2, 3, 4], 3, 1)'), isEmpty); + expect(eval('slice("Vinyl", 1, -1)'), 'iny'); + expect(eval('take([1, 2, 3], 2)'), [1, 2]); + expect(eval('take([1, 2, 3], 10)'), [1, 2, 3]); + expect(eval('take([1, 2, 3], -1)'), isEmpty); + expect(eval('skip([1, 2, 3], 2)'), [3]); + expect(eval('skip([1, 2, 3], -1)'), [1, 2, 3]); + expect(eval('take("Vinyl", 3)'), 'Vin'); + expect(eval('skip(nothing, 1)'), isEmpty); + expect(eval('reverse([1, 2, 3])'), [3, 2, 1]); + expect(eval('reverse("abc")'), 'cba'); + }); + + test('findIndex', () { + expect(eval('findIndex(records, r => r.id == selected)'), 2); + expect(eval('findIndex(records, "favorite")'), 1); + expect(eval('findIndex(records, "artist", "Bill Evans")'), 1); + expect(eval('findIndex(records, r => r.year > 2000)'), -1); + expect(eval('findIndex(nothing, r => true)'), -1); + }); + + test('lambdas take an optional index', () { + expect(eval('map(genres, (g, i) => i + 1 + ". " + g)'), [ + '1. rock', + '2. jazz', + '3. soul', + ]); + expect(eval('filter(genres, (g, i) => i != 1)'), ['rock', 'soul']); + expect(eval('find(genres, (g, i) => i == 2)'), 'soul'); + expect(eval('any(genres, (g, i) => i > 5)'), isFalse); + expect(eval('all(genres, (g, i) => i < 3)'), isTrue); + expect(eval('findIndex(genres, (g, i) => i > 0 && g == "soul")'), 2); + expect(eval('sum([10, 20, 30], (n, i) => n * i)'), 80); + expect(eval('count(genres, (g, i) => i < 2)'), 2); + expect(eval('join(genres, ", ", (g, i) => i)'), '0, 1, 2'); + // Single-parameter lambdas still work. + expect(eval('map(genres, g => upper(g))'), ['ROCK', 'JAZZ', 'SOUL']); + }); + + test('a running total', () { + expect( + eval( + 'map(tracks, (t, i) => duration(sum(take(tracks, i), ' + 'x => seconds(x.length))))', + ), + ['0:00', '9:22', '19:08'], + ); + }); + + test('index parameters are not variables', () { + expect( + FormulaEvaluator.referencedPaths('map(genres, (g, i) => i + g + n)'), + {'genres', 'n'}, + ); + }); + + test('sortBy takes several keys and directions', () { + List ids(String expression) => + (eval(expression) as List).map((r) => (r as Map)['id']).toList(); + expect(ids('sortBy(records, r => r.artist, r => r.year)'), [ + 'd', + 'b', + 'a', + 'c', + ]); + expect(ids('sortBy(records, "artist", "year", "desc")'), [ + 'b', + 'd', + 'c', + 'a', + ]); + expect(ids('sortBy(records, "artist", "desc", "year")'), [ + 'a', + 'c', + 'd', + 'b', + ]); + expect(ids('sortByDesc(records, "year")'), ['c', 'b', 'a', 'd']); + expect(ids('sortByDesc(records, "artist", "year", "asc")'), [ + 'a', + 'c', + 'd', + 'b', + ]); + expect(ids('sortBy(records, r => r.year, "desc")'), [ + 'c', + 'b', + 'a', + 'd', + ]); + expect(eval('sortBy([2, 3, 1], null, "desc")'), [3, 2, 1]); + expect(eval('sortByDesc([2, 3, 1])'), [3, 2, 1]); + }); + }); + + group('objects', () { + test('object and list literals', () { + expect(eval("{id: 'a', n: 1 + 2}"), {'id': 'a', 'n': 3}); + expect(eval("{'full name': 'Bill', \"b\": [1, {c: true}]}"), { + 'full name': 'Bill', + 'b': [ + 1, + {'c': true}, + ], + }); + expect(eval('{}'), {}); + expect(eval('{a: count > 2 ? "many" : "few"}.a'), 'many'); + expect(eval('{a: 1}["a"]'), 1); + expect(eval('length([1, 2, 3])'), 3); + expect(FormulaEvaluator.errorOf('{a 1}'), isNotNull); + expect(FormulaEvaluator.referencedPaths('{a: count, b: x.y}'), { + 'count', + 'x.y', + }); + }); + + test('merge and set return new objects', () { + expect(eval('merge(record, {favorite: true, rating: 5})'), { + ...records.first, + 'favorite': true, + 'rating': 5, + }); + expect(records.first['favorite'], isFalse); + expect(eval('merge({a: 1}, {b: 2}, {a: 3})'), {'a': 3, 'b': 2}); + expect(eval('merge(null, {a: 1})'), {'a': 1}); + expect(eval('set(record, "year", 2000)')?.toString(), contains('2000')); + expect(records.first['year'], 1959); + expect(eval('set({a: 1}, "b", 2)'), {'a': 1, 'b': 2}); + expect(eval('set([1, 2, 3], 1, 9)'), [1, 9, 3]); + expect(eval('set([1, 2, 3], 5, 9)'), [1, 2, 3]); + }); + + test('toggling a favorite in a list', () { + final Object? result = eval( + 'map(records, r => r.id == selected ' + '? merge(r, {favorite: !r.favorite}) : r)', + ); + expect(result, isA()); + final List list = result as List; + expect(list.map((r) => (r as Map)['favorite']), [ + false, + true, + true, + false, + ]); + expect(identical(list[0], records[0]), isTrue); + expect(records[2]['favorite'], isFalse); + }); + + test('formulas with braces work in text', () { + expect( + FormulaEvaluator.substitute( + r'${= count(map(records, r => r.id == selected ? merge(r, {favorite: !r.favorite}) : r), "favorite")} favorites', + resolve, + ), + '2 favorites', + ); + expect( + FormulaEvaluator.exactExpression(r'${= merge({a: {b: {c: 1}}}, {})}'), + ' merge({a: {b: {c: 1}}}, {})', + ); + expect( + FormulaEvaluator.substitute(r'${= "}" + "{"} and ${= 1 + 1}', resolve), + '}{ and 2', + ); + expect( + FormulaEvaluator.referencedPathsInText( + r'Hi ${= merge(record, {n: count}).n}', + ), + {'record', 'count'}, + ); + }); + }); + + group('money and luminance', () { + test('money keeps its default and takes decimals', () { + expect(eval('money(38)'), r'$38.00'); + expect(eval('money(38, 0)'), r'$38'); + expect(eval('money(38.5, 0)'), r'$39'); + expect(eval('money(1234.5, 1)'), r'$1,234.5'); + expect(eval("money(38, '€', 0)"), '€38'); + expect(eval("money(38, null, 'auto')"), r'$38'); + expect(eval("money(38.5, '\$', 'auto')"), r'$38.50'); + expect(eval("money(-1200, '£', 'auto')"), '-£1,200'); + expect(eval("money(-3, '€')"), '-€3.00'); + }); + + test('luminance', () { + expect(eval('luminance("#FFFFFF")'), 1); + expect(eval('luminance("#000000")'), 0); + expect(eval('luminance("#FF000000")'), 0); + expect(eval('luminance("#80FFFFFF")'), 1); + expect(eval('luminance("#fff")'), 1); + expect(eval('luminance("#FF0000")'), 0.2126); + expect(eval('luminance({r: 0, g: 1, b: 0})'), 0.7152); + expect(eval('luminance("#777777")'), closeTo(0.1845, 0.0001)); + expect(eval('luminance("nope")'), isNull); + expect( + eval('luminance("#1A1A2E") > 0.5 ? "#000000" : "#FFFFFF"'), + '#FFFFFF', + ); + }); + }); + + test('new functions are documented and their examples parse', () { + final Set documented = { + for (final doc in FormulaEvaluator.functionDocs) doc.name, + }; + for (final name in [ + 'padLeft', + 'padRight', + 'substring', + 'replace', + 'split', + 'indexOf', + 'matches', + 'slug', + 'seconds', + 'duration', + 'slice', + 'take', + 'skip', + 'findIndex', + 'reverse', + 'sortByDesc', + 'merge', + 'set', + 'luminance', + ]) { + expect(documented, contains(name)); + expect(FormulaEvaluator.functions, contains(name)); + } + for (final name in FormulaEvaluator.functions) { + expect(documented, contains(name), reason: name); + } + }); +} diff --git a/test/style_bindings_test.dart b/test/style_bindings_test.dart new file mode 100644 index 00000000..62932817 --- /dev/null +++ b/test/style_bindings_test.dart @@ -0,0 +1,349 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +/// A chip-like rectangle whose hard shadow is hidden until it is selected. +RectangleNode chip(Map variables) => RectangleNode.fromJson({ + ...RectangleNode( + id: 'chip', + name: 'Chip', + basicBoxLocal: NodeBox(0, 0, 80, 32), + fills: [PaintModel.solid(id: 'fill', color: ColorRGB.fromHex('#FBF8F3'))], + strokes: [ + PaintModel.solid(id: 'stroke', color: ColorRGB.fromHex('#2B2420')), + ], + strokeWeight: 1, + cornerRadius: const CornerRadius.all(RadiusModel.circular(4)), + effects: [ + Effect.dropShadow( + id: 'shadow', + visible: false, + radius: 6, + color: ColorRGBA.black, + offset: const Vec(0, 4), + spread: 0, + ), + ], + ).toJson(), + 'variables': variables, +})..parentID = kRootNode; + +Observable boolVariable(String name, bool value) => Observable( + VariableData(id: name, name: name, type: VariableType.boolean, value: value), +); + +class Host { + Host(this.node, {Map> variables = const {}}) + : context = CodelesslyContext.empty()..variables.addAll(variables) { + registry.setNodes({node.id: node}); + manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + } + + final BaseNode node; + final CodelesslyContext context; + final NodeRegistry registry = NodeRegistry(); + late final PassiveNodeTransformerManager manager; + + late WidgetTester _tester; + + /// The node as its bindings style it now, after [pump]. + BaseNode get styled => manager.applyStyleOverrides( + _tester.element(find.byKey(const Key('host'))), + node, + ); + + /// Pumps the node's widget, or only a host to read [styled] in when + /// [build] is false (an icon loads its SVG from the network). + Future pump(WidgetTester tester, {bool build = true}) async { + _tester = tester; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: context, + child: MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + key: const Key('host'), + builder: (context) => build + ? manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ) + : const SizedBox(), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + } +} + +List shadows(WidgetTester tester) => tester.allWidgets + .whereType() + .map((c) => c.decoration) + .whereType() + .expand((d) => d.boxShadow ?? const []) + .toList(); + +Border? border(WidgetTester tester) => tester.allWidgets + .whereType() + .map((box) => box.decoration) + .whereType() + .map((d) => d.border) + .whereType() + .firstOrNull; + +void main() { + group('effects', () { + testWidgets('effectsVisible shows a hidden shadow, and the shadow takes ' + 'the color, offset and blur bound to it', (tester) async { + final selected = boolVariable('selected', false); + final host = Host( + chip({ + 'effectsVisible': r'${= selected}', + 'effectsColor': r"${= selected ? '#2B2420' : '#000000'}", + 'effectsOffsetX': r'${= selected ? 2 : 0}', + 'effectsOffsetY': r'${= selected ? 2 : 0}', + 'effectsBlur': r'${= selected ? 0 : 6}', + }), + variables: {'selected': selected}, + ); + await host.pump(tester); + expect(shadows(tester), isEmpty); + + selected.set(selected.value.copyWith(value: true)); + await tester.pump(); + + final List painted = shadows(tester); + expect(painted, hasLength(1)); + expect(painted.single.color.toARGB32(), 0xFF2B2420); + expect(painted.single.offset, const Offset(2, 2)); + expect(painted.single.blurRadius, 0); + // The node itself is unchanged. + expect((host.node as RectangleNode).effects.single.visible, isFalse); + expect( + (host.node as RectangleNode).effects.single.offset, + const Vec(0, 4), + ); + }); + + testWidgets('effect-visible- and shadow-color- win over the ' + 'node-wide bindings', (tester) async { + final host = Host( + chip({ + 'effectsVisible': r'${= false}', + 'effect-visible-shadow': r'${= true}', + 'effectsColor': r"${= '#FF0000'}", + 'shadow-color-shadow': r"${= '#00FF00'}", + }), + ); + await host.pump(tester); + final List painted = shadows(tester); + expect(painted, hasLength(1)); + expect(painted.single.color.toARGB32(), 0xFF00FF00); + // Unbound values stay. + expect(painted.single.offset, const Offset(0, 4)); + expect(painted.single.blurRadius, 6); + }); + + testWidgets('effectsVisible hides a visible shadow', (tester) async { + final RectangleNode node = chip({'effectsVisible': r'${= 1 > 2}'}); + node.effects = [node.effects.single.copyWith(visible: true)]; + final host = Host(node); + await host.pump(tester); + expect(shadows(tester), isEmpty); + }); + + testWidgets('A node without effect bindings is not restyled', ( + tester, + ) async { + final host = Host(chip({})); + await host.pump(tester); + expect(identical(host.styled, host.node), isTrue); + }); + }); + + testWidgets('strokeWeight follows its formula', (tester) async { + final selected = boolVariable('selected', false); + final host = Host( + chip({'strokeWeight': r'${= selected ? 3 : 1}'}), + variables: {'selected': selected}, + ); + await host.pump(tester); + expect(border(tester)?.top.width, 1); + + selected.set(selected.value.copyWith(value: true)); + await tester.pump(); + expect(border(tester)?.top.width, 3); + expect((host.node as RectangleNode).strokeWeight, 1); + }); + + testWidgets('cornerRadius follows its formula', (tester) async { + final host = Host(chip({'cornerRadius': r'${= 8 * 2}'})); + await host.pump(tester); + final BorderRadius? radius = tester.allWidgets + .whereType() + .map((c) => c.decoration) + .whereType() + .map((d) => d.borderRadius) + .whereType() + .firstOrNull; + expect(radius, BorderRadius.circular(16)); + expect( + (host.styled as RectangleNode).cornerRadius, + const CornerRadius.all(RadiusModel.circular(16), linked: true), + ); + }); + + group('text', () { + TextNode text(Map variables) => TextNode.fromJson({ + ...TextNode( + id: 'text', + name: 'Text', + basicBoxLocal: NodeBox(0, 0, 100, 20), + characters: 'Oat', + textMixedProps: [ + StartEndProp( + start: 0, + end: 3, + fills: [ + PaintModel.solid( + id: 'text-fill', + color: ColorRGB.fromHex('#111111'), + ), + ], + fontName: FontName.robotoRegular, + ), + ], + ).toJson(), + 'variables': variables, + })..parentID = kRootNode; + + testWidgets('fontWeight and fill both follow their formulas', ( + tester, + ) async { + final selected = boolVariable('selected', false); + final host = Host( + text({ + 'fontWeight': r'${= selected ? 700 : 400}', + 'fill': r"${= selected ? '#C8643B' : '#111111'}", + }), + variables: {'selected': selected}, + ); + await host.pump(tester); + expect( + (host.styled as TextNode).textMixedProps.single.fontName.weight, + FontWeightNumeric.w400, + ); + + selected.set(selected.value.copyWith(value: true)); + await tester.pump(); + final StartEndProp span = (host.styled as TextNode).textMixedProps.single; + expect(span.fontName.weight, FontWeightNumeric.w700); + expect(span.fontName.family, 'Roboto'); + expect(span.fills.single.color, ColorRGB.fromHex('#C8643B')); + + final List weights = []; + for (final RichText text in tester.widgetList( + find.byType(RichText), + )) { + text.text.visitChildren((span) { + weights.add(span.style?.fontWeight); + return true; + }); + } + expect(weights, contains(FontWeight.w700)); + expect(weights, isNot(contains(FontWeight.w400))); + }); + + test('parseFontWeight reads numbers, wNNN and names', () { + expect(StyleBindings.parseFontWeight(700), FontWeightNumeric.w700); + expect(StyleBindings.parseFontWeight(650.0), FontWeightNumeric.w700); + expect(StyleBindings.parseFontWeight('400'), FontWeightNumeric.w400); + expect(StyleBindings.parseFontWeight('w600'), FontWeightNumeric.w600); + expect(StyleBindings.parseFontWeight('Bold'), FontWeightNumeric.w700); + expect( + StyleBindings.parseFontWeight('semi-bold'), + FontWeightNumeric.w600, + ); + expect(StyleBindings.parseFontWeight(1200), FontWeightNumeric.w900); + expect(StyleBindings.parseFontWeight('heavyish'), isNull); + expect(StyleBindings.parseFontWeight(null), isNull); + }); + }); + + group('icon', () { + IconNode icon(IconModel model, Map variables) => + IconNode( + id: 'icon', + name: 'Icon', + basicBoxLocal: NodeBox(0, 0, 24, 24), + icon: model, + ) + ..variables.addAll(variables) + ..parentID = kRootNode; + + testWidgets('A Material icon shows the icon name its formula gives', ( + tester, + ) async { + final selected = boolVariable('selected', false); + final host = Host( + icon( + const MaterialIcon( + name: 'star_border', + codepoint: 0xe83a, + style: MaterialIconStyle.filled, + version: 19, + ), + {'icon': r"${= selected ? 'star' : 'star_border'}"}, + ), + variables: {'selected': selected}, + ); + await host.pump(tester, build: false); + expect(identical(host.styled, host.node), isTrue); + + selected.set(selected.value.copyWith(value: true)); + await tester.pump(); + final IconNode styled = host.styled as IconNode; + final MaterialIcon shown = styled.properties.icon.icon! as MaterialIcon; + expect(shown.name, 'star'); + expect(shown.style, MaterialIconStyle.filled); + expect(shown.version, 1); + expect( + shown.toSvgUrl(), + 'https://fonts.gstatic.com/s/i/materialicons/star/v1/24px.svg', + ); + expect((host.node as IconNode).properties.icon.icon!.name, 'star_border'); + }); + + testWidgets('A Material Symbol keeps its style when renamed', ( + tester, + ) async { + final host = Host( + icon( + MaterialSymbol( + name: 'favorite', + codepoint: 0xe87d, + fontFamily: 'MaterialSymbolsRounded', + style: MaterialSymbolStyle.rounded, + ), + {'icon': r"${= 'home'}"}, + ), + ); + await host.pump(tester, build: false); + final MaterialSymbol shown = + (host.styled as IconNode).properties.icon.icon! as MaterialSymbol; + expect(shown.name, 'home'); + expect(shown.style, MaterialSymbolStyle.rounded); + }); + }); +} diff --git a/test/wrap_list_test.dart b/test/wrap_list_test.dart new file mode 100644 index 00000000..256aa899 --- /dev/null +++ b/test/wrap_list_test.dart @@ -0,0 +1,222 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +/// A list of [itemCount] 100x40 rectangles wrapping along [scrollDirection]. +List wrapList({ + AxisC scrollDirection = AxisC.horizontal, + SizeFit horizontalFit = SizeFit.expanded, + SizeFit verticalFit = SizeFit.shrinkWrap, + double width = 300, + double height = 40, + int itemCount = 5, + bool stacked = false, + SizeFit itemHorizontalFit = SizeFit.fixed, +}) => [ + ListViewNode( + id: 'list', + name: 'list', + basicBoxLocal: NodeBox(0, 0, width, height), + horizontalFit: horizontalFit, + verticalFit: verticalFit, + scrollDirection: scrollDirection, + padding: const EdgeInsetsModel.all(10), + properties: ListViewProperties( + itemCount: itemCount, + wrap: true, + stacked: stacked, + wrapSpacing: 8, + wrapRunSpacing: 6, + // Separators are not drawn in a wrapping list. + hasSeparator: true, + separatorSpacing: 50, + ), + children: ['item'], + )..parentID = kRootNode, + RectangleNode( + id: 'item', + name: 'item', + basicBoxLocal: NodeBox(0, 0, 100, 40), + horizontalFit: itemHorizontalFit, + )..parentID = 'list', +]; + +Finder items() => + find.byKey(const ValueKey('Rectangle Transformer of item [item]')); + +Future pump( + WidgetTester tester, + List nodes, { + Size screen = const Size(390, 844), + bool inScrollView = false, + CodelesslyContext? codelesslyContext, +}) async { + tester.view.physicalSize = screen; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final registry = NodeRegistry() + ..setNodes({for (final node in nodes) node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final Widget node = Builder( + builder: (context) => manager.buildWidgetFromNode( + nodes.first, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext ?? CodelesslyContext.empty(), + child: MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: inScrollView ? SingleChildScrollView(child: node) : node, + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); +} + +void main() { + testWidgets('a horizontal wrapping list flows onto further lines', ( + tester, + ) async { + await pump(tester, wrapList()); + expect(items(), findsNWidgets(5)); + final Offset list = tester.getTopLeft(find.byType(WrapNodeBox)); + final List rects = [ + for (int i = 0; i < 5; i++) tester.getRect(items().at(i)).shift(-list), + ]; + // 390 wide minus 20 of padding fits three 100-wide items with 8 gaps. + expect(rects.map((r) => r.topLeft), [ + const Offset(10, 10), + const Offset(118, 10), + const Offset(226, 10), + const Offset(10, 56), + const Offset(118, 56), + ]); + // Items keep their own size. + for (final r in rects) { + expect(r.size, const Size(100, 40)); + } + // The list grows with its runs: two lines, a run gap and the padding. + expect( + tester.getSize(find.byType(WrapNodeBox)), + const Size(390, 10 + 40 + 6 + 40 + 10), + ); + }); + + testWidgets('an expanded item takes its design size in a run', ( + tester, + ) async { + await pump(tester, wrapList(itemHorizontalFit: SizeFit.expanded)); + expect(tester.getSize(items().first), const Size(100, 40)); + expect( + tester.getTopLeft(items().at(3)).dy, + tester.getTopLeft(items().first).dy + 46, + ); + }); + + testWidgets('a wrapping list in a scroll view has no layout errors', ( + tester, + ) async { + await pump(tester, wrapList(), inScrollView: true); + expect(items(), findsNWidgets(5)); + expect(tester.getSize(find.byType(WrapNodeBox)).height, 106); + }); + + testWidgets('a fixed-height wrapping list scrolls its runs', (tester) async { + await pump(tester, wrapList(verticalFit: SizeFit.fixed, height: 60)); + expect(tester.getSize(find.byType(WrapNodeBox)), const Size(390, 60)); + expect( + find.descendant( + of: find.byType(WrappedItems), + matching: find.byType(SingleChildScrollView), + ), + findsOneWidget, + ); + expect(items(), findsNWidgets(5)); + }); + + testWidgets('a vertical wrapping list starts new columns', (tester) async { + await pump( + tester, + wrapList( + scrollDirection: AxisC.vertical, + horizontalFit: SizeFit.shrinkWrap, + verticalFit: SizeFit.fixed, + height: 120, + ), + ); + final Offset list = tester.getTopLeft(find.byType(WrapNodeBox)); + // 100 of height minus padding fits two 40-high items and a gap. + expect(tester.getTopLeft(items().at(1)) - list, const Offset(10, 58)); + expect(tester.getTopLeft(items().at(2)) - list, const Offset(116, 10)); + }); + + testWidgets('a wrapping list shows one item per item of its data', ( + tester, + ) async { + final List nodes = wrapList(); + final ListViewNode list = nodes.first as ListViewNode; + list.properties.itemCount = null; + list.variables['data'] = r'${crates}'; + await pump( + tester, + nodes, + codelesslyContext: CodelesslyContext.empty() + ..variables['crates'] = Observable( + VariableData( + id: 'crates', + name: 'crates', + type: VariableType.list, + value: ['a', 'b', 'c', 'd'], + ), + ), + ); + expect(items(), findsNWidgets(4)); + expect( + tester.getTopLeft(items().at(3)).dy, + tester.getTopLeft(items().first).dy + 46, + ); + }); + + testWidgets('stacked wins over wrap', (tester) async { + await pump(tester, wrapList(stacked: true, height: 100)); + expect(find.byType(StackedItems), findsOneWidget); + expect(find.byType(WrappedItems), findsNothing); + }); + + testWidgets('WrappedItems keeps item order and alignment', (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 250, + child: WrappedItems( + itemCount: 3, + spacing: 10, + alignment: WrapAlignment.center, + itemBuilder: (context, index) => + SizedBox(width: 100, height: 20, child: Text('$index')), + ), + ), + ), + ), + ); + // Two items (210) fit a 250 line, centred: 20 on each side. + expect(tester.getTopLeft(find.text('0')), const Offset(20, 0)); + expect(tester.getTopLeft(find.text('1')), const Offset(130, 0)); + expect(tester.getTopLeft(find.text('2')), const Offset(75, 20)); + }); +} From 507da7c86cf8114f9f53b776e47e7723e46fb62a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:41:45 +0000 Subject: [PATCH 04/12] Computed variables, motion loops and entrances, formula-safe text split - ComputedVariables keeps formula variables up to date from their inputs; Set variable leaves them alone. - NodeMotion loops, animates in on first show and honours delays. - Design canvas splits text by formulas before variables. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik --- lib/codelessly_sdk.dart | 1 + lib/src/functions/functions_repository.dart | 12 + .../passive_button_transformer.dart | 2 +- lib/src/transformers/utils/text_utils.dart | 2 +- lib/src/ui/codelessly_context.dart | 109 +++++++ lib/src/ui/codelessly_widget.dart | 2 + lib/src/ui/layout_builder.dart | 3 + lib/src/ui/node_motion.dart | 83 +++++- lib/src/utils/computed_variables.dart | 255 ++++++++++++++++ lib/src/utils/formula.dart | 7 + test/computed_variables_test.dart | 275 ++++++++++++++++++ test/formula_text_split_test.dart | 17 ++ test/node_motion_loop_test.dart | 147 ++++++++++ 13 files changed, 907 insertions(+), 8 deletions(-) create mode 100644 lib/src/utils/computed_variables.dart create mode 100644 test/computed_variables_test.dart create mode 100644 test/formula_text_split_test.dart create mode 100644 test/node_motion_loop_test.dart diff --git a/lib/codelessly_sdk.dart b/lib/codelessly_sdk.dart index 857c8ddb..29178651 100644 --- a/lib/codelessly_sdk.dart +++ b/lib/codelessly_sdk.dart @@ -47,6 +47,7 @@ export 'src/ui/skeleton.dart'; export 'src/ui/node_motion.dart'; export 'src/ui/swipe_detector.dart'; export 'src/ui/stripe_painter.dart'; +export 'src/utils/computed_variables.dart'; export 'src/utils/cupertino_icons_by_codepoint.dart'; export 'src/utils/extensions.dart'; export 'src/utils/flutter_icons_data_map.dart'; diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 868c1de8..bfa9f618 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -1139,6 +1139,16 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. name: action.variable.name, ); if (variableNotifier == null) return false; + // A computed variable's value comes from its formula: setting it does + // nothing. + if (variableNotifier.value.isComputed) { + DebugLogger.instance.printInfo( + 'Variable ${variableNotifier.value.name} is computed from a formula ' + 'and cannot be set.', + name: name, + ); + return false; + } String newValue = substituteNewValue( action.newValue, @@ -1202,6 +1212,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. final ValueNotifier? variableNotifier = payload.variables[id]; if (variableNotifier == null) return false; + if (variableNotifier.value.isComputed) return false; variableNotifier.value = variableNotifier.value.copyWith(value: value); @@ -1231,6 +1242,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. .findVariableByName(match.name); if (propertyNotifier == null) return false; + if (propertyNotifier.value.isComputed) return false; if (match.name != match.fullPath && (propertyNotifier.value.type == VariableType.map || diff --git a/lib/src/transformers/node_transformers/passive_button_transformer.dart b/lib/src/transformers/node_transformers/passive_button_transformer.dart index 62815cb6..394c988d 100644 --- a/lib/src/transformers/node_transformers/passive_button_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_button_transformer.dart @@ -130,7 +130,7 @@ class PassiveButtonWidget extends StatelessWidget { if (settings.replaceVariablesWithSymbols) { final List spans = buttonLabelTemplateOf(node) .splitMap( - variableSyntaxIdentifierRegex, + formulaOrVariableRegex, onMatch: (match) => VariableSpan(variable: match[0]!, node: node, context: context), onNonMatch: (text) => TextSpan(text: text), diff --git a/lib/src/transformers/utils/text_utils.dart b/lib/src/transformers/utils/text_utils.dart index 7ee6809a..f7d52519 100644 --- a/lib/src/transformers/utils/text_utils.dart +++ b/lib/src/transformers/utils/text_utils.dart @@ -99,7 +99,7 @@ class TextUtils { if (replaceVariableWithSymbol) { final spans = characters .splitMap( - variableSyntaxIdentifierRegex, + formulaOrVariableRegex, onMatch: (match) => VariableSpan( variable: match[0]!, node: node, diff --git a/lib/src/ui/codelessly_context.dart b/lib/src/ui/codelessly_context.dart index 2509b7a4..ff8c2392 100644 --- a/lib/src/ui/codelessly_context.dart +++ b/lib/src/ui/codelessly_context.dart @@ -4,6 +4,7 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import '../../codelessly_sdk.dart'; @@ -85,10 +86,112 @@ class CodelesslyContext with ChangeNotifier, Equatable { @override void dispose() { + unbindComputedVariables(); nodeRebuildNotifier.dispose(); super.dispose(); } + /// Listeners that keep computed variables up to date, by the notifier + /// they listen to. See [bindComputedVariables]. + final List<(Listenable, VoidCallback)> _computedListeners = []; + + /// The computed variables being evaluated, by id, to stop a cycle from + /// evaluating forever. + final Set _computing = {}; + + /// Computes the value of every computed variable (a variable with a + /// [VariableData.formula]) in [variables] and keeps it up to date: each is + /// computed again when a variable its formula reads changes, or when + /// [data] changes. Their values are cached in their notifiers, so bindings + /// read and listen to them like any other variable. + /// + /// Computed variables in a cycle hold an empty value. Call this again when + /// [variables] changes; the previous listeners are removed. + /// See [ComputedVariables]. + void bindComputedVariables() { + unbindComputedVariables(); + final Map byName = variableNamesMap(); + if (!byName.values.any((variable) => variable.isComputed)) return; + final Set cyclic = ComputedVariables.cyclicNames(byName); + // The variables as declared: a result can change a computed variable's + // type, but each evaluation starts from the declared one. + final Map declared = { + for (final VariableData variable in byName.values) + if (variable.isComputed) variable.name: variable, + }; + + final List recomputeAll = []; + final List> ordered = [ + for (final String name in ComputedVariables.evaluationOrder(byName)) + ?findVariableByName(name), + ]; + for (final Observable notifier in ordered) { + final VariableData variable = declared[notifier.value.name]!; + if (cyclic.contains(variable.name)) { + notifier.value = ComputedVariables.withResult(variable, null); + continue; + } + void recompute() => _recompute(notifier, variable); + recompute(); + recomputeAll.add(recompute); + // Listeners are added in evaluation order, so of two computed + // variables reading the same input, the one the other reads updates + // first. + for (final String input in ComputedVariables.inputsOf(variable)) { + final Observable? source = findVariableByName(input); + if (source == null || identical(source, notifier)) continue; + source.addListener(recompute); + _computedListeners.add((source, recompute)); + } + } + // Formulas can read `data`, which is set while widgets build: compute + // again after the frame, as setting a variable rebuilds its readers. + void onDataChanged() { + void run() { + for (final VoidCallback recompute in recomputeAll) { + recompute(); + } + } + + if (SchedulerBinding.instance.schedulerPhase == + SchedulerPhase.persistentCallbacks) { + SchedulerBinding.instance.addPostFrameCallback((_) => run()); + } else { + run(); + } + } + + addListener(onDataChanged); + _computedListeners.add((this, onDataChanged)); + } + + /// Removes the listeners [bindComputedVariables] added. + void unbindComputedVariables() { + for (final (Listenable listenable, VoidCallback listener) + in _computedListeners) { + listenable.removeListener(listener); + } + _computedListeners.clear(); + } + + void _recompute(Observable notifier, VariableData declared) { + if (!_computing.add(declared.id)) return; + try { + final Object? result = ComputedVariables.evaluate( + declared, + scopedValues: ScopedValues.preview( + variables: const {}, + codelesslyContext: this, + ), + ); + notifier.value = ComputedVariables.withResult(declared, result); + } catch (error) { + log('[CodelesslyContext] Computed variable ${declared.name}: $error'); + } finally { + _computing.remove(declared.id); + } + } + /// Returns a map of all of the [VariableData]s in [variables] mapped by their /// name. Map variableNamesMap() => @@ -218,12 +321,18 @@ class CodelesslyContext with ChangeNotifier, Equatable { /// Allows to easily [value] of a variable with a given [name]. /// Returns false if the variable does not exist. /// Returns true if the variable was updated successfully. + /// A computed variable (see [VariableData.formula]) can't be set: this + /// returns false and leaves it unchanged. bool updateVariable(String name, Object? value) { final ValueNotifier? variable = findVariableByName(name); if (variable == null) { log('[CodelesslyContext] Variable with name $name does not exist.'); return false; } + if (variable.value.isComputed) { + log('[CodelesslyContext] Variable $name is computed and read-only.'); + return false; + } final String newValue = value == null ? '' : '$value'; // If the value is the same, then the underlying value notifier will not diff --git a/lib/src/ui/codelessly_widget.dart b/lib/src/ui/codelessly_widget.dart index 2d1ee07a..41b651d3 100644 --- a/lib/src/ui/codelessly_widget.dart +++ b/lib/src/ui/codelessly_widget.dart @@ -394,6 +394,8 @@ class _CodelesslyWidgetState extends State { SchedulerBinding.instance.addPostFrameCallback((timeStamp) { if (!mounted) return; for (final notifier in codelesslyContext.variables.values) { + // Computed variables compute their value from the new data. + if (notifier.value.isComputed) continue; notifier.value = notifier.value.copyWith( value: widget.data[notifier.value.name], ); diff --git a/lib/src/ui/layout_builder.dart b/lib/src/ui/layout_builder.dart index 7c893eee..b935939d 100644 --- a/lib/src/ui/layout_builder.dart +++ b/lib/src/ui/layout_builder.dart @@ -289,6 +289,9 @@ class _CodelesslyLayoutBuilderState extends State { loadApisAndItsVariables(context); setCloudStorageVariables(context); + + // Computed variables read the variables loaded above. + codelesslyContext.bindComputedVariables(); } /// This loads all the apis for the layout as variables to be available diff --git a/lib/src/ui/node_motion.dart b/lib/src/ui/node_motion.dart index ba5c828b..c68c650c 100644 --- a/lib/src/ui/node_motion.dart +++ b/lib/src/ui/node_motion.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:codelessly_api/codelessly_api.dart'; @@ -72,31 +73,97 @@ class _NodeMotionState extends State with TickerProviderStateMixin { double _speed = 0; double _turns = 0; + /// Holds a transition back for [MotionModel.delayMs]; until it fires the + /// node stays where the transition starts. + Timer? _delay; + void _rebuild() => setState(() {}); + bool get _loops => + widget.animate && + widget.motion.loop != MotionLoop.none && + widget.motion.durationMs > 0; + @override void initState() { super.initState(); + final MotionModel motion = widget.motion; + if (_loops) { + _startLoop(); + } else if (widget.animate && motion.enter && motion.durationMs > 0) { + _from = motion.atRest; + _run(motion.durationMs); + } _syncSpin(); } + /// Animates from [_from] to [_to] over [ms], after the motion's delay. + void _run(int ms) { + _delay?.cancel(); + _transition + ..stop() + ..duration = Duration(milliseconds: ms) + ..value = 0; + void start() { + if (mounted) _transition.forward(from: 0); + } + + final int delay = widget.motion.delayMs; + if (delay > 0) { + _delay = Timer(Duration(milliseconds: delay), start); + } else { + start(); + } + } + + /// Repeats between rest and the motion's values. + void _startLoop() { + _delay?.cancel(); + _from = widget.motion.atRest; + _to = widget.motion; + _transition.duration = Duration(milliseconds: widget.motion.durationMs); + void start() { + if (!mounted) return; + _transition.repeat(reverse: widget.motion.loop == MotionLoop.reverse); + } + + final int delay = widget.motion.delayMs; + if (delay > 0) { + _transition.value = 0; + _delay = Timer(Duration(milliseconds: delay), start); + } else { + start(); + } + } + @override void didUpdateWidget(NodeMotion oldWidget) { super.didUpdateWidget(oldWidget); final MotionModel next = widget.motion; - if (!_sameTransform(next, _to)) { + if (_loops) { + final MotionModel old = oldWidget.motion; + if (!_sameTransform(next, _to) || + next.loop != old.loop || + next.durationMs != old.durationMs || + !_transition.isAnimating && _delay?.isActive != true) { + _startLoop(); + } else { + _to = next; + } + } else if (!_sameTransform(next, _to) || + oldWidget.motion.loop != MotionLoop.none) { final ms = widget.animate ? next.durationMs : 0; if (ms <= 0) { + _delay?.cancel(); _transition.stop(); + _transition.value = 0; _from = next; _to = next; } else { // Start from wherever the running transition has got to. _from = _current; _to = next; - _transition - ..duration = Duration(milliseconds: ms) - ..forward(from: 0); + _run(ms); } } else { // Settings that don't animate (duration, curve, spin) apply at once. @@ -118,8 +185,11 @@ class _NodeMotionState extends State with TickerProviderStateMixin { a.originX == b.originX && a.originY == b.originY; + /// Whether a transition is running or waiting out its delay. + bool get _moving => _transition.isAnimating || _delay?.isActive == true; + MotionModel get _current { - if (!_transition.isAnimating && _transition.value == 0) return _to; + if (!_moving && _transition.value == 0) return _to; final double t = flutterCurveOf(_to.curve).transform(_transition.value); double lerp(String p) => _from.valueOf(p) + (_to.valueOf(p) - _from.valueOf(p)) * t; @@ -171,6 +241,7 @@ class _NodeMotionState extends State with TickerProviderStateMixin { @override void dispose() { + _delay?.cancel(); _transition.dispose(); _spinTicker?.dispose(); super.dispose(); @@ -178,7 +249,7 @@ class _NodeMotionState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { - final MotionModel m = _transition.isAnimating ? _current : _to; + final MotionModel m = _moving ? _current : _to; if (!m.hasTransform && _turns == 0) return widget.child; Widget child = widget.child; if (m.opacity < 1) { diff --git a/lib/src/utils/computed_variables.dart b/lib/src/utils/computed_variables.dart new file mode 100644 index 00000000..de5f1a57 --- /dev/null +++ b/lib/src/utils/computed_variables.dart @@ -0,0 +1,255 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; + +import '../../codelessly_sdk.dart'; + +/// Evaluates computed (derived) variables: variables whose +/// [VariableData.formula] gives their value from other variables, e.g. +/// `visible = ${= sortBy(filter(records, r => r.crate == crateId), sortBy)}`. +/// +/// A computed variable is read like any other one, by name, in bindings and +/// formulas (`${= length(visible)}`, `${visible[0].title}`): its value is +/// materialized into the variable, so everything that reads variables reads +/// the formula's result. +/// +/// Semantics: +/// - The formula is written like a binding: `${= expression}`, a single +/// path such as `${records}`, or a bare expression (`length(records)`). +/// - It reads other variables, including other computed ones, and the +/// `data` passed to the layout. Item scoped values (`item`, `index`, +/// `value`) don't exist where a computed variable lives and read as null. +/// - Its value is cached in the variable and computed again only when a +/// variable it reads changes (see [CodelesslyContext]). +/// - Computed variables that read themselves, directly or through others, +/// form a cycle: they evaluate to null (an empty value) and don't update. +/// - The result keeps the variable's declared type when it fits it (a +/// whole number for an integer, a list for a list, ...), and otherwise +/// takes the type of the result, so a formula producing a list reads as a +/// list even if the variable was declared as text. +/// - They are read-only: Set variable actions, two-way bindings and +/// [CodelesslyContext.updateVariable] leave them unchanged and report +/// that nothing was set. +abstract final class ComputedVariables { + static final RegExp _exactPath = RegExp(r'^\$\{([^{}]+)\}$'); + + /// The single variable path of [formula] when it is exactly one `${path}`. + static String? _exactPathOf(String formula) { + final RegExpMatch? match = _exactPath.firstMatch(formula.trim()); + if (match == null) return null; + final String path = match.group(1)!.trim(); + if (path.startsWith('=')) return null; + return path; + } + + /// The formula expression of [formula]: the inside of `${= ...}`, or the + /// whole text for a bare expression. Null when [formula] is one variable + /// path, `${path}`. + static String? expressionOf(String formula) { + final String? exact = FormulaEvaluator.exactExpression(formula); + if (exact != null) return exact; + if (_exactPathOf(formula) != null) return null; + return formula.trim(); + } + + /// Why [formula] can't be evaluated, or null when it parses. + static String? errorOf(String formula) { + final String? expression = expressionOf(formula); + if (expression == null) return null; + return FormulaEvaluator.errorOf(expression); + } + + /// The names of the variables [variable]'s formula reads, e.g. `records` + /// and `crateId`. Empty for a normal variable. + static Set inputsOf(VariableData variable) { + final String? formula = variable.formula; + if (formula == null) return const {}; + final String? path = _exactPathOf(formula); + final Set paths = path != null + ? {path} + : FormulaEvaluator.referencedPaths(expressionOf(formula)!); + return { + for (final String path in paths) + if (VariableMatch.parse(path.wrapWithVariableSyntax()) + case final VariableMatch match) + match.name, + }; + } + + /// Evaluates [variable]'s formula, reading variables from [scopedValues]. + static Object? evaluate( + VariableData variable, { + required ScopedValues scopedValues, + }) { + final String? formula = variable.formula; + if (formula == null) return null; + final String? path = _exactPathOf(formula); + if (path != null) { + return PropertyValueDelegate.getVariableValueFromPath( + path, + scopedValues: scopedValues, + ); + } + return PropertyValueDelegate.evaluateFormula( + expressionOf(formula)!, + scopedValues: scopedValues, + ); + } + + /// [variable] holding [result] as its value. See [ComputedVariables] for + /// how the type is chosen. + static VariableData withResult(VariableData variable, Object? result) { + final VariableType type = result == null || _fits(result, variable.type) + ? variable.type + : _typeOf(result) ?? VariableType.text; + return _rebuild(variable, type: type, value: _encode(result)); + } + + /// The names of the computed variables in [variables] (by name) that read + /// themselves, directly or through other computed variables. + static Set cyclicNames(Map variables) { + final Set cyclic = {}; + final Map> edges = { + for (final VariableData variable in variables.values) + if (variable.isComputed) + variable.name: { + for (final String input in inputsOf(variable)) + if (variables[input]?.isComputed ?? false) input, + }, + }; + // A computed variable is cyclic when it can reach itself. + for (final String start in edges.keys) { + final Set seen = {}; + final List stack = [...edges[start]!]; + while (stack.isNotEmpty) { + final String name = stack.removeLast(); + if (name == start) { + cyclic.add(start); + break; + } + if (!seen.add(name)) continue; + stack.addAll(edges[name] ?? const {}); + } + } + return cyclic; + } + + /// The computed variables of [variables] (by name) ordered so that each + /// comes after the computed variables it reads. Cyclic ones come last. + static List evaluationOrder(Map variables) { + final Set cyclic = cyclicNames(variables); + final List order = []; + final Set visited = {}; + void visit(String name) { + if (!visited.add(name)) return; + final VariableData? variable = variables[name]; + if (variable == null || !variable.isComputed) return; + for (final String input in inputsOf(variable)) { + if (!cyclic.contains(input)) visit(input); + } + order.add(name); + } + + for (final VariableData variable in variables.values) { + if (variable.isComputed && !cyclic.contains(variable.name)) { + visit(variable.name); + } + } + return [...order, ...cyclic]; + } + + /// [variables] (by name) with every computed variable holding its + /// formula's result over the others, as a design canvas previews them with + /// default values. [data] is what the formulas read as `data`. + static Map resolveAll( + Map variables, { + Map? data, + }) { + if (!variables.values.any((variable) => variable.isComputed)) { + return variables; + } + final Set cyclic = cyclicNames(variables); + final Map resolved = {...variables}; + final ScopedValues scope = ScopedValues.preview( + variables: resolved, + data: data, + ); + for (final String name in evaluationOrder(variables)) { + final VariableData variable = resolved[name]!; + resolved[name] = withResult( + variable, + cyclic.contains(name) ? null : evaluate(variable, scopedValues: scope), + ); + } + return resolved; + } + + static bool _fits(Object result, VariableType type) => switch (type) { + VariableType.text => result is String || result is num || result is bool, + VariableType.image => result is String, + VariableType.integer => + result is num && result.isFinite && result == result.roundToDouble(), + VariableType.decimal => result is num, + VariableType.boolean => result is bool, + VariableType.color => result is String || result is ColorRGBA, + VariableType.list => result is List, + VariableType.map => result is Map, + }; + + static VariableType? _typeOf(Object result) { + try { + return VariableType.fromObjectType(result); + } catch (_) { + return null; + } + } + + static String _encode(Object? result) => switch (result) { + null => '', + String() => result, + num() || bool() => FormulaEvaluator.stringify(result), + DateTime() => result.toIso8601String(), + ColorRGBA() => result.toHex(), + List() || Map() => jsonEncode( + result, + toEncodable: (value) => + value is DateTime ? value.toIso8601String() : value.toString(), + ), + _ => result.toString(), + }; + + /// A copy of [variable] of the same class with [type] and [value]. + /// [VariableData.copyWith] keeps the old value when the new one doesn't + /// sanitize, so an empty result would not clear it. + static VariableData _rebuild( + VariableData variable, { + required VariableType type, + required String value, + }) => switch (variable) { + CanvasVariableData() => CanvasVariableData( + id: variable.id, + canvasId: variable.canvasId, + name: variable.name, + value: value, + type: type, + extra: variable.extra, + formula: variable.formula, + ), + RuntimeVariableData() => RuntimeVariableData( + id: variable.id, + name: variable.name, + value: value, + type: type, + extra: variable.extra, + formula: variable.formula, + ), + _ => VariableData( + id: variable.id, + name: variable.name, + value: value, + type: type, + extra: variable.extra, + formula: variable.formula, + ), + }; +} diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 41977a69..4dfd6e05 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'package:codelessly_api/codelessly_api.dart' show variableSyntaxIdentifierRegex; /// The body of a formula: text and quoted strings, with braces only in /// balanced pairs nested up to [depth] deep, such as the object literal in @@ -20,6 +21,12 @@ final RegExp formulaRegex = RegExp( r'(?' + _formulaBody(3) + r')\}', ); +/// Matches the formulas and variables of a text, formulas first so a +/// formula's own braces (`${= {a: 1}.a}`) don't end it early. +final RegExp formulaOrVariableRegex = RegExp( + '${formulaRegex.pattern}|${variableSyntaxIdentifierRegex.pattern}', +); + /// Matches a text that is exactly one formula, e.g. `${= qty * price}`. final RegExp exactFormulaRegex = RegExp( r'^\s*(?' + _formulaBody(3) + r')\}\s*$', diff --git a/test/computed_variables_test.dart b/test/computed_variables_test.dart new file mode 100644 index 00000000..3a26f903 --- /dev/null +++ b/test/computed_variables_test.dart @@ -0,0 +1,275 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +const String visibleFormula = + r"${= sortBy(filter(records, r => crateId == 'all' || r.crate == crateId), sortBy)}"; + +final List> records = [ + {'title': 'Banana', 'crate': 'b'}, + {'title': 'Apple', 'crate': 'a'}, + {'title': 'Cherry', 'crate': 'a'}, +]; + +CodelesslyContext recordsContext({List extra = const []}) { + final CodelesslyContext context = CodelesslyContext.empty(); + for (final VariableData variable in [ + VariableData( + id: 'records', + name: 'records', + type: VariableType.list, + value: records, + ), + VariableData(id: 'crateId', name: 'crateId', value: 'all'), + VariableData(id: 'sortBy', name: 'sortBy', value: 'title'), + VariableData( + id: 'visible', + name: 'visible', + type: VariableType.list, + formula: visibleFormula, + ), + VariableData( + id: 'count', + name: 'count', + type: VariableType.integer, + formula: r'${= length(visible)}', + ), + ...extra, + ]) { + context.variables[variable.id] = Observable(variable); + } + return context; +} + +ScopedValues scopeOf(CodelesslyContext context) => + ScopedValues.preview(variables: const {}, codelesslyContext: context); + +Object? read(String path, CodelesslyContext context) => + PropertyValueDelegate.getVariableValueFromPath( + path, + scopedValues: scopeOf(context), + ); + +void main() { + group('Computed variables', () { + test('bindings and formulas read a computed variable by name', () { + final CodelesslyContext context = recordsContext() + ..bindComputedVariables(); + expect(read(r'${visible[0].title}', context), 'Apple'); + expect(read(r'${= length(visible)}', context), 3); + expect(read(r'${= visible[2].title}', context), 'Cherry'); + // A computed variable reading another computed one. + expect(read(r'${count}', context), 3); + expect( + PropertyValueDelegate.substituteVariables( + r'${count} records, first ${visible[0].title}', + scopedValues: scopeOf(context), + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ), + '3 records, first Apple', + ); + }); + + test('it updates when an input changes and caches otherwise', () { + final CodelesslyContext context = recordsContext() + ..bindComputedVariables(); + final Observable visible = context.findVariableByName( + 'visible', + )!; + int notified = 0; + visible.addListener(() => notified++); + + context.updateVariable('crateId', 'a'); + expect(read(r'${= map(visible, r => r.title)}', context), [ + 'Apple', + 'Cherry', + ]); + expect(read(r'${count}', context), 2); + expect(notified, 1); + + // An input change that doesn't change the result notifies no one. + context.updateVariable('sortBy', 'title'); + context.findVariableByName('records')!.value = context + .findVariableByName('records')! + .value + .copyWith(value: [...records]); + expect(notified, 1); + + context.updateVariable('crateId', 'b'); + expect(read(r'${visible[0].title}', context), 'Banana'); + expect(read(r'${count}', context), 1); + expect(notified, 2); + }); + + test('computed variables are read-only', () { + final CodelesslyContext context = recordsContext() + ..bindComputedVariables(); + expect(context.updateVariable('count', 10), isFalse); + expect(read(r'${count}', context), 3); + }); + + testWidgets('a Set variable action leaves a computed variable unchanged ' + 'and a widget bound to it rebuilds when its input changes', ( + tester, + ) async { + final CodelesslyContext context = recordsContext() + ..bindComputedVariables(); + late BuildContext captured; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: context, + child: Builder( + builder: (buildContext) { + captured = buildContext; + return ValueListenableBuilder( + valueListenable: context.findVariableByName('count')!, + builder: (buildContext, _, _) => Text( + PropertyValueDelegate.substituteVariables( + r'${count} shown', + scopedValues: ScopedValues.of(buildContext), + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ), + textDirection: TextDirection.ltr, + ), + ); + }, + ), + ), + ); + expect(find.text('3 shown'), findsOneWidget); + + final bool set = FunctionsRepository.setVariableFromAction( + captured, + SetVariableAction( + variable: VariableData( + id: 'count', + name: 'count', + type: VariableType.integer, + ), + newValue: '42', + ), + ); + expect(set, isFalse); + await tester.pump(); + expect(find.text('3 shown'), findsOneWidget); + + FunctionsRepository.setVariableFromAction( + captured, + SetVariableAction( + variable: VariableData(id: 'crateId', name: 'crateId'), + newValue: 'a', + ), + ); + await tester.pump(); + expect(find.text('2 shown'), findsOneWidget); + }); + + test('cycles evaluate to empty values instead of looping', () { + final CodelesslyContext context = recordsContext( + extra: [ + VariableData(id: 'a', name: 'a', formula: r'${= b + 1}'), + VariableData(id: 'b', name: 'b', formula: r'${= a + 1}'), + VariableData(id: 'self', name: 'self', formula: r'${= self + 1}'), + VariableData( + id: 'afterCycle', + name: 'afterCycle', + formula: r"${= 'a is ' + a}", + ), + ], + )..bindComputedVariables(); + expect( + ComputedVariables.cyclicNames(context.variableNamesMap()), + {'a', 'b', 'self'}, + ); + expect(read(r'${a}', context), ''); + expect(read(r'${b}', context), ''); + expect(read(r'${self}', context), ''); + expect(read(r'${afterCycle}', context), 'a is '); + // The rest still works. + expect(read(r'${count}', context), 3); + context.updateVariable('crateId', 'b'); + expect(read(r'${count}', context), 1); + }); + + test('formulas can be a bare expression or a single path', () { + final CodelesslyContext context = recordsContext( + extra: [ + VariableData( + id: 'bare', + name: 'bare', + type: VariableType.integer, + formula: 'length(records) * 2', + ), + VariableData( + id: 'alias', + name: 'alias', + type: VariableType.list, + formula: r'${records}', + ), + ], + )..bindComputedVariables(); + expect(read(r'${bare}', context), 6); + expect(read(r'${alias[1].title}', context), 'Apple'); + expect( + ComputedVariables.inputsOf(context.variableNamesMap()['visible']!), + {'records', 'crateId', 'sortBy'}, + ); + }); + + test('a result of another type than declared takes its own type', () { + final VariableData variable = VariableData( + id: 'x', + name: 'x', + formula: '[1, 2]', + ); + final VariableData resolved = ComputedVariables.withResult(variable, [ + 1, + 2, + ]); + expect(resolved.type, VariableType.list); + expect(resolved.getValue(), [1, 2]); + expect(resolved.formula, '[1, 2]'); + expect(ComputedVariables.withResult(variable, null).value, ''); + }); + + test('resolveAll previews computed variables over default values', () { + final Map resolved = ComputedVariables.resolveAll( + recordsContext().variableNamesMap(), + ); + expect(resolved['count']!.getValue(), 3); + expect((resolved['visible']!.getValue() as List).first, { + 'title': 'Apple', + 'crate': 'a', + }); + }); + + test('the formula survives the published variables JSON', () { + final SDKLayoutVariables published = SDKLayoutVariables( + id: 'layout', + variables: { + 'canvas': { + 'visible': VariableData( + id: 'visible', + name: 'visible', + type: VariableType.list, + formula: visibleFormula, + ), + }, + }, + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + final SDKLayoutVariables decoded = SDKLayoutVariables.fromJson( + jsonDecode(jsonEncode(published.toJson())), + ); + expect(decoded.variables['canvas']!['visible']!.formula, visibleFormula); + }); + }); +} diff --git a/test/formula_text_split_test.dart b/test/formula_text_split_test.dart new file mode 100644 index 00000000..e6ce9152 --- /dev/null +++ b/test/formula_text_split_test.dart @@ -0,0 +1,17 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('a text splits into whole formulas and variables', () { + const String text = + r'Hi ${name}, ${= merge(r, {favorite: true}).favorite} and ${= 1 + 2}!'; + expect( + formulaOrVariableRegex.allMatches(text).map((m) => m[0]).toList(), + [ + r'${name}', + r'${= merge(r, {favorite: true}).favorite}', + r'${= 1 + 2}', + ], + ); + }); +} diff --git a/test/node_motion_loop_test.dart b/test/node_motion_loop_test.dart new file mode 100644 index 00000000..ff25a29e --- /dev/null +++ b/test/node_motion_loop_test.dart @@ -0,0 +1,147 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future pump(WidgetTester tester, MotionModel motion, + {bool animate = true}) => + tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: NodeMotion( + motion: motion, + animate: animate, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ); + +double opacityOf(WidgetTester tester) { + final Finder finder = find.byType(Opacity); + return finder.evaluate().isEmpty + ? 1 + : tester.widget(finder.first).opacity; +} + +double dxOf(WidgetTester tester) { + final Finder finder = find.descendant( + of: find.byType(NodeMotion), + matching: find.byType(Transform), + ); + if (finder.evaluate().isEmpty) return 0; + return tester.widget(finder.first).transform.getTranslation().x; +} + +void main() { + testWidgets('A back-and-forth loop pulses between rest and its values', ( + tester, + ) async { + await pump( + tester, + const MotionModel( + opacity: 0.4, + durationMs: 1000, + curve: MotionCurve.linear, + loop: MotionLoop.reverse, + ), + ); + expect(opacityOf(tester), 1); + await tester.pump(const Duration(milliseconds: 500)); + expect(opacityOf(tester), closeTo(0.7, 0.01)); + await tester.pump(const Duration(milliseconds: 500)); + expect(opacityOf(tester), closeTo(0.4, 0.01)); + await tester.pump(const Duration(milliseconds: 500)); + expect(opacityOf(tester), closeTo(0.7, 0.01)); + await tester.pump(const Duration(milliseconds: 500)); + expect(opacityOf(tester), closeTo(1, 0.01)); + // Still going. + await tester.pump(const Duration(milliseconds: 250)); + expect(opacityOf(tester), closeTo(0.85, 0.01)); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('A restart loop jumps back to rest each time', (tester) async { + await pump( + tester, + const MotionModel( + translateX: 100, + durationMs: 1000, + curve: MotionCurve.linear, + loop: MotionLoop.restart, + ), + ); + await tester.pump(const Duration(milliseconds: 900)); + expect(dxOf(tester), closeTo(90, 0.5)); + await tester.pump(const Duration(milliseconds: 200)); + expect(dxOf(tester), closeTo(10, 0.5)); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('An entrance animates in from rest after its delay', ( + tester, + ) async { + await pump( + tester, + const MotionModel( + translateX: 120, + durationMs: 400, + curve: MotionCurve.linear, + enter: true, + delayMs: 200, + ), + ); + expect(dxOf(tester), 0); + await tester.pump(const Duration(milliseconds: 150)); + expect(dxOf(tester), 0, reason: 'still waiting out the delay'); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 200)); + expect(dxOf(tester), closeTo(60, 1)); + await tester.pump(const Duration(milliseconds: 300)); + expect(dxOf(tester), 120); + }); + + testWidgets('A delay holds an ordinary transition back too', (tester) async { + const MotionModel motion = MotionModel( + durationMs: 100, + curve: MotionCurve.linear, + delayMs: 300, + ); + await pump(tester, motion); + await pump(tester, motion.copyWith(translateX: 50)); + await tester.pump(const Duration(milliseconds: 200)); + expect(dxOf(tester), 0); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + expect(dxOf(tester), 50); + }); + + testWidgets('The design canvas shows the values at rest, without looping', ( + tester, + ) async { + await pump( + tester, + const MotionModel( + opacity: 0.4, + durationMs: 1000, + loop: MotionLoop.reverse, + enter: true, + ), + animate: false, + ); + expect(opacityOf(tester), closeTo(0.4, 0.001)); + await tester.pump(const Duration(milliseconds: 500)); + expect(opacityOf(tester), closeTo(0.4, 0.001)); + }); + + test('loop, enter and delay survive JSON and are omitted at defaults', () { + const MotionModel motion = MotionModel( + loop: MotionLoop.reverse, + enter: true, + delayMs: 180, + ); + expect(MotionModel.fromJson(motion.toJson()), motion); + expect(MotionModel.none.toJson().containsKey('loop'), isFalse); + }); +} From cbfe407d66b0462a3a1c28721f4ad85ee8db0b3c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:47:23 +0000 Subject: [PATCH 05/12] Rows and columns lay out itemSpacing between children Formula-hidden children take no gap; children keep their state while siblings show and hide. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik --- .../passive_row_column_transformer.dart | 173 +++++++++- test/item_spacing_test.dart | 304 ++++++++++++++++++ 2 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 test/item_spacing_test.dart diff --git a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart index ec502800..4da88be9 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -8,15 +8,124 @@ typedef WidgetInserter = Widget Function(Widget child); class PassiveRowColumnTransformer extends NodeWidgetTransformer { PassiveRowColumnTransformer(super.getNode, super.manager); + /// The gap a row/column puts between consecutive children along its main + /// axis: [RowColumnNode.itemSpacing], or 0 for other row/column-like nodes. + static double itemSpacingOf(BaseNode node) => switch (node) { + RowColumnNode(:final double itemSpacing) + when itemSpacing.isFinite && itemSpacing > 0 => + itemSpacing, + _ => 0, + }; + + /// Spaces [childrenWidgets] [spacing] apart along [rowColumnNode]'s main + /// axis, leaving out the children [collapsed] marks: a child a visibility + /// formula hides (or that is disabled) takes no space, so it takes no gap + /// either, like CSS `display: none` in a flex box with a `gap`. + /// + /// When [collapsed] is null no child can collapse and the gap is Flutter's + /// own `Flex.spacing` (returned as `spacing`). Otherwise the gaps are built + /// into the children and the Flex gets no spacing: + /// + /// - A child that doesn't flex on the main axis gets the gap before it as + /// main-axis padding (or the one after it, when the next child flexes). + /// A fixed extent next to a child is exactly what `Flex.spacing` adds, so + /// placement is the same for every main-axis alignment and the flex + /// children share the same free space. + /// - Between two flexing children the gap is a box of its own. A flexing + /// child takes the free space, so alignment spreads nothing and the box + /// lands where `Flex.spacing` would put the gap. + /// + /// Every child keeps its wrapper whether it gets a gap or not, and every + /// child is keyed by its node id, so a child keeps its state while its + /// siblings collapse and show again. + static ({List children, double spacing}) spaceChildren( + BaseNode rowColumnNode, { + required List childrenWidgets, + required List childrenNodes, + required double spacing, + List? collapsed, + }) { + if (spacing <= 0 || childrenWidgets.length < 2) { + return (children: childrenWidgets, spacing: 0); + } + if (collapsed == null) { + return (children: childrenWidgets, spacing: spacing); + } + assert(childrenWidgets.length == childrenNodes.length); + assert(collapsed.length == childrenNodes.length); + + final bool isRow = + (rowColumnNode as RowColumnMixin).rowColumnType == RowColumnType.row; + bool flexes(BaseNode child) => + (isRow ? child.horizontalFit : child.verticalFit).isFlex; + + final int count = childrenNodes.length; + final List leading = List.filled(count, 0); + final List trailing = List.filled(count, 0); + // Index of a flexing child -> whether a gap box goes before it. + final Set boxBefore = {}; + + int? previous; + for (int i = 0; i < count; i++) { + if (collapsed[i]) continue; + if (previous != null) { + if (!flexes(childrenNodes[i])) { + leading[i] = spacing; + } else if (!flexes(childrenNodes[previous])) { + trailing[previous] = spacing; + } else { + boxBefore.add(i); + } + } + previous = i; + } + + final List children = []; + for (int i = 0; i < count; i++) { + final BaseNode child = childrenNodes[i]; + Widget widget = childrenWidgets[i]; + if (boxBefore.contains(i)) { + children.add( + SizedBox( + key: ValueKey('${child.id}-gap'), + width: isRow ? spacing : null, + height: isRow ? null : spacing, + ), + ); + } + if (!flexes(child)) { + widget = Padding( + padding: isRow + ? EdgeInsets.only(left: leading[i], right: trailing[i]) + : EdgeInsets.only(top: leading[i], bottom: trailing[i]), + child: widget, + ); + } + children.add( + KeyedSubtree(key: ValueKey('${child.id}-spaced'), child: widget), + ); + } + return (children: children, spacing: 0); + } + static Widget buildRowColumnWidget( BaseNode rowColumnNode, { required List childrenWidgets, required List childrenNodes, required bool withScroll, required GetNode getNode, + List? collapsedChildren, }) { assert(rowColumnNode is RowColumnMixin); + final spaced = spaceChildren( + rowColumnNode, + childrenWidgets: childrenWidgets, + childrenNodes: childrenNodes, + spacing: itemSpacingOf(rowColumnNode), + collapsed: collapsedChildren, + ); + final bool isRow = (rowColumnNode as RowColumnMixin).rowColumnType == RowColumnType.row; @@ -100,7 +209,8 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { ? MainAxisSize.min : MainAxisSize.max, key: ValueKey(rowColumnNode.id), - children: childrenWidgets, + spacing: spaced.spacing, + children: spaced.children, ); if (measureCross) { @@ -379,8 +489,67 @@ class PassiveRowColumnWidget extends StatelessWidget { ); } + /// Whether [child] may take no space at runtime: a visibility formula or + /// condition decides whether it shows, or it is disabled. + bool _mayCollapse(BuildContext context, BaseNode child) { + final NodeTransformerManager manager = this.manager; + if (!child.enabled) return true; + if (manager is! PassiveNodeTransformerManager) return false; + return manager.runtimeVisibility(context, child) != null || + PropertyValueDelegate.getPropertyValue( + child, + 'node_enabled', + scopedValues: ScopedValues.of(context), + ) != + null; + } + + /// Whether [child] takes no space now (see [_mayCollapse]). + bool _isCollapsed(BuildContext context, BaseNode child) { + final NodeTransformerManager manager = this.manager; + if (manager is! PassiveNodeTransformerManager) return !child.enabled; + final bool enabled = + PropertyValueDelegate.getPropertyValue( + child, + 'node_enabled', + scopedValues: ScopedValues.of(context), + ) ?? + child.enabled; + return !enabled || manager.runtimeVisibility(context, child) == false; + } + @override Widget build(BuildContext context) { + // With a gap, a child that collapses at runtime must give up its gaps + // too, so the row/column rebuilds when one of them shows or hides. + final bool spaced = PassiveRowColumnTransformer.itemSpacingOf(node) > 0; + final List collapsible = spaced + ? children.where((child) => _mayCollapse(context, child)).toList() + : const []; + if (collapsible.isEmpty) return _build(context, null); + + List collapsedNow(BuildContext context) => [ + for (final BaseNode child in children) + collapsible.contains(child) && _isCollapsed(context, child), + ]; + + final NodeTransformerManager manager = this.manager; + final List listenables = + manager is PassiveNodeTransformerManager + ? [ + for (final BaseNode child in collapsible) + ...manager.listenablesFor(context, child), + ] + : const []; + if (listenables.isEmpty) return _build(context, collapsedNow(context)); + return ManagedListenableBuilder( + key: ValueKey('${node.id}-spacing'), + listenables: listenables, + builder: (context) => _build(context, collapsedNow(context)), + ); + } + + Widget _build(BuildContext context, List? collapsedChildren) { final List widgetChildren = children.map((child) { final Widget builtWidget = manager.buildWidgetByID( child.id, @@ -411,6 +580,7 @@ class PassiveRowColumnWidget extends StatelessWidget { childrenNodes: children, withScroll: true, getNode: manager.getNode, + collapsedChildren: collapsedChildren, ), ], settings: settings, @@ -421,6 +591,7 @@ class PassiveRowColumnWidget extends StatelessWidget { childrenNodes: children, withScroll: true, getNode: manager.getNode, + collapsedChildren: collapsedChildren, ); if (kIsTestLayout) { diff --git a/test/item_spacing_test.dart b/test/item_spacing_test.dart new file mode 100644 index 00000000..2fecb5ff --- /dev/null +++ b/test/item_spacing_test.dart @@ -0,0 +1,304 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +// RowColumnNode.itemSpacing: the gap between consecutive children, as +// Flutter's Flex.spacing (and CSS gap) lays it out. + +RectangleNode rect( + String id, { + double width = 50, + double height = 50, + SizeFit horizontalFit = SizeFit.fixed, + SizeFit verticalFit = SizeFit.fixed, + bool visible = true, +}) => RectangleNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, width, height), + horizontalFit: horizontalFit, + verticalFit: verticalFit, + visible: visible, +); + +RowColumnNode rowColumn( + List children, { + RowColumnType type = RowColumnType.row, + double width = 300, + double height = 50, + double itemSpacing = 10, + SizeFit horizontalFit = SizeFit.fixed, + SizeFit verticalFit = SizeFit.fixed, + MainAxisAlignmentC mainAxisAlignment = MainAxisAlignmentC.start, +}) { + for (final child in children) { + child.parentID = 'row'; + } + return RowColumnNode( + id: 'row', + name: 'row', + basicBoxLocal: NodeBox(0, 0, width, height), + children: [for (final child in children) child.id], + rowColumnType: type, + horizontalFit: horizontalFit, + verticalFit: verticalFit, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: CrossAxisAlignmentC.start, + itemSpacing: itemSpacing, + )..parentID = kRootNode; +} + +Finder rectOf(String id) => + find.byKey(ValueKey('Rectangle Transformer of $id [$id]')); + +Future pump( + WidgetTester tester, + RowColumnNode root, + List nodes, { + CodelesslyContext? codelesslyContext, +}) async { + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final registry = NodeRegistry() + ..setNodes({ + for (final node in [root, ...nodes]) node.id: node, + }); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext ?? CodelesslyContext.empty(), + child: MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + root, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); +} + +/// Main-axis offset of [id] from the row/column's start. +double mainOffset(WidgetTester tester, String id, {bool isRow = true}) { + final Offset child = tester.getTopLeft(rectOf(id)); + final Offset row = tester.getTopLeft(rectOf('row')); + return isRow ? child.dx - row.dx : child.dy - row.dy; +} + +double mainSize(WidgetTester tester, String id, {bool isRow = true}) { + final Size size = tester.getSize(rectOf(id)); + return isRow ? size.width : size.height; +} + +Observable> visibility(bool visible) => + Observable>([BoolValue(name: 'visible', value: visible)]); + +void main() { + testWidgets('start: children are spaced by the gap via Flex.spacing', ( + tester, + ) async { + final children = [rect('a'), rect('b'), rect('c')]; + await pump(tester, rowColumn(children), children); + expect(mainOffset(tester, 'a'), 0); + expect(mainOffset(tester, 'b'), 60); + expect(mainOffset(tester, 'c'), 120); + final CodelesslyFlex flex = tester.widget(find.byType(CodelesslyFlex)); + expect(flex.spacing, 10); + }); + + testWidgets('center: the gaps are centered with the children', ( + tester, + ) async { + final children = [rect('a'), rect('b'), rect('c')]; + await pump( + tester, + rowColumn(children, mainAxisAlignment: MainAxisAlignmentC.center), + children, + ); + // (300 - 150 - 20) / 2 = 65. + expect(mainOffset(tester, 'a'), 65); + expect(mainOffset(tester, 'b'), 125); + expect(mainOffset(tester, 'c'), 185); + }); + + testWidgets('end: the last child ends at the row end', (tester) async { + final children = [rect('a'), rect('b')]; + await pump( + tester, + rowColumn(children, mainAxisAlignment: MainAxisAlignmentC.end), + children, + ); + expect(mainOffset(tester, 'a'), 190); + expect(mainOffset(tester, 'b'), 250); + }); + + testWidgets('spaceBetween: the gap adds to the distributed space', ( + tester, + ) async { + final children = [rect('a'), rect('b'), rect('c')]; + await pump( + tester, + rowColumn(children, mainAxisAlignment: MainAxisAlignmentC.spaceBetween), + children, + ); + expect(mainOffset(tester, 'a'), 0); + expect(mainOffset(tester, 'b'), 125); + expect(mainOffset(tester, 'c'), 250); + }); + + testWidgets('spaceEvenly and spaceAround', (tester) async { + final children = [rect('a'), rect('b')]; + await pump( + tester, + rowColumn(children, mainAxisAlignment: MainAxisAlignmentC.spaceEvenly), + children, + ); + // free = 300 - 100 - 10 = 190, 3 slots of 190/3. + expect(mainOffset(tester, 'a'), moreOrLessEquals(190 / 3)); + expect(mainOffset(tester, 'b'), moreOrLessEquals(2 * 190 / 3 + 60)); + + await pump( + tester, + rowColumn(children, mainAxisAlignment: MainAxisAlignmentC.spaceAround), + children, + ); + // free = 190, 2 slots of 95: 47.5 on the outside. + expect(mainOffset(tester, 'a'), 47.5); + expect(mainOffset(tester, 'b'), 47.5 + 50 + 95 + 10); + }); + + testWidgets('expanded children share the space left after the gaps', ( + tester, + ) async { + final children = [ + rect('a'), + rect('e', horizontalFit: SizeFit.expanded), + rect('b'), + ]; + await pump(tester, rowColumn(children), children); + expect(mainSize(tester, 'e'), 180); + expect(mainOffset(tester, 'e'), 60); + expect(mainOffset(tester, 'b'), 250); + }); + + testWidgets('a shrink-wrapping column sums the gaps', (tester) async { + final children = [rect('a'), rect('b'), rect('c')]; + await pump( + tester, + rowColumn( + children, + type: RowColumnType.column, + width: 50, + itemSpacing: 8, + verticalFit: SizeFit.shrinkWrap, + ), + children, + ); + expect(tester.getSize(rectOf('row')).height, 166); + expect(mainOffset(tester, 'c', isRow: false), 116); + }); + + testWidgets('a child hidden with the eye toggle keeps its space and gaps', ( + tester, + ) async { + final children = [rect('a'), rect('h', visible: false), rect('b')]; + await pump(tester, rowColumn(children), children); + expect(mainOffset(tester, 'b'), 120); + }); + + testWidgets('a child hidden at runtime gives up its gap', (tester) async { + final hidden = visibility(false); + final codelesslyContext = CodelesslyContext.empty() + ..nodeValues['h'] = hidden; + final children = [rect('a'), rect('h'), rect('b')]; + await pump( + tester, + rowColumn(children), + children, + codelesslyContext: codelesslyContext, + ); + expect(mainOffset(tester, 'b'), 60); + + hidden.value = [const BoolValue(name: 'visible', value: true)]; + await tester.pump(); + expect(tester.takeException(), isNull); + expect(mainOffset(tester, 'h'), 60); + expect(mainOffset(tester, 'b'), 120); + + // The first child hidden: no gap before the second. + final first = visibility(false); + await pump( + tester, + rowColumn(children), + children, + codelesslyContext: CodelesslyContext.empty()..nodeValues['a'] = first, + ); + expect(mainOffset(tester, 'h'), 0); + expect(mainOffset(tester, 'b'), 60); + }); + + testWidgets('runtime-hidden children with spaceBetween and expanded', ( + tester, + ) async { + final children = [rect('a'), rect('h'), rect('b')]; + await pump( + tester, + rowColumn(children, mainAxisAlignment: MainAxisAlignmentC.spaceBetween), + children, + codelesslyContext: CodelesslyContext.empty() + ..nodeValues['h'] = visibility(false), + ); + expect(mainOffset(tester, 'a'), 0); + expect(mainOffset(tester, 'b'), 250); + + // Two expanded children around a hidden one get one gap between them. + final flexChildren = [ + rect('e1', horizontalFit: SizeFit.expanded), + rect('h'), + rect('e2', horizontalFit: SizeFit.expanded), + ]; + await pump( + tester, + rowColumn(flexChildren), + flexChildren, + codelesslyContext: CodelesslyContext.empty() + ..nodeValues['h'] = visibility(false), + ); + expect(mainSize(tester, 'e1'), 145); + expect(mainSize(tester, 'e2'), 145); + expect(mainOffset(tester, 'e2'), 155); + }); + + testWidgets('a hidden child keeps its state while its gap comes and goes', ( + tester, + ) async { + final hidden = visibility(true); + final children = [rect('a'), rect('h'), rect('b')]; + await pump( + tester, + rowColumn(children), + children, + codelesslyContext: CodelesslyContext.empty()..nodeValues['a'] = hidden, + ); + final Element before = tester.element(rectOf('b')); + hidden.value = [const BoolValue(name: 'visible', value: false)]; + await tester.pump(); + expect(mainOffset(tester, 'b'), 60); + expect(tester.element(rectOf('b')), same(before)); + }); +} From 1bb95c24ce97fe2e35a051f030687993402725f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:57:29 +0000 Subject: [PATCH 06/12] Double tap and hover triggers; vertical sliders with bound min/max and thumb stroke Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik --- .../passive_slider_transformer.dart | 131 +++++++- .../passive_transformer_manager.dart | 1 + .../widget_node_transformer_manager.dart | 83 +++++ lib/src/utils/computed_variables.dart | 2 + test/double_tap_hover_slider_test.dart | 299 ++++++++++++++++++ 5 files changed, 507 insertions(+), 9 deletions(-) create mode 100644 test/double_tap_hover_slider_test.dart diff --git a/lib/src/transformers/node_transformers/passive_slider_transformer.dart b/lib/src/transformers/node_transformers/passive_slider_transformer.dart index a7ecc97f..faa897cf 100644 --- a/lib/src/transformers/node_transformers/passive_slider_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_slider_transformer.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:codelessly_api/codelessly_api.dart'; import 'package:flutter/material.dart'; @@ -94,6 +96,20 @@ class PassiveSliderWidget extends StatelessWidget { scopedValues: scopedValues, ) ?? node.value; + final double min = + PropertyValueDelegate.getPropertyValue( + node, + 'min', + scopedValues: scopedValues, + ) ?? + node.properties.min; + final double max = + PropertyValueDelegate.getPropertyValue( + node, + 'max', + scopedValues: scopedValues, + ) ?? + node.properties.max; return TransformerSlider( node: node, @@ -101,6 +117,8 @@ class PassiveSliderWidget extends StatelessWidget { variablesOverrides: variablesOverrides, onChanged: (value) => onChanged(context, value), value: value, + min: min, + max: max, ); } } @@ -113,6 +131,8 @@ class TransformerSlider extends StatelessWidget { required this.onChanged, required this.value, this.variablesOverrides = const [], + this.min, + this.max, }); final SliderNode node; @@ -121,8 +141,18 @@ class TransformerSlider extends StatelessWidget { final ValueChanged? onChanged; final double value; + /// The slider's minimum, when bound; [SliderProperties.min] otherwise. + final double? min; + + /// The slider's maximum, when bound; [SliderProperties.max] otherwise. + /// Never less than the minimum. + final double? max; + @override Widget build(BuildContext context) { + final double min = this.min ?? node.properties.min; + final double max = math.max(min, this.max ?? node.properties.max); + final bool vertical = node.properties.orientation == AxisC.vertical; final SliderTrackShape trackShape = switch (node.properties.trackShape) { SliderTrackShapeEnum.rectangle => const RectangularSliderTrackShape(), SliderTrackShapeEnum.roundedRectangle => @@ -158,9 +188,12 @@ class TransformerSlider extends StatelessWidget { }; final bool showsLabel = showValueIndicator != ShowValueIndicator.never; - return SizedBox( - width: node.basicBoxLocal.width, - height: node.basicBoxLocal.height, + // A vertical slider is a horizontal one turned a quarter turn + // counterclockwise, so min sits at the bottom. RotatedBox turns its + // layout and hit testing too, so the whole node box drags the thumb. + final Widget slider = SizedBox( + width: vertical ? node.basicBoxLocal.height : node.basicBoxLocal.width, + height: vertical ? node.basicBoxLocal.width : node.basicBoxLocal.height, child: SliderTheme( data: SliderThemeData( trackHeight: node.properties.trackHeight, @@ -171,10 +204,12 @@ class TransformerSlider extends StatelessWidget { inactiveTrackColor: node.properties.inactiveTrackColor .toFlutterColor(), thumbColor: node.properties.thumbColor.toFlutterColor(), - thumbShape: RoundSliderThumbShape( + thumbShape: StrokedSliderThumbShape( enabledThumbRadius: node.properties.showThumb ? node.properties.thumbRadius : 0, + strokeColor: node.properties.thumbStrokeColor?.toFlutterColor(), + strokeWidth: node.properties.thumbStrokeWidth, ), trackShape: trackShape, // GappedSliderTrackShape requires a gap; 6 is the Material 3 @@ -208,22 +243,100 @@ class TransformerSlider extends StatelessWidget { ), ), child: Slider( - value: value.clamp(node.properties.min, node.properties.max), + value: value.clamp(min, max), onChanged: onChanged, autofocus: node.properties.autofocus, - min: node.properties.min, - max: node.properties.max, + min: min, + max: max, divisions: node.properties.isDiscrete ? node.properties.divisions : null, label: showsLabel ? getSliderLabel(node, value) : null, secondaryTrackValue: node.properties.secondaryTrackValue?.clamp( - node.properties.min, - node.properties.max, + min, + max, ), padding: node.properties.padding?.flutterEdgeInsets, ), ), ); + + final TextDirection direction = + Directionality.maybeOf(context) ?? TextDirection.ltr; + final TextDirection flipped = direction == TextDirection.ltr + ? TextDirection.rtl + : TextDirection.ltr; + // Vertical runs bottom to top in left to right text, so an inverted + // vertical slider runs top to bottom. + final Widget directed = node.properties.inverted + ? Directionality(textDirection: flipped, child: slider) + : Directionality(textDirection: direction, child: slider); + + if (!vertical) { + return node.properties.inverted ? directed : slider; + } + return SizedBox( + width: node.basicBoxLocal.width, + height: node.basicBoxLocal.height, + child: RotatedBox(quarterTurns: 3, child: directed), + ); + } +} + +/// A [RoundSliderThumbShape] with an optional stroke drawn inside its edge. +class StrokedSliderThumbShape extends RoundSliderThumbShape { + /// Creates a [StrokedSliderThumbShape]. + const StrokedSliderThumbShape({ + super.enabledThumbRadius, + this.strokeColor, + this.strokeWidth = 0, + }); + + /// Color of the stroke; no stroke when null. + final Color? strokeColor; + + /// Width of the stroke; no stroke when 0. + final double strokeWidth; + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + super.paint( + context, + center, + activationAnimation: activationAnimation, + enableAnimation: enableAnimation, + isDiscrete: isDiscrete, + labelPainter: labelPainter, + parentBox: parentBox, + sliderTheme: sliderTheme, + textDirection: textDirection, + value: value, + textScaleFactor: textScaleFactor, + sizeWithOverflow: sizeWithOverflow, + ); + final Color? color = strokeColor; + if (color == null || strokeWidth <= 0 || enabledThumbRadius <= 0) return; + final double width = math.min(strokeWidth, enabledThumbRadius); + context.canvas.drawCircle( + center, + enabledThumbRadius - width / 2, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = width + ..color = color, + ); } } diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index cc4141b8..4612822e 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -125,6 +125,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { if (settings.withReactions) { widget = wrapWithReaction(context, styled, widget); widget = wrapWithSwipes(context, styled, widget); + widget = wrapWithPointerTriggers(context, styled, widget); widget = wrapWithSemantics(styled, widget); widget = applyTooltipAndZoom(context, styled, widget); } diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 54cbcd0b..7c309e25 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:codelessly_api/codelessly_api.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart' show PointerDeviceKind; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:provider/provider.dart'; @@ -389,6 +390,88 @@ abstract class WidgetNodeTransformerManager ); } + /// Runs [node]'s double tap and hover reactions. + /// + /// A double tap recognizer is only added when a double tap reaction + /// exists, since it makes single taps wait out the double tap timeout + /// (300ms) before they fire `click`. Hover reactions listen with a + /// [MouseRegion], which touch input never enters. The legacy + /// [TriggerType.cursorEnter] and [TriggerType.cursorExit] run with + /// [TriggerType.hoverEnter] and [TriggerType.hoverExit]. + Widget wrapWithPointerTriggers( + BuildContext context, + BaseNode node, + Widget widget, + ) { + if (node is! ReactionMixin) return widget; + final List reactions = (node as ReactionMixin).reactions + .where( + (reaction) => + _pointerTriggers.contains(reaction.trigger.type) && + reaction.action.enabled, + ) + .toList(); + if (reactions.isEmpty) return widget; + bool has(TriggerType type) => + reactions.any((reaction) => reaction.trigger.type == type); + + if (has(TriggerType.doubleTap)) { + widget = GestureDetector( + behavior: HitTestBehavior.opaque, + onDoubleTap: () => FunctionsRepository.triggerAction( + context, + TriggerType.doubleTap, + reactions: reactions, + ), + child: widget, + ); + } + + final bool enters = + has(TriggerType.hoverEnter) || has(TriggerType.cursorEnter); + final bool exits = + has(TriggerType.hoverExit) || has(TriggerType.cursorExit); + if (enters || exits) { + void run(PointerEvent event, List types) { + if (event.kind == PointerDeviceKind.touch) return; + for (final TriggerType type in types) { + FunctionsRepository.triggerAction( + context, + type, + reactions: reactions, + ); + } + } + + widget = MouseRegion( + opaque: false, + hitTestBehavior: HitTestBehavior.opaque, + onEnter: enters + ? (event) => run(event, const [ + TriggerType.hoverEnter, + TriggerType.cursorEnter, + ]) + : null, + onExit: exits + ? (event) => run(event, const [ + TriggerType.hoverExit, + TriggerType.cursorExit, + ]) + : null, + child: widget, + ); + } + return widget; + } + + static const Set _pointerTriggers = { + TriggerType.doubleTap, + TriggerType.hoverEnter, + TriggerType.hoverExit, + TriggerType.cursorEnter, + TriggerType.cursorExit, + }; + BorderRadius? getBorderRadius(BaseNode node) { if (node is CornerMixin) { if (node.cornerRadius.linked && diff --git a/lib/src/utils/computed_variables.dart b/lib/src/utils/computed_variables.dart index de5f1a57..8fe17873 100644 --- a/lib/src/utils/computed_variables.dart +++ b/lib/src/utils/computed_variables.dart @@ -194,6 +194,8 @@ abstract final class ComputedVariables { VariableType.color => result is String || result is ColorRGBA, VariableType.list => result is List, VariableType.map => result is Map, + // A choice holds the name of one of its options. + VariableType.choice => result is String, }; static VariableType? _typeOf(Object result) { diff --git a/test/double_tap_hover_slider_test.dart b/test/double_tap_hover_slider_test.dart new file mode 100644 index 00000000..cece07bb --- /dev/null +++ b/test/double_tap_hover_slider_test.dart @@ -0,0 +1,299 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/functions/action_chain.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +Reaction on(TriggerType type, String function) => Reaction( + trigger: TriggerModel(type: type), + action: CallFunctionAction(name: function), +); + +RectangleNode box(List reactions) => RectangleNode( + id: 'box', + name: 'box', + basicBoxLocal: NodeBox(0, 0, 100, 100), + reactions: reactions, +)..parentID = kRootNode; + +Future pumpNode( + WidgetTester tester, + BaseNode node, + List calls, { + Map> variables = const {}, +}) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final codelesslyContext = CodelesslyContext.empty() + ..functions = { + for (final name in ['tap', 'double', 'enter', 'exit', 'reset']) + name: (context, reference, params) => calls.add(name), + }; + codelesslyContext.variables.addAll(variables); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Material( + child: ActionChainHost( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ), + ); + return codelesslyContext; +} + +SliderNode slider({ + SliderProperties? properties, + List reactions = const [], + Map variables = const {}, + double width = 300, + double height = 48, +}) => SliderNode( + id: 'slider', + name: 'slider', + basicBoxLocal: NodeBox(0, 0, width, height), + value: 0, + properties: properties ?? SliderProperties(), + reactions: reactions, + variables: variables, +)..parentID = kRootNode; + +void main() { + testWidgets('A single tap still clicks when there is no double tap', ( + tester, + ) async { + final calls = []; + await pumpNode(tester, box([on(TriggerType.click, 'tap')]), calls); + await tester.tapAt(const Offset(50, 50)); + await tester.pump(); + expect(calls, ['tap']); + expect( + find.byWidgetPredicate( + (w) => w is GestureDetector && w.onDoubleTap != null, + ), + findsNothing, + ); + }); + + testWidgets('Double tap runs its reaction; single taps still click', ( + tester, + ) async { + final calls = []; + await pumpNode( + tester, + box([on(TriggerType.click, 'tap'), on(TriggerType.doubleTap, 'double')]), + calls, + ); + await tester.tapAt(const Offset(50, 50)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(const Offset(50, 50)); + await tester.pump(); + expect(calls, ['double']); + + // A lone tap waits out the double tap timeout, then clicks. + await tester.pump(const Duration(seconds: 1)); + await tester.tapAt(const Offset(50, 50)); + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 50)); + expect(calls, ['double', 'tap']); + }); + + testWidgets('Hover enter and exit run for a mouse, never for touch', ( + tester, + ) async { + final calls = []; + await pumpNode( + tester, + box([ + on(TriggerType.hoverEnter, 'enter'), + on(TriggerType.hoverExit, 'exit'), + ]), + calls, + ); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(300, 300)); + await mouse.moveTo(const Offset(50, 50)); + await tester.pump(); + expect(calls, ['enter']); + await mouse.moveTo(const Offset(300, 300)); + await tester.pump(); + expect(calls, ['enter', 'exit']); + await mouse.removePointer(); + + calls.clear(); + await tester.tapAt(const Offset(50, 50)); + await tester.pump(); + expect(calls, isEmpty); + }); + + testWidgets('Double tapping a slider can reset it', (tester) async { + final calls = []; + await pumpNode( + tester, + slider(reactions: [on(TriggerType.doubleTap, 'reset')]), + calls, + ); + await tester.tapAt(const Offset(150, 24)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(const Offset(150, 24)); + await tester.pump(const Duration(milliseconds: 500)); + expect(calls, ['reset']); + }); + + testWidgets('A vertical slider fills its box and drags along it', ( + tester, + ) async { + final calls = []; + await pumpNode( + tester, + slider( + width: 48, + height: 300, + properties: SliderProperties( + min: -8, + max: 8, + orientation: AxisC.vertical, + ), + ), + calls, + ); + expect(find.byType(RotatedBox), findsOneWidget); + expect(tester.getSize(find.byType(Slider)), const Size(300, 48)); + final RenderBox rotated = tester.renderObject(find.byType(RotatedBox)); + expect(rotated.size, const Size(48, 300)); + + // Tapping near the top of a vertical slider picks a high value. + final values = []; + final Slider widget = tester.widget(find.byType(Slider)); + expect(widget.min, -8); + expect(widget.max, 8); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: TransformerSlider( + node: slider( + width: 48, + height: 300, + properties: SliderProperties( + min: -8, + max: 8, + orientation: AxisC.vertical, + ), + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + onChanged: values.add, + value: 0, + ), + ), + ), + ), + ); + await tester.tapAt(const Offset(24, 40)); + await tester.pump(); + expect(values.last, greaterThan(4)); + + // Dragging up from the bottom moves it up through the range. + values.clear(); + await tester.dragFrom(const Offset(24, 280), const Offset(0, -260)); + await tester.pump(); + expect(values.first, lessThan(-4)); + expect(values.last, greaterThan(4)); + + // Inverted, the top is the low end. + values.clear(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: TransformerSlider( + node: slider( + width: 48, + height: 300, + properties: SliderProperties( + min: -8, + max: 8, + orientation: AxisC.vertical, + inverted: true, + ), + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + onChanged: values.add, + value: 0, + ), + ), + ), + ), + ); + await tester.tapAt(const Offset(24, 40)); + await tester.pump(); + expect(values.last, lessThan(-4)); + }); + + testWidgets('Slider min and max follow their bound variables', ( + tester, + ) async { + await pumpNode( + tester, + slider(variables: {'min': r'${low}', 'max': r'${high}'}), + [], + variables: { + 'low': Observable( + VariableData( + id: 'low', + name: 'low', + type: VariableType.integer, + value: '-8', + ), + ), + 'high': Observable( + VariableData( + id: 'high', + name: 'high', + type: VariableType.integer, + value: '8', + ), + ), + }, + ); + final Slider widget = tester.widget(find.byType(Slider)); + expect(widget.min, -8); + expect(widget.max, 8); + }); + + testWidgets('The slider thumb can carry a stroke', (tester) async { + await pumpNode( + tester, + slider( + properties: SliderProperties( + thumbStrokeColor: ColorRGBA.white, + thumbStrokeWidth: 2, + ), + ), + [], + ); + final SliderTheme theme = tester.widget(find.byType(SliderTheme).last); + final shape = theme.data.thumbShape as StrokedSliderThumbShape; + expect(shape.strokeWidth, 2); + expect(shape.strokeColor, Colors.white); + expect(tester.takeException(), isNull); + }); +} From d282f9edb6aebece2a661631ed2f43c21d281258 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 04:04:23 +0000 Subject: [PATCH 07/12] Dashed strokes draw round and square caps along the path; zero patterns no longer hang Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik --- .../transformers/utils/stroke_painter.dart | 145 ++++++++++++++- test/dashed_stroke_caps_test.dart | 170 ++++++++++++++++++ 2 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 test/dashed_stroke_caps_test.dart diff --git a/lib/src/transformers/utils/stroke_painter.dart b/lib/src/transformers/utils/stroke_painter.dart index cf937610..421fecb9 100644 --- a/lib/src/transformers/utils/stroke_painter.dart +++ b/lib/src/transformers/utils/stroke_painter.dart @@ -88,8 +88,17 @@ class StrokePainter extends CustomPainter { // If the stroke is dotted, we take a completely different rendering // route. + // A pattern that never advances would loop forever, and draws nothing. + if (dashPattern.isNotEmpty && + dashPattern.fold(0, (sum, dash) => sum + max(0, dash)) <= 0) { + return; + } if (dashPattern.isNotEmpty && strokeSide != StrokeSide.all) { _paintDashedSide(canvas, size, paint); + } else if (dashPattern.isNotEmpty && + strokeCap != StrokeCapEnum.none && + strokeWidth > 0) { + _paintCappedDashes(canvas, size, paint, isLine, isHorizLine, isVertLine); } else if (dashPattern.isNotEmpty) { final Path path = Path(); @@ -136,7 +145,9 @@ class StrokePainter extends CustomPainter { while (totalDistance < metric.length) { final double dashDistance = dashArray.nextValue(); - if (draw) { + // A butt-capped dash of no length covers nothing (a stroke width of + // zero would paint a hairline instead). + if (draw && dashDistance > 0) { // Now that we're about to draw a singular dash, we need to do it // manually. We do this with tangent calculations. Luckily for us, // Flutter has a convenient method for that! @@ -239,6 +250,21 @@ class StrokePainter extends CustomPainter { }; final double length = (end - start).distance; if (length <= 0 || dashPattern.every((dash) => dash <= 0)) return; + if (strokeCap != StrokeCapEnum.none && strokeWidth > 0) { + // Round and square caps extend every dash by half the stroke width, + // so even a dash of zero length draws a dot. + paint + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth; + _paintDashesAlong( + canvas, + Path() + ..moveTo(start.dx, start.dy) + ..lineTo(end.dx, end.dy), + paint, + ); + return; + } final LoopingList dashes = LoopingList(dashPattern); final Offset direction = (end - start) / length; paint @@ -261,6 +287,123 @@ class StrokePainter extends CustomPainter { } } + /// Paints a dashed stroke on every side whose dashes have round or square + /// caps, as SVG and Figma draw them: each dash runs along the stroke's + /// center line and its caps extend it by half the stroke width at both + /// ends, so a dash of zero length is a dot as wide as the stroke. + /// + /// Dashes without caps are painted as "needles" across the stroke (see + /// [paint]) instead; caps on those would stick out of the stroke's band. + void _paintCappedDashes( + Canvas canvas, + Size size, + Paint paint, + bool isLine, + bool isHorizLine, + bool isVertLine, + ) { + final Path path = Path(); + if (isLine) { + path + ..moveTo( + isHorizLine ? strokeWidth / 2 : 0, + isVertLine ? strokeWidth / 2 : 0, + ) + ..lineTo( + isHorizLine ? strokeWidth / 2 : size.width, + isVertLine ? strokeWidth / 2 : size.height, + ); + } else { + // The center line of the stroke: half its width inside the box for an + // inside stroke, on the edge for a centered one, and half its width + // outside for an outside one. + final double grow = switch (strokeAlign) { + StrokeAlignC.inside => -strokeWidth / 2, + StrokeAlignC.center => 0, + StrokeAlignC.outside => strokeWidth / 2, + }; + if (boxShape == BoxShape.circle) { + final double diameter = min(size.width, size.height) + grow * 2; + if (diameter <= 0) return; + path.addOval( + Rect.fromCenter( + center: Offset(size.width / 2, size.height / 2), + width: diameter, + height: diameter, + ), + ); + } else { + final RRect box = borderRadius + .toRRect(Rect.fromLTWH(0, 0, size.width, size.height)) + .inflate(grow); + if (box.width <= 0 || box.height <= 0) return; + path.addRRect(box); + } + } + paint + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth; + _paintDashesAlong(canvas, path, paint); + } + + /// Paints [dashPattern] along [path] with [paint], whose cap is round or + /// square. Dashes shorter than [_dotLength] are painted as dots: the cap + /// alone, which a stroke of (almost) no length may not draw. + void _paintDashesAlong(Canvas canvas, Path path, Paint paint) { + final LoopingList dashes = LoopingList(dashPattern); + final Paint dotPaint = Paint() + ..color = paint.color + ..style = PaintingStyle.fill + ..isAntiAlias = paint.isAntiAlias; + for (final PathMetric metric in path.computeMetrics()) { + double distance = 0; + bool draw = true; + while (distance < metric.length) { + final double dash = max(0.0, dashes.nextValue()); + if (draw) { + final double end = min(distance + dash, metric.length); + if (end - distance < _dotLength) { + final Tangent? tangent = metric.getTangentForOffset(distance); + if (tangent != null) _paintDot(canvas, tangent, dotPaint); + } else { + canvas.drawPath(metric.extractPath(distance, end), paint); + } + } + distance += dash; + draw = !draw; + } + } + } + + /// Dashes shorter than this are painted as a dot of the cap's shape. + static const double _dotLength = 0.01; + + /// Paints a dash of no length at [tangent]: a circle as wide as the + /// stroke for a round cap, a square turned along the path for a square + /// one. + void _paintDot(Canvas canvas, Tangent tangent, Paint paint) { + final double radius = strokeWidth / 2; + if (strokeCap == StrokeCapEnum.round) { + canvas.drawCircle(tangent.position, radius, paint); + return; + } + final double length = tangent.vector.distance; + final Offset along = length == 0 + ? Offset(radius, 0) + : tangent.vector / length * radius; + final Offset across = Offset(-along.dy, along.dx); + final Offset center = tangent.position; + canvas.drawPath( + Path() + ..moveTo((center - along - across).dx, (center - along - across).dy) + ..lineTo((center + along - across).dx, (center + along - across).dy) + ..lineTo((center + along + across).dx, (center + along + across).dy) + ..lineTo((center - along + across).dx, (center - along + across).dy) + ..close(), + paint, + ); + } + @override bool shouldRepaint(covariant StrokePainter oldDelegate) => oldDelegate.borderRadius != borderRadius || diff --git a/test/dashed_stroke_caps_test.dart b/test/dashed_stroke_caps_test.dart new file mode 100644 index 00000000..5fe78836 --- /dev/null +++ b/test/dashed_stroke_caps_test.dart @@ -0,0 +1,170 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'stroke_side_test.dart' show pumpNode, strokePaint; + +/// A 100x100 circle (a fully rounded box) with a stroke of tiny dashes, like +/// the strobe dots on a turntable platter. +RectangleNode platter({ + required StrokeCapEnum cap, + List dashPattern = const [0, 10], + StrokeSide side = StrokeSide.all, +}) => RectangleNode( + id: 'platter', + name: 'Platter', + basicBoxLocal: NodeBox(0, 0, 100, 100), + cornerRadius: const CornerRadius.all(RadiusModel.circular(50)), + strokes: [PaintModel.solid(id: 'stroke', color: ColorRGB.fromHex('#000000'))], + strokeWeight: 4, + strokeAlign: StrokeAlignC.inside, + strokeCap: cap, + strokeSide: side, + dashPattern: dashPattern, +)..parentID = kRootNode; + +/// Paints [painter] into a [size] image and returns how many pixels have +/// any ink, so tests need no golden files. +Future inkedPixels(WidgetTester tester, CustomPainter painter, + {Size size = const Size(100, 100)}) async { + final int? count = await tester.runAsync(() async { + final ui.PictureRecorder recorder = ui.PictureRecorder(); + painter.paint(Canvas(recorder), size); + final ui.Image image = await recorder.endRecording().toImage( + size.width.toInt(), + size.height.toInt(), + ); + final ByteData bytes = (await image.toByteData())!; + int inked = 0; + for (int i = 3; i < bytes.lengthInBytes; i += 4) { + if (bytes.getUint8(i) > 0) inked++; + } + return inked; + }); + return count!; +} + +StrokePainter painterFor({ + required StrokeCapEnum cap, + List dashPattern = const [0, 10], + StrokeSide side = StrokeSide.all, +}) => StrokePainter( + strokeWidth: 4, + dashPattern: dashPattern, + strokeCap: cap, + strokeSide: side, + borderRadius: BorderRadius.circular(50), +); + +void main() { + testWidgets('Zero-length dashes with round caps paint a dot each, inside ' + 'the stroke band', (tester) async { + await pumpNode(tester, platter(cap: StrokeCapEnum.round)); + expect(tester.takeException(), isNull); + // The first dash sits at the start of the stroke's center line, which is + // half the stroke width inside the box. + expect( + strokePaint(), + paints + ..circle(radius: 2) + ..circle(radius: 2) + ..circle(radius: 2), + ); + // A circle of center line radius 48 is ~301.6 long: 31 dots, 10 apart. + final int inked = await inkedPixels( + tester, + painterFor(cap: StrokeCapEnum.round), + ); + // Each dot is a circle of radius 2 (~12.6 px). + expect(inked, greaterThan(31 * 8)); + }); + + testWidgets('Zero-length dashes with square caps paint a square each', ( + tester, + ) async { + await pumpNode(tester, platter(cap: StrokeCapEnum.square)); + expect(tester.takeException(), isNull); + expect(strokePaint(), paints..path()..path()..path()); + expect( + await inkedPixels(tester, painterFor(cap: StrokeCapEnum.square)), + greaterThan(31 * 10), + ); + }); + + testWidgets('Dashes without caps and no length still paint nothing', ( + tester, + ) async { + expect( + await inkedPixels(tester, painterFor(cap: StrokeCapEnum.none)), + 0, + ); + }); + + testWidgets('Tiny round dashes stay within the stroke band of the box', ( + tester, + ) async { + final int? outside = await tester.runAsync(() async { + final ui.PictureRecorder recorder = ui.PictureRecorder(); + painterFor( + cap: StrokeCapEnum.round, + dashPattern: const [0.001, 6], + ).paint(Canvas(recorder), const Size(100, 100)); + final ui.Image image = await recorder.endRecording().toImage(120, 120); + final ByteData bytes = (await image.toByteData())!; + int count = 0; + for (int y = 0; y < 120; y++) { + for (int x = 0; x < 120; x++) { + final double dx = x + 0.5 - 50, dy = y + 0.5 - 50; + final double r2 = dx * dx + dy * dy; + // Ink outside the circle, or deeper inside than the stroke reaches. + if ((r2 > 51 * 51 || r2 < 45 * 45) && + bytes.getUint8((y * 120 + x) * 4 + 3) > 0) { + count++; + } + } + } + return count; + }); + expect(outside, 0); + }); + + testWidgets('Round dashes with length paint along the stroke', ( + tester, + ) async { + final int dots = await inkedPixels( + tester, + painterFor(cap: StrokeCapEnum.round), + ); + final int dashes = await inkedPixels( + tester, + painterFor(cap: StrokeCapEnum.round, dashPattern: const [4, 6]), + ); + expect(dashes, greaterThan(dots)); + }); + + testWidgets('A dashed side with round caps draws dots too', (tester) async { + expect( + await inkedPixels( + tester, + painterFor(cap: StrokeCapEnum.round, side: StrokeSide.top), + ), + greaterThan(0), + ); + }); + + testWidgets('A pattern of zeros paints nothing and does not hang', ( + tester, + ) async { + expect( + await inkedPixels( + tester, + painterFor(cap: StrokeCapEnum.round, dashPattern: const [0, 0]), + ), + 0, + ); + }); +} From 50793d3050e21f4ea87fad4f94eea295e1b53e7d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:13:40 +0000 Subject: [PATCH 08/12] A link's parameters also set the variables they name Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik --- lib/src/codelessly.dart | 20 +++++++++++ test/route_variables_test.dart | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 test/route_variables_test.dart diff --git a/lib/src/codelessly.dart b/lib/src/codelessly.dart index f5959501..af278020 100644 --- a/lib/src/codelessly.dart +++ b/lib/src/codelessly.dart @@ -7,6 +7,7 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/widgets.dart'; import 'package:http/http.dart'; +import 'package:provider/provider.dart'; import '../codelessly_sdk.dart'; import 'functions/app_auth.dart'; @@ -899,12 +900,31 @@ class Codelessly { } } + /// Sets the variables a link's parameters name to their values, so + /// `//?selectedId=kind-of-blue` opens with `selectedId` set, + /// as well as passing them as the screen's route parameters. Parameters + /// that name no variable are only route parameters. + static void setVariablesFromRoute( + BuildContext context, + CodelesslyRoute route, + ) { + if (route.params.isEmpty) return; + final CodelesslyContext? codelesslyContext = + Provider.of(context, listen: false); + if (codelesslyContext == null) return; + for (final MapEntry param in route.params.entries) { + if (codelesslyContext.findVariableByName(param.key) == null) continue; + codelesslyContext.updateVariable(param.key, param.value); + } + } + /// Opens [pendingRoute] over the first screen shown, unless it is that /// screen. void _openPendingRoute(BuildContext context, String canvasId) { final CodelesslyRoute? pending = pendingRoute; if (pending == null) return; pendingRoute = null; + setVariablesFromRoute(context, pending); if (pending.canvasId == canvasId) return; unawaited( FunctionsRepository.navigate( diff --git a/test/route_variables_test.dart b/test/route_variables_test.dart new file mode 100644 index 00000000..272c735c --- /dev/null +++ b/test/route_variables_test.dart @@ -0,0 +1,64 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('A link sets the variables its parameters name', (tester) async { + final CodelesslyContext c = CodelesslyContext.empty() + ..variables['a'] = Observable( + VariableData(id: 'a', name: 'selectedId', value: ''), + ) + ..variables['b'] = Observable( + VariableData( + id: 'b', + name: 'spinning', + type: VariableType.boolean, + value: 'false', + ), + ); + late BuildContext captured; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: c, + child: Builder( + builder: (context) { + captured = context; + return const SizedBox(); + }, + ), + ), + ); + + Codelessly.setVariablesFromRoute( + captured, + const CodelesslyRoute('record', { + 'selectedId': 'kind-of-blue', + 'spinning': 'true', + 'from': 'link', + }), + ); + + expect(c.findVariableByName('selectedId')!.value.value, 'kind-of-blue'); + expect(c.findVariableByName('spinning')!.value.value, 'true'); + // A parameter that names no variable is only a route parameter. + expect(c.findVariableByName('from'), isNull); + }); + + testWidgets('Without a Codelessly context nothing happens', (tester) async { + late BuildContext captured; + await tester.pumpWidget( + Builder( + builder: (context) { + captured = context; + return const SizedBox(); + }, + ), + ); + Codelessly.setVariablesFromRoute( + captured, + const CodelesslyRoute('x', {'a': 'b'}), + ); + }); +} From c4d8e84b4020918a0cc849d898e1f2dc3fc9874b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:06:45 +0000 Subject: [PATCH 09/12] Image fills listen to the variables in their URL; concat and list + in formulas Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik --- lib/src/utils/extensions.dart | 22 +++++++++++++ lib/src/utils/formula.dart | 11 +++++++ test/formula_concat_fill_vars_test.dart | 41 +++++++++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 test/formula_concat_fill_vars_test.dart diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index de5cefd2..6eae72cd 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -2161,6 +2161,28 @@ extension BaseNodeExt on BaseNode { } } + // Image fills can name their image with a variable or formula + // (`${record.art}`); the node rebuilds when it changes. + if (this is GeometryMixin) { + for (final PaintModel fill in (this as GeometryMixin).fills) { + final String? url = fill.downloadUrl; + if (url == null || !url.contains(r'${')) continue; + final Iterable names = [ + for (final match in variableSyntaxIdentifierRegex.allMatches(url)) + if (VariableMatch.parse(match[0]!) case final VariableMatch m) + m.name, + for (final path in FormulaEvaluator.referencedPathsInText(url)) + path.split('.').first, + ]; + for (final String name in names) { + if (!includePredefined && predefinedVariableNames.contains(name)) { + continue; + } + usedVariables.add(name); + } + } + } + // Get variables from actions if (this is ReactionMixin) { for (final reaction in (this as ReactionMixin).reactions) { diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 4dfd6e05..ff62e95a 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -378,6 +378,11 @@ class FormulaEvaluator { 'without(list, other)', 'without(all, picked)', ), + FormulaFunctionDoc( + 'concat', + 'concat(list or item, ...)', + 'concat([record.art], sleeves) or list1 + list2', + ), FormulaFunctionDoc('number', 'number(value)', 'number("42") → 42'), FormulaFunctionDoc('text', 'text(value)', 'text(42) → "42"'), FormulaFunctionDoc( @@ -1214,6 +1219,10 @@ final Map _functions = { 'merge': _merge, 'set': (a) => _set(_arg(a, 0), _arg(a, 1), _arg(a, 2)), 'unique': _unique, + 'concat': (a) => [ + for (final value in a) + if (value is Iterable) ...value else if (value != null) value, + ], 'without': (a) => [ for (final item in _values(_arg(a, 0), null)) if (!_values(_arg(a, 1), null).any((other) => _looseEquals(item, other))) @@ -1426,6 +1435,8 @@ class _Binary extends _Node { final num? na = a is String ? null : _toNum(a); final num? nb = b is String ? null : _toNum(b); if (na != null && nb != null) return _tidy(na + nb); + // Lists join: [first] + rest. + if (a is List && b is List) return [...a, ...b]; return FormulaEvaluator.stringify(a) + FormulaEvaluator.stringify(b); case '-': return _tidy(_num(a) - _num(b)); diff --git a/test/formula_concat_fill_vars_test.dart b/test/formula_concat_fill_vars_test.dart new file mode 100644 index 00000000..a473a55d --- /dev/null +++ b/test/formula_concat_fill_vars_test.dart @@ -0,0 +1,41 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final Map vars = { + 'art': 'a.jpg', + 'sleeves': ['k.jpg', 'w.jpg'], + }; + Object? eval(String expression) => + FormulaEvaluator.evaluate(expression, (path) => vars[path]); + + test('lists join with concat and +', () { + expect(eval('concat([art], sleeves)'), ['a.jpg', 'k.jpg', 'w.jpg']); + expect(eval('concat(art, sleeves, null)'), ['a.jpg', 'k.jpg', 'w.jpg']); + expect(eval('[art] + sleeves'), ['a.jpg', 'k.jpg', 'w.jpg']); + expect(eval('"a" + 1'), 'a1'); + expect(eval('1 + 2'), 3); + }); + + test('an image fill bound to a variable or formula is listened to', () { + RectangleNode node(String url) => RectangleNode( + id: 'r', + name: 'r', + basicBoxLocal: NodeBox(0, 0, 10, 10), + fills: [ + PaintModel( + id: 'f', + type: PaintType.image, + downloadUrl: url, + ), + ], + ); + expect(node(r'${fArt}').getUsedVariableNames(), {'fArt'}); + expect( + node(r'${= editing ? record.art : fallback}').getUsedVariableNames(), + {'editing', 'record', 'fallback'}, + ); + expect(node('https://x/y.jpg').getUsedVariableNames(), isEmpty); + }); +} From 8aa0c23b9d826992a2a5e770336b29b5fa040e70 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:19:17 +0000 Subject: [PATCH 10/12] Set a screen's route before its load actions run, link included Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik --- lib/src/codelessly.dart | 25 ++++++++++++- .../passive_canvas_transformer.dart | 1 + test/route_variables_test.dart | 35 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/src/codelessly.dart b/lib/src/codelessly.dart index af278020..a6c5234f 100644 --- a/lib/src/codelessly.dart +++ b/lib/src/codelessly.dart @@ -892,7 +892,7 @@ class Codelessly { _currentNavigatedCanvasId = canvasId; final ModalRoute? modal = ModalRoute.of(context); if (canvasId != null && modal is! PopupRoute) { - route.value = CodelesslyRoute.of(canvasId, modal?.settings.arguments); + route.value = _routeOf(canvasId, modal); _openPendingRoute(context, canvasId); } for (final listener in [..._navigationListeners.values]) { @@ -900,6 +900,29 @@ class Codelessly { } } + /// The route of screen [canvasId] shown by [modal]: the link that opened + /// the app when it names this screen, or the parameters it was opened + /// with. + CodelesslyRoute _routeOf(String canvasId, ModalRoute? modal) { + final CodelesslyRoute? pending = pendingRoute; + if (pending != null && pending.canvasId == canvasId) return pending; + return CodelesslyRoute.of(canvasId, modal?.settings.arguments); + } + + /// Makes [route] screen [canvasId]'s before its load actions run, so they + /// can read `${route.name}`, and applies the link that opened the app to + /// it when the link names this screen (navigation listeners only hear of + /// a screen after its first frame). + void prepareRoute(BuildContext context, String canvasId) { + final ModalRoute? modal = ModalRoute.of(context); + if (modal is PopupRoute) return; + route.value = _routeOf(canvasId, modal); + final CodelesslyRoute? pending = pendingRoute; + if (pending != null && pending.canvasId == canvasId) { + setVariablesFromRoute(context, pending); + } + } + /// Sets the variables a link's parameters name to their values, so /// `//?selectedId=kind-of-blue` opens with `selectedId` set, /// as well as passing them as the screen's route parameters. Parameters diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 49a6d3fd..331631fd 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -845,6 +845,7 @@ class _PassiveCanvasWidgetState extends State { if (_redirected) return; } + context.read()?.prepareRoute(context, widget.node.id); triggerOnLoadActions(context); _watchValues(context); _startTimers(); diff --git a/test/route_variables_test.dart b/test/route_variables_test.dart index 272c735c..e89e4078 100644 --- a/test/route_variables_test.dart +++ b/test/route_variables_test.dart @@ -61,4 +61,39 @@ void main() { const CodelesslyRoute('x', {'a': 'b'}), ); }); + + testWidgets('A link to the screen shown first applies before its load actions', ( + tester, + ) async { + final Codelessly codelessly = Codelessly(); + codelessly.pendingRoute = const CodelesslyRoute('crates', { + 'loadState': 'loading', + 'debugHold': '1', + }); + final CodelesslyContext c = CodelesslyContext.empty() + ..variables['s'] = Observable( + VariableData(id: 's', name: 'loadState', value: 'ready'), + ); + late BuildContext captured; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: c, + child: Builder( + builder: (context) { + captured = context; + return const SizedBox(); + }, + ), + ), + ); + + codelessly.prepareRoute(captured, 'crates'); + expect(codelessly.route.value?.params['debugHold'], '1'); + expect(c.findVariableByName('loadState')!.value.value, 'loading'); + + // Another screen gets its own (empty) route. + codelessly.prepareRoute(captured, 'ledger'); + expect(codelessly.route.value?.canvasId, 'ledger'); + expect(codelessly.route.value?.params, isEmpty); + }); } From 68d717d76cfeac4c56b04450763645b7401cadaf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:39:13 +0000 Subject: [PATCH 11/12] Motion entrances from values with replay, rectangular slider thumbs, canvas slider bindings, formula paths - NodeMotion plays an entrance from enterFrom and again when the bound replayKey changes. - RoundedRectSliderThumbShape; PassiveSliderWidget takes the scope its bindings read. - A node's used variables include the paths its formulas read. - Lint fixes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik --- .../passive_canvas_transformer.dart | 3 +- .../passive_slider_transformer.dart | 133 +++++++++++-- .../widget_node_transformer_manager.dart | 17 +- lib/src/ui/codelessly_widget.dart | 5 +- lib/src/ui/node_motion.dart | 15 +- lib/src/utils/extensions.dart | 22 ++- lib/src/utils/formula.dart | 6 +- test/computed_variables_test.dart | 9 +- test/dashed_stroke_caps_test.dart | 20 +- test/formula_concat_fill_vars_test.dart | 8 +- test/formula_object_literal_paths_test.dart | 186 ++++++++++++++++++ test/formula_strings_objects_test.dart | 7 +- test/node_motion_entrance_test.dart | 151 ++++++++++++++ test/node_motion_loop_test.dart | 28 +-- test/route_variables_test.dart | 61 +++--- test/slider_thumb_padding_test.dart | 150 ++++++++++++++ test/timer_key_swipe_triggers_test.dart | 2 +- 17 files changed, 728 insertions(+), 95 deletions(-) create mode 100644 test/formula_object_literal_paths_test.dart create mode 100644 test/node_motion_entrance_test.dart create mode 100644 test/slider_thumb_padding_test.dart diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 331631fd..68a1aa02 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -920,7 +920,8 @@ class _PassiveCanvasWidgetState extends State { statusBarIconBrightness: brightness, statusBarBrightness: brightness, ), - child: widget.settings.isPreview || + child: + widget.settings.isPreview || !widget.node.reactions.any( (reaction) => reaction.trigger.type == TriggerType.keyPress, ) diff --git a/lib/src/transformers/node_transformers/passive_slider_transformer.dart b/lib/src/transformers/node_transformers/passive_slider_transformer.dart index faa897cf..445f06d1 100644 --- a/lib/src/transformers/node_transformers/passive_slider_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_slider_transformer.dart @@ -58,11 +58,17 @@ class PassiveSliderWidget extends StatelessWidget { final WidgetBuildSettings settings; final List variablesOverrides; + /// The values the slider's bound `value`, `min` and `max` read, such as a + /// design canvas's variable defaults. When null they come from [context] + /// with [variablesOverrides]. + final ScopedValues? scopedValues; + const PassiveSliderWidget({ super.key, required this.node, required this.settings, this.variablesOverrides = const [], + this.scopedValues, }); void onChanged(BuildContext context, double internalValue) { @@ -85,10 +91,9 @@ class PassiveSliderWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final ScopedValues scopedValues = ScopedValues.of( - context, - variablesOverrides: variablesOverrides, - ); + final ScopedValues scopedValues = + this.scopedValues ?? + ScopedValues.of(context, variablesOverrides: variablesOverrides); final double value = PropertyValueDelegate.getPropertyValue( node, @@ -204,13 +209,33 @@ class TransformerSlider extends StatelessWidget { inactiveTrackColor: node.properties.inactiveTrackColor .toFlutterColor(), thumbColor: node.properties.thumbColor.toFlutterColor(), - thumbShape: StrokedSliderThumbShape( - enabledThumbRadius: node.properties.showThumb - ? node.properties.thumbRadius - : 0, - strokeColor: node.properties.thumbStrokeColor?.toFlutterColor(), - strokeWidth: node.properties.thumbStrokeWidth, - ), + thumbShape: switch (node.properties.thumbShape) { + SliderThumbShapeEnum.circle => StrokedSliderThumbShape( + enabledThumbRadius: node.properties.showThumb + ? node.properties.thumbRadius + : 0, + strokeColor: node.properties.thumbStrokeColor?.toFlutterColor(), + strokeWidth: node.properties.thumbStrokeWidth, + ), + // The slider lays out horizontally and a vertical one is turned + // a quarter turn, so its thumb's width and height swap. + SliderThumbShapeEnum.roundedRect => RoundedRectSliderThumbShape( + size: !node.properties.showThumb + ? Size.zero + : vertical + ? Size( + node.properties.thumbHeight, + node.properties.thumbWidth, + ) + : Size( + node.properties.thumbWidth, + node.properties.thumbHeight, + ), + cornerRadius: node.properties.thumbCornerRadius, + strokeColor: node.properties.thumbStrokeColor?.toFlutterColor(), + strokeWidth: node.properties.thumbStrokeWidth, + ), + }, trackShape: trackShape, // GappedSliderTrackShape requires a gap; 6 is the Material 3 // (2024) default. @@ -340,3 +365,89 @@ class StrokedSliderThumbShape extends RoundSliderThumbShape { ); } } + +/// A rounded rectangle slider thumb, like a fader cap, with an optional +/// stroke drawn inside its edge. [size] is in the slider's own (horizontal) +/// layout: its width runs along the track. +class RoundedRectSliderThumbShape extends SliderComponentShape { + /// Creates a [RoundedRectSliderThumbShape]. + const RoundedRectSliderThumbShape({ + required this.size, + this.cornerRadius = 0, + this.strokeColor, + this.strokeWidth = 0, + this.elevation = 1, + this.pressedElevation = 6, + }); + + /// Size of the thumb; along the track, then across it. + final Size size; + + /// Radius of the thumb's corners, at most half its shorter side. + final double cornerRadius; + + /// Color of the stroke; no stroke when null. + final Color? strokeColor; + + /// Width of the stroke; no stroke when 0. + final double strokeWidth; + + /// Shadow elevation of the thumb at rest. + final double elevation; + + /// Shadow elevation of the thumb while it is dragged. + final double pressedElevation; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) => size; + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + if (size.isEmpty) return; + final Canvas canvas = context.canvas; + final Color color = ColorTween( + begin: sliderTheme.disabledThumbColor, + end: sliderTheme.thumbColor, + ).evaluate(enableAnimation)!; + final double radius = math.max( + 0, + math.min(cornerRadius, size.shortestSide / 2), + ); + final RRect rect = RRect.fromRectAndRadius( + Rect.fromCenter(center: center, width: size.width, height: size.height), + Radius.circular(radius), + ); + final double shadow = Tween( + begin: elevation, + end: pressedElevation, + ).evaluate(activationAnimation); + if (shadow > 0 && !debugDisableShadows) { + canvas.drawShadow(Path()..addRRect(rect), Colors.black, shadow, true); + } + canvas.drawRRect(rect, Paint()..color = color); + + final Color? stroke = strokeColor; + if (stroke == null || strokeWidth <= 0) return; + final double width = math.min(strokeWidth, size.shortestSide / 2); + canvas.drawRRect( + rect.deflate(width / 2), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = width + ..color = stroke, + ); + } +} diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 7c309e25..59e1c75a 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -145,8 +145,9 @@ abstract class WidgetNodeTransformerManager } /// The motion [node] shows now: its [BaseNode.motion] with the values - /// bound through `motion.` variables resolved from [scopedValues]. - /// Null when the node doesn't move. + /// bound through `motion.` variables resolved from [scopedValues], + /// and `motion.replayKey` resolved to text. Null when the node doesn't + /// move. static MotionModel? resolveMotion( BaseNode node, { required ScopedValues scopedValues, @@ -169,6 +170,18 @@ abstract class WidgetNodeTransformerManager if (number == null || !number.isFinite) continue; motion = (motion ?? MotionModel.none).withValue(property, number); } + const String replay = MotionModel.replayKeyProperty; + if (node.variables.containsKey(MotionModel.variableKey(replay))) { + final Object? value = PropertyValueDelegate.getPropertyValue( + node, + MotionModel.variableKey(replay), + scopedValues: scopedValues, + ); + motion = (motion ?? MotionModel.none).copyWith( + replayKey: value?.toString(), + clearReplayKey: value == null, + ); + } return motion; } diff --git a/lib/src/ui/codelessly_widget.dart b/lib/src/ui/codelessly_widget.dart index 41b651d3..968adb87 100644 --- a/lib/src/ui/codelessly_widget.dart +++ b/lib/src/ui/codelessly_widget.dart @@ -542,10 +542,7 @@ class _CodelesslyWidgetState extends State { final SDKPublishModel model = snapshot.data!; effectiveLayoutID = _effectiveController.layoutID ?? - CodelesslyWidget.layoutIdForScreen( - model, - widget.initialScreen, - ) ?? + CodelesslyWidget.layoutIdForScreen(model, widget.initialScreen) ?? model.entryLayoutId; if (effectiveLayoutID == null) { diff --git a/lib/src/ui/node_motion.dart b/lib/src/ui/node_motion.dart index c68c650c..9c9a6c88 100644 --- a/lib/src/ui/node_motion.dart +++ b/lib/src/ui/node_motion.dart @@ -90,8 +90,8 @@ class _NodeMotionState extends State with TickerProviderStateMixin { final MotionModel motion = widget.motion; if (_loops) { _startLoop(); - } else if (widget.animate && motion.enter && motion.durationMs > 0) { - _from = motion.atRest; + } else if (widget.animate && motion.hasEntrance && motion.durationMs > 0) { + _from = motion.entranceStart; _run(motion.durationMs); } _syncSpin(); @@ -140,7 +140,16 @@ class _NodeMotionState extends State with TickerProviderStateMixin { void didUpdateWidget(NodeMotion oldWidget) { super.didUpdateWidget(oldWidget); final MotionModel next = widget.motion; - if (_loops) { + final bool replay = + widget.animate && next.replayKey != oldWidget.motion.replayKey; + if (_loops && replay) { + _startLoop(); + } else if (replay && next.durationMs > 0) { + // Plays the entrance again, wherever the node was. + _from = next.entranceStart; + _to = next; + _run(next.durationMs); + } else if (_loops) { final MotionModel old = oldWidget.motion; if (!_sameTransform(next, _to) || next.loop != old.loop || diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 6eae72cd..1a16aed1 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -1890,11 +1890,18 @@ extension ActionModelExt on ActionModel { return _namesIn(strings, includePredefined); } + /// The variables [strings] read: their `${path}`s and the paths their + /// formulas read, such as `records` in + /// `${= map(records, r => merge(r, {plays: r.plays + 1}))}`. static Set _namesIn(List strings, bool includePredefined) { return strings - .map((text) => VariableMatch.parseAll(text)) - .expand((matches) => matches) - .map((match) => match.name) + .expand( + (text) => [ + for (final match in VariableMatch.parseAll(text)) match.name, + for (final path in FormulaEvaluator.referencedPathsInText(text)) + path.split(RegExp(r'[.\[]')).first, + ], + ) .where( (name) => includePredefined || !predefinedVariableNames.contains(name), @@ -2140,7 +2147,14 @@ extension BaseNodeExt on BaseNode { /// Collects all the variable names used in this node. Set getUsedVariableNames({bool includePredefined = false}) { final Set usedVariables = {}; - for (final path in variables.values) { + // A property bound to a formula reads the paths in it. + final Iterable boundPaths = variables.values.expand( + (path) => switch (FormulaEvaluator.exactExpression(path)) { + final String expression => FormulaEvaluator.referencedPaths(expression), + null => [path], + }, + ); + for (final path in boundPaths) { final match = VariableMatch.parse(path.wrapWithVariableSyntax()); if (match == null) continue; diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index ff62e95a..e27c7880 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -1,5 +1,7 @@ import 'dart:math' as math; -import 'package:codelessly_api/codelessly_api.dart' show variableSyntaxIdentifierRegex; + +import 'package:codelessly_api/codelessly_api.dart' + show variableSyntaxIdentifierRegex; /// The body of a formula: text and quoted strings, with braces only in /// balanced pairs nested up to [depth] deep, such as the object literal in @@ -1221,7 +1223,7 @@ final Map _functions = { 'unique': _unique, 'concat': (a) => [ for (final value in a) - if (value is Iterable) ...value else if (value != null) value, + if (value is Iterable) ...value else ?value, ], 'without': (a) => [ for (final item in _values(_arg(a, 0), null)) diff --git a/test/computed_variables_test.dart b/test/computed_variables_test.dart index 3a26f903..7580056b 100644 --- a/test/computed_variables_test.dart +++ b/test/computed_variables_test.dart @@ -181,10 +181,11 @@ void main() { ), ], )..bindComputedVariables(); - expect( - ComputedVariables.cyclicNames(context.variableNamesMap()), - {'a', 'b', 'self'}, - ); + expect(ComputedVariables.cyclicNames(context.variableNamesMap()), { + 'a', + 'b', + 'self', + }); expect(read(r'${a}', context), ''); expect(read(r'${b}', context), ''); expect(read(r'${self}', context), ''); diff --git a/test/dashed_stroke_caps_test.dart b/test/dashed_stroke_caps_test.dart index 5fe78836..76c9b899 100644 --- a/test/dashed_stroke_caps_test.dart +++ b/test/dashed_stroke_caps_test.dart @@ -29,8 +29,11 @@ RectangleNode platter({ /// Paints [painter] into a [size] image and returns how many pixels have /// any ink, so tests need no golden files. -Future inkedPixels(WidgetTester tester, CustomPainter painter, - {Size size = const Size(100, 100)}) async { +Future inkedPixels( + WidgetTester tester, + CustomPainter painter, { + Size size = const Size(100, 100), +}) async { final int? count = await tester.runAsync(() async { final ui.PictureRecorder recorder = ui.PictureRecorder(); painter.paint(Canvas(recorder), size); @@ -88,7 +91,13 @@ void main() { ) async { await pumpNode(tester, platter(cap: StrokeCapEnum.square)); expect(tester.takeException(), isNull); - expect(strokePaint(), paints..path()..path()..path()); + expect( + strokePaint(), + paints + ..path() + ..path() + ..path(), + ); expect( await inkedPixels(tester, painterFor(cap: StrokeCapEnum.square)), greaterThan(31 * 10), @@ -98,10 +107,7 @@ void main() { testWidgets('Dashes without caps and no length still paint nothing', ( tester, ) async { - expect( - await inkedPixels(tester, painterFor(cap: StrokeCapEnum.none)), - 0, - ); + expect(await inkedPixels(tester, painterFor(cap: StrokeCapEnum.none)), 0); }); testWidgets('Tiny round dashes stay within the stroke band of the box', ( diff --git a/test/formula_concat_fill_vars_test.dart b/test/formula_concat_fill_vars_test.dart index a473a55d..6b51e68f 100644 --- a/test/formula_concat_fill_vars_test.dart +++ b/test/formula_concat_fill_vars_test.dart @@ -23,13 +23,7 @@ void main() { id: 'r', name: 'r', basicBoxLocal: NodeBox(0, 0, 10, 10), - fills: [ - PaintModel( - id: 'f', - type: PaintType.image, - downloadUrl: url, - ), - ], + fills: [PaintModel(id: 'f', type: PaintType.image, downloadUrl: url)], ); expect(node(r'${fArt}').getUsedVariableNames(), {'fArt'}); expect( diff --git a/test/formula_object_literal_paths_test.dart b/test/formula_object_literal_paths_test.dart new file mode 100644 index 00000000..30b431fc --- /dev/null +++ b/test/formula_object_literal_paths_test.dart @@ -0,0 +1,186 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +/// The formula a crate digger counts plays with: an object literal inside a +/// lambda inside `${= }`. +const String countPlay = + r'${= map(records, r => r.id == x ? merge(r, {plays: r.plays + 1}) : r)}'; + +const List> crate = [ + {'id': 'a', 'plays': 1}, + {'id': 'b', 'plays': 4}, +]; + +CodelesslyContext crateContext() => CodelesslyContext.empty() + ..variables['records'] = Observable( + VariableData( + id: 'records', + name: 'records', + type: VariableType.list, + value: jsonEncode(crate), + ), + ) + ..variables['x'] = Observable( + VariableData(id: 'x', name: 'x', type: VariableType.text, value: 'b'), + ) + ..variables['label'] = Observable( + VariableData(id: 'label', name: 'label', type: VariableType.text), + ); + +Future pumpContext( + WidgetTester tester, + CodelesslyContext codelesslyContext, +) async { + late BuildContext captured; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: Builder( + builder: (context) { + captured = context; + return const SizedBox(); + }, + ), + ), + ); + return captured; +} + +List recordsOf(CodelesslyContext c) { + final Object? value = c.variables['records']!.value.getValue(); + return value is String ? jsonDecode(value) as List : value as List; +} + +void main() { + test('The regexes keep a formula with object literals whole', () { + expect(exactFormulaRegex.hasMatch(countPlay), isTrue); + expect(FormulaEvaluator.exactExpression(countPlay), isNotNull); + expect(formulaRegex.firstMatch('n: $countPlay!')!.group(0), countPlay); + }); + + testWidgets('A set variable action takes an object literal formula', ( + tester, + ) async { + final CodelesslyContext c = crateContext(); + final BuildContext context = await pumpContext(tester, c); + final bool set = FunctionsRepository.setVariableFromAction( + context, + SetVariableAction( + variable: VariableData( + id: 'records', + name: 'records', + type: VariableType.list, + ), + newValue: countPlay, + ), + ); + expect(set, isTrue); + expect(recordsOf(c), [ + {'id': 'a', 'plays': 1}, + {'id': 'b', 'plays': 5}, + ]); + }); + + testWidgets('A text variable set to text around such a formula', ( + tester, + ) async { + final CodelesslyContext c = crateContext(); + final BuildContext context = await pumpContext(tester, c); + FunctionsRepository.setVariableFromAction( + context, + SetVariableAction( + variable: VariableData( + id: 'label', + name: 'label', + type: VariableType.text, + ), + newValue: r'Plays: ${= merge({n: 2}, {n: 3}).n} of ${x}', + ), + ); + expect(c.variables['label']!.value.value, 'Plays: 3 of b'); + }); + + testWidgets('A property binding and a text read such a formula', ( + tester, + ) async { + final CodelesslyContext c = crateContext(); + final BuildContext context = await pumpContext(tester, c); + final ScopedValues scopedValues = ScopedValues.of(context); + final TextNode text = TextNode( + id: 'count', + name: 'count', + basicBoxLocal: NodeBox(0, 0, 100, 20), + characters: '', + variables: { + 'data': countPlay, + 'characters': + r'${= count(filter(' + r'map(records, r => merge(r, {hot: r.plays > 2})), ' + r'r => r.hot))} hot', + }, + ); + final List? mapped = PropertyValueDelegate.getPropertyValue( + text, + 'data', + scopedValues: scopedValues, + ); + expect(mapped, [ + {'id': 'a', 'plays': 1}, + {'id': 'b', 'plays': 5}, + ]); + expect( + PropertyValueDelegate.substituteVariables( + text.variables['characters']!, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + scopedValues: scopedValues, + ), + '1 hot', + ); + }); + + test('Variable syntax matches keep such a formula whole', () { + expect( + [ + for (final m in variableSyntaxIdentifierRegex.allMatches( + 'Played $countPlay times by \${user.name}', + )) + m[0], + ], + [countPlay, r'${user.name}'], + ); + expect(countPlay.wrapWithVariableSyntax(), countPlay); + expect(countPlay.containsUncheckedVariablePath, isTrue); + // A quoted brace doesn't end it either, and a path still matches alone. + expect( + variableSyntaxIdentifierRegex.firstMatch(r"${= '}' + x} ${y}")![0], + r"${= '}' + x}", + ); + }); + + test('The variables an action or binding reads include its formula', () { + final RectangleNode node = RectangleNode( + id: 'row', + name: 'row', + basicBoxLocal: NodeBox(0, 0, 10, 10), + reactions: [ + Reaction( + trigger: TriggerModel(type: TriggerType.click), + action: SetVariableAction( + variable: VariableData( + id: 'records', + name: 'records', + type: VariableType.list, + ), + newValue: countPlay, + ), + ), + ], + )..variables['visible'] = r'${= count(filter(crate, c => c.on)) > 0}'; + expect(node.getUsedVariableNames(), {'records', 'x', 'crate'}); + }); +} diff --git a/test/formula_strings_objects_test.dart b/test/formula_strings_objects_test.dart index 0fd4b288..519aa819 100644 --- a/test/formula_strings_objects_test.dart +++ b/test/formula_strings_objects_test.dart @@ -212,12 +212,7 @@ void main() { 'd', 'b', ]); - expect(ids('sortBy(records, r => r.year, "desc")'), [ - 'c', - 'b', - 'a', - 'd', - ]); + expect(ids('sortBy(records, r => r.year, "desc")'), ['c', 'b', 'a', 'd']); expect(eval('sortBy([2, 3, 1], null, "desc")'), [3, 2, 1]); expect(eval('sortByDesc([2, 3, 1])'), [3, 2, 1]); }); diff --git a/test/node_motion_entrance_test.dart b/test/node_motion_entrance_test.dart new file mode 100644 index 00000000..e3f0f892 --- /dev/null +++ b/test/node_motion_entrance_test.dart @@ -0,0 +1,151 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future pump( + WidgetTester tester, + MotionModel motion, { + bool animate = true, +}) => tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: NodeMotion( + motion: motion, + animate: animate, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), +); + +double opacityOf(WidgetTester tester) { + final Finder finder = find.byType(Opacity); + return finder.evaluate().isEmpty + ? 1 + : tester.widget(finder.first).opacity; +} + +double scaleOf(WidgetTester tester) { + final Finder finder = find.descendant( + of: find.byType(NodeMotion), + matching: find.byType(Transform), + ); + if (finder.evaluate().isEmpty) return 1; + return tester.widget(finder.first).transform.getMaxScaleOnAxis(); +} + +/// A record that fades and settles in from slightly too big. +const MotionModel record = MotionModel( + durationMs: 400, + curve: MotionCurve.linear, + enterFrom: MotionModel(scale: 1.15, opacity: 0), +); + +void main() { + testWidgets('An entrance starts from enterFrom and settles at rest', ( + tester, + ) async { + await pump(tester, record); + expect(opacityOf(tester), 0); + expect(scaleOf(tester), closeTo(1.15, 0.001)); + await tester.pump(const Duration(milliseconds: 200)); + expect(opacityOf(tester), closeTo(0.5, 0.01)); + expect(scaleOf(tester), closeTo(1.075, 0.001)); + await tester.pump(const Duration(milliseconds: 250)); + expect(opacityOf(tester), 1); + expect(scaleOf(tester), 1); + }); + + testWidgets('An entrance from values settles on the node values', ( + tester, + ) async { + await pump( + tester, + const MotionModel( + opacity: 0.8, + durationMs: 400, + curve: MotionCurve.linear, + enter: true, + enterFrom: MotionModel(opacity: 0), + ), + ); + expect(opacityOf(tester), 0); + await tester.pump(const Duration(milliseconds: 200)); + expect(opacityOf(tester), closeTo(0.4, 0.01)); + await tester.pump(const Duration(milliseconds: 250)); + expect(opacityOf(tester), closeTo(0.8, 0.001)); + }); + + testWidgets('Changing replayKey plays the entrance again', (tester) async { + await pump(tester, record.copyWith(replayKey: 'a')); + await tester.pump(const Duration(milliseconds: 500)); + expect(opacityOf(tester), 1); + + // Same key: nothing moves. + await pump(tester, record.copyWith(replayKey: 'a')); + await tester.pump(const Duration(milliseconds: 100)); + expect(opacityOf(tester), 1); + + await pump(tester, record.copyWith(replayKey: 'b')); + expect(opacityOf(tester), 0); + expect(scaleOf(tester), closeTo(1.15, 0.001)); + await tester.pump(const Duration(milliseconds: 200)); + expect(opacityOf(tester), closeTo(0.5, 0.01)); + await tester.pump(const Duration(milliseconds: 250)); + expect(opacityOf(tester), 1); + }); + + testWidgets('A replay without enterFrom starts from rest', (tester) async { + const MotionModel motion = MotionModel( + opacity: 0.2, + durationMs: 400, + curve: MotionCurve.linear, + ); + await pump(tester, motion.copyWith(replayKey: '1')); + expect(opacityOf(tester), closeTo(0.2, 0.001), reason: 'no entrance'); + await pump(tester, motion.copyWith(replayKey: '2')); + expect(opacityOf(tester), 1); + await tester.pump(const Duration(milliseconds: 200)); + expect(opacityOf(tester), closeTo(0.6, 0.01)); + await tester.pump(const Duration(milliseconds: 250)); + expect(opacityOf(tester), closeTo(0.2, 0.001)); + }); + + testWidgets('The design canvas ignores entrances and replays', ( + tester, + ) async { + await pump(tester, record.copyWith(replayKey: 'a'), animate: false); + expect(opacityOf(tester), 1); + await pump(tester, record.copyWith(replayKey: 'b'), animate: false); + expect(opacityOf(tester), 1); + }); + + test('enterFrom and replayKey survive JSON', () { + final MotionModel motion = record.copyWith(replayKey: 'x'); + final Map json = motion.toJson(); + expect(json['enterFrom'], {'scale': 1.15, 'opacity': 0.0}); + expect(MotionModel.fromJson(json), motion); + expect(motion.copyWith(clearEnterFrom: true).enterFrom, isNull); + expect(MotionModel.none.toJson().containsKey('enterFrom'), isFalse); + }); + + test('motion.replayKey resolves from a variable to text', () { + final RectangleNode node = + RectangleNode( + id: 'disc', + name: 'Disc', + basicBoxLocal: NodeBox(0, 0, 100, 100), + ) + ..motion = record + ..variables[MotionModel.variableKey(MotionModel.replayKeyProperty)] = + r'${= 2 + 5}'; + final MotionModel resolved = WidgetNodeTransformerManager.resolveMotion( + node, + scopedValues: ScopedValues.empty(), + )!; + expect(resolved.replayKey, '7'); + expect(resolved.enterFrom, record.enterFrom); + }); +} diff --git a/test/node_motion_loop_test.dart b/test/node_motion_loop_test.dart index ff25a29e..c1f93b43 100644 --- a/test/node_motion_loop_test.dart +++ b/test/node_motion_loop_test.dart @@ -3,20 +3,22 @@ import 'package:codelessly_sdk/codelessly_sdk.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -Future pump(WidgetTester tester, MotionModel motion, - {bool animate = true}) => - tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: NodeMotion( - motion: motion, - animate: animate, - child: const SizedBox(width: 100, height: 100), - ), - ), +Future pump( + WidgetTester tester, + MotionModel motion, { + bool animate = true, +}) => tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: NodeMotion( + motion: motion, + animate: animate, + child: const SizedBox(width: 100, height: 100), ), - ); + ), + ), +); double opacityOf(WidgetTester tester) { final Finder finder = find.byType(Opacity); diff --git a/test/route_variables_test.dart b/test/route_variables_test.dart index e89e4078..a1245764 100644 --- a/test/route_variables_test.dart +++ b/test/route_variables_test.dart @@ -62,38 +62,39 @@ void main() { ); }); - testWidgets('A link to the screen shown first applies before its load actions', ( - tester, - ) async { - final Codelessly codelessly = Codelessly(); - codelessly.pendingRoute = const CodelesslyRoute('crates', { - 'loadState': 'loading', - 'debugHold': '1', - }); - final CodelesslyContext c = CodelesslyContext.empty() - ..variables['s'] = Observable( - VariableData(id: 's', name: 'loadState', value: 'ready'), + testWidgets( + 'A link to the screen shown first applies before its load actions', + (tester) async { + final Codelessly codelessly = Codelessly(); + codelessly.pendingRoute = const CodelesslyRoute( + 'crates', + {'loadState': 'loading', 'debugHold': '1'}, ); - late BuildContext captured; - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: c, - child: Builder( - builder: (context) { - captured = context; - return const SizedBox(); - }, + final CodelesslyContext c = CodelesslyContext.empty() + ..variables['s'] = Observable( + VariableData(id: 's', name: 'loadState', value: 'ready'), + ); + late BuildContext captured; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: c, + child: Builder( + builder: (context) { + captured = context; + return const SizedBox(); + }, + ), ), - ), - ); + ); - codelessly.prepareRoute(captured, 'crates'); - expect(codelessly.route.value?.params['debugHold'], '1'); - expect(c.findVariableByName('loadState')!.value.value, 'loading'); + codelessly.prepareRoute(captured, 'crates'); + expect(codelessly.route.value?.params['debugHold'], '1'); + expect(c.findVariableByName('loadState')!.value.value, 'loading'); - // Another screen gets its own (empty) route. - codelessly.prepareRoute(captured, 'ledger'); - expect(codelessly.route.value?.canvasId, 'ledger'); - expect(codelessly.route.value?.params, isEmpty); - }); + // Another screen gets its own (empty) route. + codelessly.prepareRoute(captured, 'ledger'); + expect(codelessly.route.value?.canvasId, 'ledger'); + expect(codelessly.route.value?.params, isEmpty); + }, + ); } diff --git a/test/slider_thumb_padding_test.dart b/test/slider_thumb_padding_test.dart new file mode 100644 index 00000000..f291f018 --- /dev/null +++ b/test/slider_thumb_padding_test.dart @@ -0,0 +1,150 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +SliderNode slider(Map properties, {double width = 300}) => + SliderNode.fromJson({ + 'id': 'slider', + 'name': 'slider', + 'type': 'slider', + 'basicBoxLocal': [0, 0, width, 48], + 'value': 0, + 'properties': properties, + })..parentID = kRootNode; + +/// Pumps [node] and returns the value a tap at [dx] picks. +Future valueAt(WidgetTester tester, SliderNode node, double dx) async { + final values = []; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: TransformerSlider( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + onChanged: values.add, + value: 0, + ), + ), + ), + ), + ); + await tester.tapAt(Offset(dx, 24)); + await tester.pump(); + return values.last; +} + +void main() { + group('Slider padding', () { + testWidgets('Without padding, the track is inset for the thumb overlay', ( + tester, + ) async { + final SliderNode node = slider({}); + expect(node.properties.padding, isNull); + // The track starts 24px in (the overlay radius) and spans 252px. + expect(await valueAt(tester, node, 24 + 126), closeTo(50, 0.5)); + }); + + testWidgets('padding: 0 runs the track edge to edge', (tester) async { + final SliderNode node = slider({'padding': 0}); + expect(node.properties.padding, EdgeInsetsModel.zero); + expect(node.toJson()['properties'], {'padding': 0}); + expect(await valueAt(tester, node, 150), closeTo(50, 0.5)); + expect(await valueAt(tester, node, 75), closeTo(25, 0.5)); + }); + + testWidgets('Set padding insets the track by that much', (tester) async { + final SliderNode node = slider({ + 'padding': [10, 0], + }); + // 10px each side: the track is 280px wide. + expect(await valueAt(tester, node, 10 + 70), closeTo(25, 0.5)); + }); + }); + + group('Slider thumb shape', () { + testWidgets('A round thumb is the default', (tester) async { + final SliderNode node = slider({}); + expect(node.properties.thumbShape, SliderThumbShapeEnum.circle); + await valueAt(tester, node, 150); + final SliderTheme theme = tester.widget(find.byType(SliderTheme).last); + expect(theme.data.thumbShape, isA()); + }); + + testWidgets('A rounded rectangle thumb has its width and height', ( + tester, + ) async { + final SliderNode node = slider({ + 'thumbShape': 'roundedRect', + 'thumbWidth': 12, + 'thumbHeight': 30, + 'thumbCornerRadius': 3, + 'thumbStrokeColor': ColorRGBA.white.toJson(), + 'thumbStrokeWidth': 1, + }); + expect(SliderNode.fromJson(node.toJson()).properties, node.properties); + await valueAt(tester, node, 150); + final SliderTheme theme = tester.widget(find.byType(SliderTheme).last); + final shape = theme.data.thumbShape as RoundedRectSliderThumbShape; + expect(shape.size, const Size(12, 30)); + expect(shape.cornerRadius, 3); + expect(shape.strokeWidth, 1); + expect(shape.strokeColor, Colors.white); + expect(tester.takeException(), isNull); + }); + + testWidgets('A vertical slider swaps the thumb into its own layout', ( + tester, + ) async { + final SliderNode node = SliderNode.fromJson({ + 'id': 'fader', + 'name': 'fader', + 'type': 'slider', + 'basicBoxLocal': [0, 0, 48, 300], + 'value': 0, + 'properties': { + 'orientation': 'vertical', + 'thumbShape': 'roundedRect', + 'thumbWidth': 40, + 'thumbHeight': 16, + 'padding': [8, 0], + }, + })..parentID = kRootNode; + final values = []; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: TransformerSlider( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + onChanged: values.add, + value: 50, + ), + ), + ), + ), + ); + final SliderTheme theme = tester.widget(find.byType(SliderTheme).last); + final shape = theme.data.thumbShape as RoundedRectSliderThumbShape; + // Along the (turned) track first: the cap is 40 wide and 16 tall on + // screen. + expect(shape.size, const Size(16, 40)); + expect(tester.takeException(), isNull); + }); + + testWidgets('Hiding the thumb hides a rectangular one too', (tester) async { + final SliderNode node = slider({ + 'thumbShape': 'roundedRect', + 'showThumb': false, + }); + await valueAt(tester, node, 150); + final SliderTheme theme = tester.widget(find.byType(SliderTheme).last); + final shape = theme.data.thumbShape as RoundedRectSliderThumbShape; + expect(shape.size, Size.zero); + }); + }); +} diff --git a/test/timer_key_swipe_triggers_test.dart b/test/timer_key_swipe_triggers_test.dart index b361182b..db5fa685 100644 --- a/test/timer_key_swipe_triggers_test.dart +++ b/test/timer_key_swipe_triggers_test.dart @@ -38,7 +38,7 @@ Future> pumpCanvas( home: ActionChainHost( child: PassiveCanvasWidget( node: canvas(reactions), - settings: WidgetBuildSettings(debugLabel: 'test'), + settings: const WidgetBuildSettings(debugLabel: 'test'), builder: (_) => const SizedBox.expand(), ), ), From 3fcbdc527ef7f1822d1931ece781ed3f607c9f2c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:41:07 +0000 Subject: [PATCH 12/12] codelessly_sdk 8.3.0, on codelessly_api ^6.2.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ pubspec.yaml | 4 ++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc42932b..afe4868e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +## 8.3.0 + +### Motion and interaction + +- `NodeMotion` draws a node's motion: animated transforms, spin, loops, + entrances (from rest or `enterFrom`, replayed when `replayKey` changes) and + delays. +- Timer, key press, swipe, double tap and hover triggers. +- Stacked and wrapping lists; row and column `itemSpacing`; vertical, + inverted and rectangular-thumb sliders with bound `min`/`max`. + +### Formulas and variables + +- Computed variables (`VariableData.formula`), kept up to date from their + inputs; Set variable leaves them alone. +- New functions: `padLeft`/`padRight`, `substring`, `replace`, `split`, + `indexOf`, `matches`, `slug`, `seconds`, `duration`, `slice`, `take`, + `skip`, `reverse`, `findIndex`, `concat`, `merge`, `set`, `luminance`, + `sortByDesc`; object literals, `(item, i)` lambdas, multi-key `sortBy`, + `money(value, decimals)` and list `+`. +- Bindable effects, stroke weight, corner radius, font weight and icon + (`StyleBindings`); image fills listen to the variables in their URL. +- A screen variable shadows an app variable of the same name everywhere. + +### Navigation + +- A screen's route is set before its load actions run; a link's parameters + also set the variables they name. +- `CodelesslyWidget.initialScreen` opens a screen by id or name. + +### Fixes + +- Dashed strokes draw round and square caps along the path; a zero pattern + no longer hangs. + ## 8.2.0 ### States diff --git a/pubspec.yaml b/pubspec.yaml index ea7839c4..fb46ccb2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: codelessly_sdk description: Codelessly CloudUI™ - Server-driven UI for Flutter. Supercharge your Flutter apps with dynamic UI and real-time updates. Build and publish UI without code! -version: 8.2.0 +version: 8.3.0 homepage: https://codelessly.com/ repository: https://github.com/Codelessly/CodelesslySDK issue_tracker: https://github.com/Codelessly/CodelesslySDK/issues @@ -28,7 +28,7 @@ dependencies: flutter: sdk: flutter # Codelessly - codelessly_api: ^6.1.0 + codelessly_api: ^6.2.0 json_annotation: ^4.12.0 # Firebase