diff --git a/packages/flutter/lib/src/widgets/widget_preview.dart b/packages/flutter/lib/src/widgets/widget_preview.dart index 6246f4ec8b..75f9e70c1d 100644 --- a/packages/flutter/lib/src/widgets/widget_preview.dart +++ b/packages/flutter/lib/src/widgets/widget_preview.dart @@ -13,70 +13,71 @@ import 'framework.dart'; /// /// {@tool snippet} /// -/// Functions annotated with `@Preview()` must return a `WidgetPreview` -/// and be public, top-level functions. +/// Functions annotated with `@Preview()` must return a `Widget` or +/// `WidgetBuilder` and be public. This annotation can only be applied +/// to top-level functions, static methods defined within a class, and +/// public `Widget` constructors and factories with no required arguments. /// /// ```dart -/// @Preview() -/// WidgetPreview widgetPreview() { -/// return const WidgetPreview(name: 'Preview 1', child: Text('Foo')); +/// @Preview(name: 'Top-level preview') +/// Widget preview() => const Text('Foo'); +/// +/// @Preview(name: 'Builder preview') +/// WidgetBuilder builderPreview() { +/// return (BuildContext context) { +/// return const Text('Builder'); +/// }; +/// } +/// +/// class MyWidget extends StatelessWidget { +/// @Preview(name: 'Constructor preview') +/// const MyWidget.preview({super.key}); +/// +/// @Preview(name: 'Factory constructor preview') +/// factory MyWidget.factoryPreview() => const MyWidget.preview(); +/// +/// @Preview(name: 'Static preview') +/// static Widget previewStatic() => const Text('Static'); +/// +/// @override +/// Widget build(BuildContext context) { +/// return const Text('MyWidget'); +/// } /// } /// ``` /// {@end-tool} -/// -/// See also: -/// -/// * [WidgetPreview], a data class used to specify widget previews. // TODO(bkonyi): link to actual documentation when available. -class Preview { +base class Preview { /// Annotation used to mark functions that return widget previews. - const Preview(); -} - -/// Wraps a [Widget], initializing various state and properties to allow for -/// previewing of the [Widget] in the widget previewer. -/// -/// WARNING: This interface is not stable and **will change**. -/// -/// See also: -/// -/// * [Preview], an annotation class used to mark functions returning widget -/// previews. -// TODO(bkonyi): link to actual documentation when available. -class WidgetPreview { - /// Wraps [child] in a [WidgetPreview] instance that applies some set of - /// properties. - const WidgetPreview({ - required this.child, - this.name, - this.width, - this.height, - this.textScaleFactor, - }); + const Preview({this.name, this.width, this.height, this.textScaleFactor, this.wrapper}); /// A description to be displayed alongside the preview. /// /// If not provided, no name will be associated with the preview. final String? name; - /// The [Widget] to be rendered in the preview. - final Widget child; - - /// Artificial width constraint to be applied to the [child]. + /// Artificial width constraint to be applied to the previewed widget. /// /// If not provided, the previewed widget will attempt to set its own width /// constraints and may result in an unbounded constraint error. final double? width; - /// Artificial height constraint to be applied to the [child]. + /// Artificial height constraint to be applied to the previewed widget. /// /// If not provided, the previewed widget will attempt to set its own height /// constraints and may result in an unbounded constraint error. final double? height; - /// Applies font scaling to text within the [child]. + /// Applies font scaling to text within the previewed widget. /// /// If not provided, the default text scaling factor provided by [MediaQuery] /// will be used. final double? textScaleFactor; + + /// Wraps the previewed [Widget] in a [Widget] tree. + /// + /// This function can be used to perform dependency injection or setup + /// additional scaffolding needed to correctly render the preview. + // TODO(bkonyi): provide an example. + final Widget Function(Widget)? wrapper; } diff --git a/packages/flutter_tools/lib/src/commands/widget_preview.dart b/packages/flutter_tools/lib/src/commands/widget_preview.dart index c710e0b8c6..97d2b78d8b 100644 --- a/packages/flutter_tools/lib/src/commands/widget_preview.dart +++ b/packages/flutter_tools/lib/src/commands/widget_preview.dart @@ -181,6 +181,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C late final FlutterProject rootProject = getRootProject(); late final PreviewDetector _previewDetector = PreviewDetector( + projectRoot: rootProject.directory, logger: logger, fs: fs, onChangeDetected: onChangeDetected, @@ -260,7 +261,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C await _populatePreviewPubspec(rootProject: rootProject); } - final PreviewMapping initialPreviews = await _previewDetector.initialize(rootProject.directory); + final PreviewMapping initialPreviews = await _previewDetector.initialize(); _previewCodeGenerator.populatePreviewsInGeneratedPreviewScaffold(initialPreviews); if (boolArg(kLaunchPreviewer)) { diff --git a/packages/flutter_tools/lib/src/widget_preview/preview_code_generator.dart b/packages/flutter_tools/lib/src/widget_preview/preview_code_generator.dart index b615247eed..a7a203f8d1 100644 --- a/packages/flutter_tools/lib/src/widget_preview/preview_code_generator.dart +++ b/packages/flutter_tools/lib/src/widget_preview/preview_code_generator.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:built_collection/built_collection.dart'; import 'package:code_builder/code_builder.dart'; import '../base/file_system.dart'; @@ -30,37 +31,100 @@ class PreviewCodeGenerator { /// An example of a formatted generated file containing previews from two files could be: /// /// ```dart - /// import 'package:foo/foo.dart' as _i1; - /// import 'package:foo/src/bar.dart' as _i2; - /// import 'package:widget_preview/widget_preview.dart'; + /// import 'widget_preview.dart' as _i1; + /// import 'package:splash/foo.dart' as _i2; + /// import 'package:splash/main.dart' as _i3; + /// import 'package:flutter/widgets.dart' as _i4; /// - /// List previews() => [ - /// _i1.fooPreview(), - /// _i2.barPreview1(), - /// _i3.barPreview2(), + /// List<_i1.WidgetPreview> previews() => [ + /// _i1.WidgetPreview(height: 100.0, width: 10000.0, child: _i2.preview()), + /// _i1.WidgetPreview( + /// name: 'Foo', + /// height: 50 + 20, + /// width: 200.0, + /// textScaleFactor: 2.0, + /// child: _i3.preview(), + /// ), + /// _i1.WidgetPreview( + /// name: 'Baz', + /// height: 50.0, + /// width: 200.0, + /// textScaleFactor: 3.0, + /// child: _i2.stateInjector(_i3.preview()), + /// ), + /// _i1.WidgetPreview(name: 'Bar', child: _i4.Builder(builder: _i3.preview2())), + /// _i1.WidgetPreview(name: 'Constructor preview', height: 50.0, width: 100.0, child: _i3.MyWidget()), + /// _i1.WidgetPreview( + /// name: 'Named constructor preview', + /// height: 50.0, + /// width: 100.0, + /// child: _i3.MyWidget.preview(), + /// ), + /// _i1.WidgetPreview( + /// name: 'Static preview', + /// height: 50.0, + /// width: 100.0, + /// child: _i3.MyWidget.staticPreview(), + /// ), /// ]; /// ``` void populatePreviewsInGeneratedPreviewScaffold(PreviewMapping previews) { + final TypeReference returnType = + (TypeReferenceBuilder() + ..symbol = 'List' + ..types = ListBuilder([ + refer('WidgetPreview', 'widget_preview.dart'), + ])) + .build(); final Library lib = Library( (LibraryBuilder b) => b.body.addAll([ - Directive.import('package:flutter/widgets.dart'), - Method( - (MethodBuilder b) => - b - ..body = - literalList([ - for (final MapEntry>( - key: (path: String _, :Uri uri), - value: List previewMethods, - ) - in previews.entries) ...[ - for (final String method in previewMethods) - refer(method, uri.toString()).call([]), - ], - ]).code - ..name = 'previews' - ..returns = refer('List'), - ), + Method((MethodBuilder b) { + final List previewExpressions = []; + for (final MapEntry>( + key: (path: String _, :Uri uri), + value: List previewMethods, + ) + in previews.entries) { + for (final PreviewDetails preview in previewMethods) { + Expression previewWidget = refer( + preview.functionName, + uri.toString(), + ).call([]); + + if (preview.isBuilder) { + previewWidget = refer( + 'Builder', + 'package:flutter/widgets.dart', + ).newInstance([], {'builder': previewWidget}); + } + if (preview.hasWrapper) { + previewWidget = refer( + preview.wrapper!, + preview.wrapperLibraryUri, + ).call([previewWidget]); + } + previewExpressions.add( + refer( + 'WidgetPreview', + 'widget_preview.dart', + ).newInstance([], { + if (preview.name != null) PreviewDetails.kName: refer(preview.name!).expression, + ...?_buildDoubleParameters(key: PreviewDetails.kHeight, property: preview.height), + ...?_buildDoubleParameters(key: PreviewDetails.kWidth, property: preview.width), + ...?_buildDoubleParameters( + key: PreviewDetails.kTextScaleFactor, + property: preview.textScaleFactor, + ), + 'child': previewWidget, + }), + ); + } + } + b + ..body = literalList(previewExpressions).code + ..name = 'previews' + ..returns = returnType; + }), ]), ); final DartEmitter emitter = DartEmitter.scoped(useNullSafetySyntax: true); @@ -70,4 +134,16 @@ class PreviewCodeGenerator { // TODO(bkonyi): do we want to bother with formatting this? generatedPreviewFile.writeAsStringSync(lib.accept(emitter).toString()); } + + Map? _buildDoubleParameters({ + required String key, + required String? property, + }) { + if (property == null) { + return null; + } + return { + key: CodeExpression(Code('${double.tryParse(property) ?? property}')), + }; + } } diff --git a/packages/flutter_tools/lib/src/widget_preview/preview_detector.dart b/packages/flutter_tools/lib/src/widget_preview/preview_detector.dart index 0f238836ba..fe9b46ec86 100644 --- a/packages/flutter_tools/lib/src/widget_preview/preview_detector.dart +++ b/packages/flutter_tools/lib/src/widget_preview/preview_detector.dart @@ -3,13 +3,16 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:collection'; import 'package:analyzer/dart/analysis/analysis_context.dart'; import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; import 'package:analyzer/dart/analysis/results.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/file_system/physical_file_system.dart'; +import 'package:meta/meta.dart'; import 'package:watcher/watcher.dart'; import '../base/file_system.dart'; @@ -25,11 +28,14 @@ import 'preview_code_generator.dart'; typedef PreviewPath = ({String path, Uri uri}); /// Represents a set of previews for a given file. -typedef PreviewMapping = Map>; +typedef PreviewMapping = Map>; extension on Token { /// Convenience getter to identify tokens for private fields and functions. bool get isPrivate => toString().startsWith('_'); + + /// Convenience getter to identify WidgetBuilder types. + bool get isWidgetBuilder => toString() == 'WidgetBuilder'; } extension on Annotation { @@ -50,27 +56,191 @@ extension on ParsedUnitResult { PreviewPath toPreviewPath() => (path: path, uri: uri); } +/// Contains details related to a single preview instance. +final class PreviewDetails { + PreviewDetails({required this.functionName, required this.isBuilder}); + + @visibleForTesting + PreviewDetails.test({ + required this.functionName, + required this.isBuilder, + String? name, + String? width, + String? height, + String? textScaleFactor, + String? wrapper, + String? wrapperLibraryUri = '', + }) : _name = name, + _width = width, + _height = height, + _textScaleFactor = textScaleFactor, + _wrapper = wrapper, + _wrapperLibraryUri = wrapperLibraryUri; + + @visibleForTesting + PreviewDetails copyWith({ + String? functionName, + bool? isBuilder, + String? name, + String? width, + String? height, + String? textScaleFactor, + String? wrapper, + String? wrapperLibraryUri, + }) { + return PreviewDetails.test( + functionName: functionName ?? this.functionName, + isBuilder: isBuilder ?? this.isBuilder, + name: name ?? this.name, + width: width ?? this.width, + height: height ?? this.height, + textScaleFactor: textScaleFactor ?? this.textScaleFactor, + wrapper: wrapper ?? this.wrapper, + wrapperLibraryUri: wrapperLibraryUri ?? this.wrapperLibraryUri, + ); + } + + static const String kName = 'name'; + static const String kWidth = 'width'; + static const String kHeight = 'height'; + static const String kTextScaleFactor = 'textScaleFactor'; + static const String kWrapper = 'wrapper'; + static const String kWrapperLibraryUri = 'wrapperLibraryUrl'; + + /// The name of the function returning the preview. + final String functionName; + + /// Set to `true` if the preview function is returning a [WidgetBuilder] + /// instead of a [Widget]. + final bool isBuilder; + + /// A description to be displayed alongside the preview. + /// + /// If not provided, no name will be associated with the preview. + String? get name => _name; + String? _name; + + /// Artificial width constraint to be applied to the [child]. + /// + /// If not provided, the previewed widget will attempt to set its own width + /// constraints and may result in an unbounded constraint error. + String? get width => _width; + String? _width; + + /// Artificial height constraint to be applied to the [child]. + /// + /// If not provided, the previewed widget will attempt to set its own height + /// constraints and may result in an unbounded constraint error. + String? get height => _height; + String? _height; + + /// Applies font scaling to text within the [child]. + /// + /// If not provided, the default text scaling factor provided by [MediaQuery] + /// will be used. + String? get textScaleFactor => _textScaleFactor; + String? _textScaleFactor; + + /// The name of a tear-off used to wrap the [Widget] returned by the preview + /// function defined by [functionName]. + /// + /// If not provided, the [Widget] returned by [functionName] will be used by + /// the previewer directly. + String? get wrapper => _wrapper; + String? _wrapper; + + /// The URI for the library containing the declaration of [wrapper]. + String? get wrapperLibraryUri => _wrapperLibraryUri; + String? _wrapperLibraryUri; + + bool get hasWrapper => _wrapper != null; + + void _setField({required NamedExpression node}) { + final String key = node.name.label.name; + final Expression expression = node.expression; + final String source = expression.toSource(); + switch (key) { + case kName: + _name = source; + case kWidth: + _width = source; + case kHeight: + _height = source; + case kTextScaleFactor: + _textScaleFactor = source; + case kWrapper: + _wrapper = source; + _wrapperLibraryUri = (node.expression as SimpleIdentifier).element!.library2!.identifier; + default: + throw StateError('Unknown Preview field "$name": $source'); + } + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + return other.runtimeType == runtimeType && + other is PreviewDetails && + other.functionName == functionName && + other.isBuilder == isBuilder && + other.height == height && + other.width == width && + other.textScaleFactor == textScaleFactor && + other.wrapper == wrapper && + other.wrapperLibraryUri == wrapperLibraryUri; + } + + @override + String toString() => + 'PreviewDetails(function: $functionName isBuilder: $isBuilder $kName: $name ' + '$kWidth: $width $kHeight: $height $kTextScaleFactor: $textScaleFactor $kWrapper: $wrapper ' + '$kWrapperLibraryUri: $wrapperLibraryUri)'; + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll([ + functionName, + isBuilder, + height, + width, + textScaleFactor, + wrapper, + wrapperLibraryUri, + ]); +} + class PreviewDetector { PreviewDetector({ + required this.projectRoot, required this.fs, required this.logger, required this.onChangeDetected, required this.onPubspecChangeDetected, }); + final Directory projectRoot; final FileSystem fs; final Logger logger; final void Function(PreviewMapping) onChangeDetected; final void Function() onPubspecChangeDetected; StreamSubscription? _fileWatcher; + final PreviewDetectorMutex _mutex = PreviewDetectorMutex(); late final PreviewMapping _pathToPreviews; + late final AnalysisContextCollection collection = AnalysisContextCollection( + includedPaths: [projectRoot.absolute.path], + resourceProvider: PhysicalResourceProvider.INSTANCE, + ); + /// Starts listening for changes to Dart sources under [projectRoot] and returns /// the initial [PreviewMapping] for the project. - Future initialize(Directory projectRoot) async { + Future initialize() async { // Find the initial set of previews. - _pathToPreviews = findPreviewFunctions(projectRoot); + _pathToPreviews = await findPreviewFunctions(projectRoot); final Watcher watcher = Watcher(projectRoot.path); _fileWatcher = watcher.events.listen((WatchEvent event) async { @@ -87,7 +257,7 @@ class PreviewDetector { return; } logger.printStatus('Detected change in $eventPath.'); - final PreviewMapping filePreviewsMapping = findPreviewFunctions( + final PreviewMapping filePreviewsMapping = await findPreviewFunctions( fs.file(Uri.file(event.path)), ); final bool hasExistingPreviews = @@ -102,13 +272,13 @@ class PreviewDetector { } if (filePreviewsMapping.isNotEmpty) { // The set of previews has changed, but there are still previews in the file. - final MapEntry>( + final MapEntry>( key: PreviewPath location, - value: List filePreviews, + value: List filePreviews, ) = filePreviewsMapping.entries.first; logger.printStatus('Updated previews for ${location.uri}: $filePreviews'); if (filePreviews.isNotEmpty) { - final List? currentPreviewsForFile = _pathToPreviews[location]; + final List? currentPreviewsForFile = _pathToPreviews[location]; if (filePreviews != currentPreviewsForFile) { _pathToPreviews[location] = filePreviews; } @@ -127,63 +297,201 @@ class PreviewDetector { } Future dispose() async { - await _fileWatcher?.cancel(); + // Guard disposal behind a mutex to make sure the analyzer has finished + // processing the latest file updates to avoid throwing an exception. + await _mutex.runGuarded(() async { + await _fileWatcher?.cancel(); + await collection.dispose(); + }); } /// Search for functions annotated with `@Preview` in the current project. - PreviewMapping findPreviewFunctions(FileSystemEntity entity) { - final AnalysisContextCollection collection = AnalysisContextCollection( - includedPaths: [entity.absolute.path], - resourceProvider: PhysicalResourceProvider.INSTANCE, - ); - + Future findPreviewFunctions(FileSystemEntity entity) async { final PreviewMapping previews = PreviewMapping(); - for (final AnalysisContext context in collection.contexts) { - logger.printStatus('Finding previews in ${context.contextRoot.root.path}...'); + // Only process one FileSystemEntity at a time so we don't invalidate an AnalysisSession that's + // in use when we call context.changeFile(...). + await _mutex.runGuarded(() async { + // TODO(bkonyi): this can probably be replaced by a call to collection.contextFor(...), + // but we need to figure out the right path format for Windows. + for (final AnalysisContext context in collection.contexts) { + logger.printStatus('Finding previews in ${entity.path}...'); - for (final String filePath in context.contextRoot.analyzedFiles()) { - logger.printTrace('Checking file: $filePath'); - if (!filePath.isDartFile) { - continue; + // If we're processing a single file, it means the file watcher detected a + // change in a Dart source. We need to notify the analyzer that this file + // has changed so it can reanalyze the file. + if (entity is File) { + context.changeFile(entity.path); + await context.applyPendingFileChanges(); } - final SomeParsedLibraryResult lib = context.currentSession.getParsedLibrary(filePath); - if (lib is ParsedLibraryResult) { - for (final ParsedUnitResult libUnit in lib.units) { - final List previewEntries = previews[libUnit.toPreviewPath()] ?? []; - for (final CompilationUnitMember entity in libUnit.unit.declarations) { - if (entity is FunctionDeclaration && !entity.name.isPrivate) { - bool foundPreview = false; - for (final Annotation annotation in entity.metadata) { - if (annotation.isPreview) { - // What happens if the annotation is applied multiple times? - foundPreview = true; - break; - } - } - if (foundPreview) { - logger.printStatus('Found preview at:'); - logger.printStatus('File path: ${libUnit.uri}'); - logger.printStatus('Preview function: ${entity.name}'); - logger.printStatus(''); - previewEntries.add(entity.name.toString()); - } + for (final String filePath in context.contextRoot.analyzedFiles()) { + logger.printTrace('Checking file: $filePath'); + if (!filePath.isDartFile || !filePath.startsWith(entity.path)) { + logger.printTrace('Skipping $filePath'); + continue; + } + final SomeResolvedLibraryResult lib = await context.currentSession.getResolvedLibrary( + filePath, + ); + // TODO(bkonyi): ensure this can handle part files. + if (lib is ResolvedLibraryResult) { + for (final ResolvedUnitResult libUnit in lib.units) { + final List previewEntries = + previews[libUnit.toPreviewPath()] ?? []; + final PreviewVisitor visitor = PreviewVisitor(); + libUnit.unit.visitChildren(visitor); + previewEntries.addAll(visitor.previewEntries); + if (previewEntries.isNotEmpty) { + previews[libUnit.toPreviewPath()] = previewEntries; } } - if (previewEntries.isNotEmpty) { - previews[libUnit.toPreviewPath()] = previewEntries; - } + } else { + logger.printWarning('Unknown library type at $filePath: $lib'); } - } else { - logger.printWarning('Unknown library type at $filePath: $lib'); } } - } - final int previewCount = previews.values.fold( - 0, - (int count, List value) => count + value.length, - ); - logger.printStatus('Found $previewCount ${pluralize('preview', previewCount)}.'); + final int previewCount = previews.values.fold( + 0, + (int count, List value) => count + value.length, + ); + logger.printStatus('Found $previewCount ${pluralize('preview', previewCount)}.'); + }); return previews; } } + +/// Visitor which detects previews and extracts [PreviewDetails] for later code +/// generation. +// TODO(bkonyi): this visitor needs better error detection to identify invalid +// previews and report them to the previewer without causing the entire +// environment to shutdown or fail to render valid previews. +class PreviewVisitor extends RecursiveAstVisitor { + final List previewEntries = []; + + FunctionDeclaration? _currentFunction; + ConstructorDeclaration? _currentConstructor; + MethodDeclaration? _currentMethod; + PreviewDetails? _currentPreview; + + /// Handles previews defined on top-level functions. + @override + void visitFunctionDeclaration(FunctionDeclaration node) { + assert(_currentFunction == null); + if (node.name.isPrivate) { + return; + } + + final TypeAnnotation? returnType = node.returnType; + if (returnType == null || returnType.question != null) { + return; + } + _scopedVisitChildren(node, (FunctionDeclaration? node) => _currentFunction = node); + } + + /// Handles previews defined on constructors. + @override + void visitConstructorDeclaration(ConstructorDeclaration node) { + _scopedVisitChildren(node, (ConstructorDeclaration? node) => _currentConstructor = node); + } + + /// Handles previews defined on static methods within classes. + @override + void visitMethodDeclaration(MethodDeclaration node) { + if (!node.isStatic) { + return; + } + _scopedVisitChildren(node, (MethodDeclaration? node) => _currentMethod = node); + } + + @override + void visitAnnotation(Annotation node) { + if (!node.isPreview) { + return; + } + assert(_currentFunction != null || _currentConstructor != null || _currentMethod != null); + if (_currentFunction != null) { + final NamedType returnType = _currentFunction!.returnType! as NamedType; + _currentPreview = PreviewDetails( + functionName: _currentFunction!.name.toString(), + isBuilder: returnType.name2.isWidgetBuilder, + ); + } else if (_currentConstructor != null) { + final SimpleIdentifier returnType = _currentConstructor!.returnType as SimpleIdentifier; + final Token? name = _currentConstructor!.name; + _currentPreview = PreviewDetails( + functionName: '$returnType${name == null ? '' : '.$name'}', + isBuilder: false, + ); + } else if (_currentMethod != null) { + final NamedType returnType = _currentMethod!.returnType! as NamedType; + final ClassDeclaration parentClass = _currentMethod!.parent! as ClassDeclaration; + _currentPreview = PreviewDetails( + functionName: '${parentClass.name}.${_currentMethod!.name}', + isBuilder: returnType.name2.isWidgetBuilder, + ); + } + node.visitChildren(this); + previewEntries.add(_currentPreview!); + _currentPreview = null; + } + + @override + void visitNamedExpression(NamedExpression node) { + // Extracts named properties from the @Preview annotation. + _currentPreview?._setField(node: node); + } + + void _scopedVisitChildren(T node, void Function(T?) setter) { + setter(node); + node.visitChildren(this); + setter(null); + } +} + +/// Used to protect global state accessed in blocks containing calls to +/// asynchronous methods. +/// +/// Originally from DDS: +/// https://github.com/dart-lang/sdk/blob/3fe58da3cfe2c03fb9ee691a7a4709082fad3e73/pkg/dds/lib/src/utils/mutex.dart +class PreviewDetectorMutex { + /// Executes a block of code containing asynchronous calls atomically. + /// + /// If no other asynchronous context is currently executing within + /// [criticalSection], it will immediately be called. Otherwise, the + /// caller will be suspended and entered into a queue to be resumed once the + /// lock is released. + Future runGuarded(FutureOr Function() criticalSection) async { + try { + await _acquireLock(); + return await criticalSection(); + } finally { + _releaseLock(); + } + } + + Future _acquireLock() async { + if (!_locked) { + _locked = true; + return; + } + + final Completer request = Completer(); + _outstandingRequests.add(request); + await request.future; + } + + void _releaseLock() { + if (_outstandingRequests.isNotEmpty) { + final Completer request = _outstandingRequests.removeFirst(); + request.complete(); + return; + } + // Only release the lock if no other requests are pending to prevent races + // between the next request from the queue to be handled and incoming + // requests. + _locked = false; + } + + bool _locked = false; + final Queue> _outstandingRequests = Queue>(); +} diff --git a/packages/flutter_tools/templates/template_manifest.json b/packages/flutter_tools/templates/template_manifest.json index a1850cecb3..04f74048da 100644 --- a/packages/flutter_tools/templates/template_manifest.json +++ b/packages/flutter_tools/templates/template_manifest.json @@ -353,6 +353,7 @@ "templates/plugin_swift_package_manager/macos.tmpl/projectName.tmpl/Sources/projectName.tmpl/PrivacyInfo.xcprivacy", "templates/widget_preview_scaffold/lib/main.dart.tmpl", + "templates/widget_preview_scaffold/lib/src/widget_preview.dart.tmpl", "templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl", "templates/widget_preview_scaffold/lib/src/controls.dart.tmpl", "templates/widget_preview_scaffold/lib/src/generated_preview.dart.tmpl", diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/generated_preview.dart.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/generated_preview.dart.tmpl index 5ffa0b7d35..7817e12b33 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/generated_preview.dart.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/generated_preview.dart.tmpl @@ -3,5 +3,6 @@ // found in the LICENSE file. import 'package:flutter/widgets.dart'; +import 'widget_preview.dart'; List previews() => []; diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview.dart.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview.dart.tmpl new file mode 100644 index 0000000000..1da5546e47 --- /dev/null +++ b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview.dart.tmpl @@ -0,0 +1,53 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +/// Wraps a [Widget], initializing various state and properties to allow for +/// previewing of the [Widget] in the widget previewer. +/// +/// WARNING: This interface is not stable and **will change**. +/// +/// See also: +/// +/// * [Preview], an annotation class used to mark functions returning widget +/// previews. +// TODO(bkonyi): link to actual documentation when available. +class WidgetPreview { + /// Wraps [child] in a [WidgetPreview] instance that applies some set of + /// properties. + const WidgetPreview({ + required this.child, + this.name, + this.width, + this.height, + this.textScaleFactor, + }); + + /// A description to be displayed alongside the preview. + /// + /// If not provided, no name will be associated with the preview. + final String? name; + + /// The [Widget] to be rendered in the preview. + final Widget child; + + /// Artificial width constraint to be applied to the [child]. + /// + /// If not provided, the previewed widget will attempt to set its own width + /// constraints and may result in an unbounded constraint error. + final double? width; + + /// Artificial height constraint to be applied to the [child]. + /// + /// If not provided, the previewed widget will attempt to set its own height + /// constraints and may result in an unbounded constraint error. + final double? height; + + /// Applies font scaling to text within the [child]. + /// + /// If not provided, the default text scaling factor provided by [MediaQuery] + /// will be used. + final double? textScaleFactor; +} \ No newline at end of file diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl index 42a9507c7e..3d71be219a 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl @@ -10,6 +10,7 @@ import 'package:flutter/services.dart'; import 'controls.dart'; import 'generated_preview.dart'; import 'utils.dart'; +import 'widget_preview.dart'; class WidgetPreviewWidget extends StatefulWidget { const WidgetPreviewWidget({ diff --git a/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/preview_detector_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/preview_detector_test.dart index a7b8de089e..842e875bf8 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/preview_detector_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/preview_detector_test.dart @@ -70,6 +70,7 @@ void main() { projectRoot = createBasicProjectStructure(fs); logger = BufferLogger.test(); previewDetector = PreviewDetector( + projectRoot: projectRoot, logger: logger, fs: fs, onChangeDetected: onChangeDetectedRoot, @@ -89,15 +90,48 @@ void main() { addPreviewContainingFile(projectRoot, ['src', 'bar.dart']), ]; addNonPreviewContainingFile(projectRoot, ['baz.dart']); - final PreviewMapping mapping = previewDetector.findPreviewFunctions(projectRoot); + final PreviewMapping mapping = await previewDetector.findPreviewFunctions(projectRoot); expect(mapping.keys.toSet(), previewFiles.toSet()); }); testUsingContext('can detect previews in updated files', () async { + final List expectedPreviewDetails = [ + PreviewDetails.test(functionName: 'previews', isBuilder: false, name: 'Top-level preview'), + PreviewDetails.test( + functionName: 'builderPreview', + isBuilder: true, + name: 'Builder preview', + ), + PreviewDetails.test( + functionName: 'attributesPreview', + isBuilder: false, + name: 'Attributes preview', + width: '100.0', + height: '100', + textScaleFactor: '2', + wrapper: 'testWrapper', + ), + PreviewDetails.test( + functionName: 'MyWidget.preview', + isBuilder: false, + name: 'Constructor preview', + ), + PreviewDetails.test( + functionName: 'MyWidget.factoryPreview', + isBuilder: false, + name: 'Factory constructor preview', + ), + PreviewDetails.test( + functionName: 'MyWidget.previewStatic', + isBuilder: false, + name: 'Static preview', + ), + ]; + // Create two files with existing previews and one without. - final PreviewMapping expectedInitialMapping = >{ - addPreviewContainingFile(projectRoot, ['foo.dart']): ['previews'], - addPreviewContainingFile(projectRoot, ['src', 'bar.dart']): ['previews'], + final PreviewMapping expectedInitialMapping = >{ + addPreviewContainingFile(projectRoot, ['foo.dart']): expectedPreviewDetails, + addPreviewContainingFile(projectRoot, ['src', 'bar.dart']): expectedPreviewDetails, }; final PreviewPath nonPreviewContainingFile = addNonPreviewContainingFile( projectRoot, @@ -107,15 +141,15 @@ void main() { Completer completer = Completer(); onChangeDetected = (PreviewMapping updated) { // The new preview in baz.dart should be included in the preview mapping. - expect(updated, >{ + expect(stripNonDeterministicFields(updated), >{ ...expectedInitialMapping, - nonPreviewContainingFile: ['previews'], + nonPreviewContainingFile: expectedPreviewDetails, }); completer.complete(); }; // Initialize the file watcher. - final PreviewMapping initialPreviews = await previewDetector.initialize(projectRoot); - expect(initialPreviews, expectedInitialMapping); + final PreviewMapping initialPreviews = await previewDetector.initialize(); + expect(stripNonDeterministicFields(initialPreviews), expectedInitialMapping); // Update the file without an existing preview to include a preview and ensure it triggers // the preview detector. @@ -125,7 +159,7 @@ void main() { completer = Completer(); onChangeDetected = (PreviewMapping updated) { // The removed preview in baz.dart should not longer be included in the preview mapping. - expect(updated, expectedInitialMapping); + expect(stripNonDeterministicFields(updated), expectedInitialMapping); completer.complete(); }; @@ -135,6 +169,65 @@ void main() { await completer.future; }); + testUsingContext('can detect previews in newly added files', () async { + final List expectedPreviewDetails = [ + PreviewDetails.test(functionName: 'previews', isBuilder: false, name: 'Top-level preview'), + PreviewDetails.test( + functionName: 'builderPreview', + isBuilder: true, + name: 'Builder preview', + ), + PreviewDetails.test( + functionName: 'attributesPreview', + isBuilder: false, + name: 'Attributes preview', + width: '100.0', + height: '100', + textScaleFactor: '2', + wrapper: 'testWrapper', + ), + PreviewDetails.test( + functionName: 'MyWidget.preview', + isBuilder: false, + name: 'Constructor preview', + ), + PreviewDetails.test( + functionName: 'MyWidget.factoryPreview', + isBuilder: false, + name: 'Factory constructor preview', + ), + PreviewDetails.test( + functionName: 'MyWidget.previewStatic', + isBuilder: false, + name: 'Static preview', + ), + ]; + + // The initial mapping should be empty as there's no files containing previews. + final PreviewMapping expectedInitialMapping = >{}; + + final Completer completer = Completer(); + late final PreviewPath previewContainingFilePath; + onChangeDetected = (PreviewMapping updated) { + if (completer.isCompleted) { + return; + } + // The new previews in baz.dart should be included in the preview mapping. + expect(stripNonDeterministicFields(updated), >{ + previewContainingFilePath: expectedPreviewDetails, + }); + completer.complete(); + }; + + // Initialize the file watcher. + final PreviewMapping initialPreviews = await previewDetector.initialize(); + expect(stripNonDeterministicFields(initialPreviews), expectedInitialMapping); + + // Create baz.dart, which contains previews. + previewContainingFilePath = addPreviewContainingFile(projectRoot, ['baz.dart']); + await completer.future; + }); + testUsingContext('can detect changes in the pubspec.yaml', () async { // Create an initial pubspec. populatePubspec(projectRoot, 'abc'); @@ -144,7 +237,7 @@ void main() { completer.complete(); }; // Initialize the file watcher. - final PreviewMapping initialPreviews = await previewDetector.initialize(projectRoot); + final PreviewMapping initialPreviews = await previewDetector.initialize(); expect(initialPreviews, isEmpty); // Change the contents of the pubspec and verify the callback is invoked. @@ -154,10 +247,55 @@ void main() { }); } +/// Creates a copy of [mapping] with [PreviewDetails] entries that have non-deterministic values +/// that differ per run (e.g., temporary file paths). +PreviewMapping stripNonDeterministicFields(PreviewMapping mapping) { + return mapping.map>(( + PreviewPath key, + List value, + ) { + return MapEntry>( + key, + value.map((PreviewDetails details) => details.copyWith(wrapperLibraryUri: '')).toList(), + ); + }); +} + const String previewContainingFileContents = ''' -@Preview() -// This isn't necessarily valid code. We're just looking for the annotation -WidgetPreview previews() => WidgetPreview(); +@Preview(name: 'Top-level preview') +Widget previews() => Text('Foo'); + +@Preview(name: 'Builder preview') +WidgetBuilder builderPreview() { + return (BuildContext context) { + return Text('Builder'); + }; +} + +Widget testWrapper(Widget child) { + return child; +} + +@Preview(name: 'Attributes preview', height: 100, width: 100.0, textScaleFactor: 2, wrapper: testWrapper) +Widget attributesPreview() { + return Text('Attributes'); +} + +class MyWidget extends StatelessWidget { + @Preview(name: 'Constructor preview') + MyWidget.preview(); + + @Preview(name: 'Factory constructor preview') + MyWidget.factoryPreview() => MyWidget.preview(); + + @Preview(name: 'Static preview') + static Widget previewStatic() => Text('Static'); + + @override + Widget build(BuildContext context) { + return Text('MyWidget'); + } +} '''; const String nonPreviewContainingFileContents = ''' diff --git a/packages/flutter_tools/test/commands.shard/permeable/widget_preview_test.dart b/packages/flutter_tools/test/commands.shard/permeable/widget_preview_test.dart index 0f6a4a962e..61e57705a8 100644 --- a/packages/flutter_tools/test/commands.shard/permeable/widget_preview_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/widget_preview_test.dart @@ -190,12 +190,12 @@ void main() { ); const String samplePreviewFile = ''' -@Preview() -WidgetPreview preview() => const WidgetPreview(child: Text('Foo'));'''; +@Preview(name: 'preview') +Widget preview() => Text('Foo');'''; const String expectedGeneratedFileContents = ''' // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:flutter_project/foo.dart' as _i1;import 'package:flutter/widgets.dart';List previews() => [_i1.preview()];'''; +import 'widget_preview.dart' as _i1;import 'package:flutter_project/foo.dart' as _i2;List<_i1.WidgetPreview> previews() => [_i1.WidgetPreview(name: 'preview', child: _i2.preview(), )];'''; testUsingContext( 'start finds existing previews and injects them into ${PreviewCodeGenerator.generatedPreviewFilePath}', diff --git a/packages/flutter_tools/test/general.shard/widget_preview/preview_code_generator_test.dart b/packages/flutter_tools/test/general.shard/widget_preview/preview_code_generator_test.dart index b6edc91fcc..7b2eb42ce6 100644 --- a/packages/flutter_tools/test/general.shard/widget_preview/preview_code_generator_test.dart +++ b/packages/flutter_tools/test/general.shard/widget_preview/preview_code_generator_test.dart @@ -40,28 +40,45 @@ void main() { expect(generatedPreviewFile, isNot(exists)); // Populate the generated preview file. - codeGenerator.populatePreviewsInGeneratedPreviewScaffold(>{ - (path: '', uri: Uri(path: 'foo.dart')): ['preview'], - (path: '', uri: Uri(path: 'src/bar.dart')): ['barPreview1', 'barPreview2'], - }); + codeGenerator.populatePreviewsInGeneratedPreviewScaffold( + >{ + (path: '', uri: Uri(path: 'foo.dart')): [ + PreviewDetails(functionName: 'preview', isBuilder: false), + ], + (path: '', uri: Uri(path: 'src/bar.dart')): [ + PreviewDetails(functionName: 'barPreview1', isBuilder: false), + PreviewDetails(functionName: 'barPreview2', isBuilder: false), + PreviewDetails.test( + functionName: 'barPreview3', + isBuilder: true, + name: 'Foo', + width: '123', + height: '456', + textScaleFactor: '50', + wrapper: 'wrapper', + wrapperLibraryUri: 'wrapper.dart', + ), + ], + }, + ); expect(generatedPreviewFile, exists); // Check that the generated file contains: // - An import of the widget preview library // - Prefixed imports for both 'foo.dart' and 'src/bar.dart' // - A top-level function 'List previews()' - // - A returned list containing function calls to 'preview()' from 'foo.dart' and 'barPreview1()' - // and 'barPreview2()' from 'src/bar.dart' + // - A returned list containing function calls to 'preview()' from 'foo.dart' and + // 'barPreview1()', 'barPreview2()', and 'barPreview3()' from 'src/bar.dart' // // The generated file is unfortunately unformatted. const String expectedGeneratedPreviewFileContents = ''' // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'foo.dart' as _i1;import 'src/bar.dart' as _i2;import 'package:flutter/widgets.dart';List previews() => [_i1.preview(), _i2.barPreview1(), _i2.barPreview2(), ];'''; +import 'widget_preview.dart' as _i1;import 'foo.dart' as _i2;import 'src/bar.dart' as _i3;import 'wrapper.dart' as _i4;import 'package:flutter/widgets.dart' as _i5;List<_i1.WidgetPreview> previews() => [_i1.WidgetPreview(child: _i2.preview()), _i1.WidgetPreview(child: _i3.barPreview1()), _i1.WidgetPreview(child: _i3.barPreview2()), _i1.WidgetPreview(name: Foo, height: 456.0, width: 123.0, textScaleFactor: 50.0, child: _i4.wrapper(_i5.Builder(builder: _i3.barPreview3())), ), ];'''; expect(generatedPreviewFile.readAsStringSync(), expectedGeneratedPreviewFileContents); // Regenerate the generated file with no previews. codeGenerator.populatePreviewsInGeneratedPreviewScaffold( - const >{}, + const >{}, ); expect(generatedPreviewFile, exists); @@ -69,7 +86,8 @@ import 'foo.dart' as _i1;import 'src/bar.dart' as _i2;import 'package:flutter/wi // - An import of the widget preview library // - A top-level function 'List previews()' that returns an empty list. const String emptyGeneratedPreviewFileContents = ''' -import 'package:flutter/widgets.dart';List previews() => [];'''; +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'widget_preview.dart' as _i1;List<_i1.WidgetPreview> previews() => [];'''; expect(generatedPreviewFile.readAsStringSync(), emptyGeneratedPreviewFileContents); }, );