diff --git a/DEPS b/DEPS index c0b87a7c058a5..f1f21ada982e5 100644 --- a/DEPS +++ b/DEPS @@ -35,7 +35,7 @@ vars = { # Dart is: https://github.com/dart-lang/sdk/blob/master/DEPS. # You can use //tools/dart/create_updated_flutter_deps.py to produce # updated revision list of existing dependencies. - 'dart_revision': '2607b01bec99f324e45b00fde76591f244f65a4e', + 'dart_revision': 'e5dd92c3ca766810e0e5a02d8725b1cebc19f564', # WARNING: DO NOT EDIT MANUALLY # The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py diff --git a/ci/licenses_golden/licenses_third_party b/ci/licenses_golden/licenses_third_party index 8456a8f553462..bd3f5416a0475 100644 --- a/ci/licenses_golden/licenses_third_party +++ b/ci/licenses_golden/licenses_third_party @@ -1,4 +1,4 @@ -Signature: 8d4521c3fe82b7be926a7af5f8b15557 +Signature: 65f332e775d31fee5a51ee0a303354c3 UNUSED LICENSES: @@ -9750,9 +9750,11 @@ FILE: ../../../third_party/dart/samples_2/ffi/sqlite/lib/src/ffi/arena.dart FILE: ../../../third_party/dart/samples_2/ffi/sqlite/lib/src/ffi/dylib_utils.dart FILE: ../../../third_party/dart/sdk/lib/_internal/js_runtime/lib/rti.dart FILE: ../../../third_party/dart/sdk/lib/_internal/js_runtime/lib/shared/recipe_syntax.dart +FILE: ../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_allocation_patch.dart FILE: ../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_dynamic_library_patch.dart FILE: ../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_native_type_patch.dart FILE: ../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_patch.dart +FILE: ../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_struct_patch.dart FILE: ../../../third_party/dart/sdk/lib/ffi/annotations.dart FILE: ../../../third_party/dart/sdk/lib/ffi/dynamic_library.dart FILE: ../../../third_party/dart/sdk/lib/ffi/ffi.dart diff --git a/lib/snapshot/libraries.json b/lib/snapshot/libraries.json index 540785de60f7f..6267363cdeb65 100644 --- a/lib/snapshot/libraries.json +++ b/lib/snapshot/libraries.json @@ -82,9 +82,11 @@ "ffi": { "uri": "../../../third_party/dart/sdk/lib/ffi/ffi.dart", "patches": [ + "../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_patch.dart", + "../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_allocation_patch.dart", "../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_dynamic_library_patch.dart", "../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_native_type_patch.dart", - "../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_patch.dart" + "../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_struct_patch.dart" ] }, "wasm": { diff --git a/lib/snapshot/libraries.yaml b/lib/snapshot/libraries.yaml index 49838b0d52788..a0dfd15a382d0 100644 --- a/lib/snapshot/libraries.yaml +++ b/lib/snapshot/libraries.yaml @@ -87,9 +87,11 @@ flutter: ffi: uri: "../../../third_party/dart/sdk/lib/ffi/ffi.dart" patches: + - "../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_patch.dart" + - "../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_allocation_patch.dart" - "../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_dynamic_library_patch.dart" - "../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_native_type_patch.dart" - - "../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_patch.dart" + - "../../../third_party/dart/sdk/lib/_internal/vm/lib/ffi_struct_patch.dart" wasm: uri: "../../../third_party/dart/sdk/lib/wasm/wasm.dart" diff --git a/lib/web_ui/dev/goldens_lock.yaml b/lib/web_ui/dev/goldens_lock.yaml index 1b80d23509540..7e6a15eedcff3 100644 --- a/lib/web_ui/dev/goldens_lock.yaml +++ b/lib/web_ui/dev/goldens_lock.yaml @@ -1,2 +1,2 @@ repository: https://github.com/flutter/goldens.git -revision: b85f9093e6bc6d4e7cbb7f97491667c143c4a360 +revision: 99caeb1bcb8b7a856a78bd8d55816cc97db56112 diff --git a/lib/web_ui/dev/test_runner.dart b/lib/web_ui/dev/test_runner.dart index 842a5318b26b6..d6dbb11ebc88d 100644 --- a/lib/web_ui/dev/test_runner.dart +++ b/lib/web_ui/dev/test_runner.dart @@ -88,6 +88,15 @@ class TestCommand extends Command with ArgUtils { '.dart_tool/goldens. Use this option to bulk-update all screenshots, ' 'for example, when a new browser version affects pixels.', ) + ..addFlag( + 'fetch-goldens-repo', + defaultsTo: true, + negatable: true, + help: + 'Whether to fetch the goldens repo. Set this to false to iterate ' + 'on golden tests without fearing that the fetcher will overwrite ' + 'your local changes.', + ) ..addOption( 'browser', defaultsTo: 'chrome', @@ -165,39 +174,41 @@ class TestCommand extends Command with ArgUtils { final FilePath dir = FilePath.fromWebUi(''); print(''); print('Initial test run is done!'); - print('Watching ${dir.relativeToCwd}/lib and ${dir.relativeToCwd}/test to re-run tests'); + print( + 'Watching ${dir.relativeToCwd}/lib and ${dir.relativeToCwd}/test to re-run tests'); print(''); PipelineWatcher( - dir: dir.absolute, - pipeline: testPipeline, - ignore: (event) { - // Ignore font files that are copied whenever tests run. - if (event.path.endsWith('.ttf')) { - return true; - } + dir: dir.absolute, + pipeline: testPipeline, + ignore: (event) { + // Ignore font files that are copied whenever tests run. + if (event.path.endsWith('.ttf')) { + return true; + } - // Ignore auto-generated JS files. - // The reason we are using `.contains()` instead of `.endsWith()` is - // because the auto-generated files could end with any of the - // following: - // - // - browser_test.dart.js - // - browser_test.dart.js.map - // - browser_test.dart.js.deps - if (event.path.contains('browser_test.dart.js')) { - return true; - } + // Ignore auto-generated JS files. + // The reason we are using `.contains()` instead of `.endsWith()` is + // because the auto-generated files could end with any of the + // following: + // + // - browser_test.dart.js + // - browser_test.dart.js.map + // - browser_test.dart.js.deps + if (event.path.contains('browser_test.dart.js')) { + return true; + } - // React to changes in lib/ and test/ folders. - final String relativePath = path.relative(event.path, from: dir.absolute); - if (relativePath.startsWith('lib/') || relativePath.startsWith('test/')) { - return false; - } + // React to changes in lib/ and test/ folders. + final String relativePath = + path.relative(event.path, from: dir.absolute); + if (relativePath.startsWith('lib/') || + relativePath.startsWith('test/')) { + return false; + } - // Ignore anything else. - return true; - } - ).start(); + // Ignore anything else. + return true; + }).start(); // Return a never-ending future. return Completer().future; } else { @@ -217,7 +228,8 @@ class TestCommand extends Command with ArgUtils { bool unitTestResult = await runUnitTests(); bool integrationTestResult = await runIntegrationTests(); if (integrationTestResult != unitTestResult) { - print('Tests run. Integration tests passed: $integrationTestResult ' + print( + 'Tests run. Integration tests passed: $integrationTestResult ' 'unit tests passed: $unitTestResult'); } return integrationTestResult && unitTestResult; @@ -225,7 +237,8 @@ class TestCommand extends Command with ArgUtils { return await runUnitTests(); } } - throw UnimplementedError('Unknown test type requested: $testTypesRequested'); + throw UnimplementedError( + 'Unknown test type requested: $testTypesRequested'); } on TestFailureException { return true; } @@ -272,9 +285,9 @@ class TestCommand extends Command with ArgUtils { environment.webUiTestResultsDirectory.createSync(recursive: true); // If screenshot tests are available, fetch the screenshot goldens. - if (isScreenshotTestsAvailable) { + if (isScreenshotTestsAvailable && doFetchGoldensRepo) { if (isVerboseLoggingEnabled) { - print('INFO: Screenshot tests available'); + print('INFO: Fetching goldens repo'); } final GoldensRepoFetcher goldensRepoFetcher = GoldensRepoFetcher( environment.webUiGoldensRepositoryDirectory, @@ -483,6 +496,9 @@ class TestCommand extends Command with ArgUtils { /// ".dart_tool/goldens". bool get doUpdateScreenshotGoldens => boolArg('update-screenshot-goldens'); + /// Whether to fetch the goldens repo prior to running tests. + bool get doFetchGoldensRepo => boolArg('fetch-goldens-repo'); + /// Runs all tests specified in [targets]. /// /// Unlike [_runAllTestsForCurrentPlatform], this does not filter targets @@ -774,6 +790,7 @@ const List _kTestFonts = [ 'ahem.ttf', 'Roboto-Regular.ttf', 'NotoNaskhArabic-Regular.ttf', + 'NotoColorEmoji.ttf', ]; void _copyTestFontsIntoWebUi() { diff --git a/lib/web_ui/lib/src/engine/assets.dart b/lib/web_ui/lib/src/engine/assets.dart index 61c056f3f6763..4f0fab752c03f 100644 --- a/lib/web_ui/lib/src/engine/assets.dart +++ b/lib/web_ui/lib/src/engine/assets.dart @@ -88,7 +88,16 @@ class AssetManagerException implements Exception { class WebOnlyMockAssetManager implements AssetManager { String defaultAssetsDir = ''; String defaultAssetManifest = '{}'; - String defaultFontManifest = '[]'; + String defaultFontManifest = '''[ + { + "family":"$_robotoFontFamily", + "fonts":[{"asset":"$_robotoTestFontUrl"}] + }, + { + "family":"$_ahemFontFamily", + "fonts":[{"asset":"$_ahemFontUrl"}] + } + ]'''; @override String get assetsDir => defaultAssetsDir; diff --git a/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart b/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart index 90b047214086f..aa9055f9318ac 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart @@ -359,14 +359,17 @@ class HtmlViewEmbedder { final Set unusedViews = Set.from(_activeCompositionOrder); _activeCompositionOrder.clear(); + List? debugInvalidViewIds; for (int i = 0; i < _compositionOrder.length; i++) { int viewId = _compositionOrder[i]; - assert( - _views.containsKey(viewId), - 'Cannot render platform view $viewId. ' - 'It has not been created, or it has been deleted.', - ); + if (assertionsEnabled) { + if (!_views.containsKey(viewId)) { + debugInvalidViewIds ??= []; + debugInvalidViewIds.add(viewId); + continue; + } + } unusedViews.remove(viewId); html.Element platformViewRoot = _rootViews[viewId]!; @@ -381,6 +384,16 @@ class HtmlViewEmbedder { for (final int unusedViewId in unusedViews) { _releaseOverlay(unusedViewId); + _rootViews[unusedViewId]?.remove(); + } + + if (assertionsEnabled) { + if (debugInvalidViewIds != null && debugInvalidViewIds.isNotEmpty) { + throw AssertionError( + 'Cannot render platform views: ${debugInvalidViewIds.join(', ')}. ' + 'These views have not been created, or they have been deleted.', + ); + } } } @@ -476,6 +489,7 @@ class OverlayCache { for (final Surface overlay in _cache) { overlay.dispose(); } + _cache.clear(); } } diff --git a/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart b/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart index 5c6ad00aab42b..98d902a9d866f 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart @@ -211,15 +211,19 @@ Future _registerSymbolsAndEmoji() async { String? symbolsFontUrl = extractUrlFromCss(symbolsCss); String? emojiFontUrl = extractUrlFromCss(emojiCss); - if (symbolsFontUrl == null || emojiFontUrl == null) { - html.window.console - .warn('Error parsing CSS for Noto Emoji and Symbols font.'); + if (symbolsFontUrl != null) { + notoDownloadQueue.add(_ResolvedNotoSubset( + symbolsFontUrl, 'Noto Sans Symbols', const [])); + } else { + html.window.console.warn('Error parsing CSS for Noto Symbols font.'); } - notoDownloadQueue.add(_ResolvedNotoSubset( - symbolsFontUrl!, 'Noto Sans Symbols', const [])); - notoDownloadQueue.add(_ResolvedNotoSubset( - emojiFontUrl!, 'Noto Color Emoji Compat', const [])); + if (emojiFontUrl != null) { + notoDownloadQueue.add(_ResolvedNotoSubset( + emojiFontUrl, 'Noto Color Emoji Compat', const [])); + } else { + html.window.console.warn('Error parsing CSS for Noto Emoji font.'); + } } /// Finds the minimum set of fonts which covers all of the [codeunits]. @@ -388,6 +392,9 @@ class _ResolvedNotoSubset { final List ranges; _ResolvedNotoSubset(this.url, this.family, this.ranges); + + @override + String toString() => '_ResolvedNotoSubset($family, $url)'; } _NotoFont _notoSansSC = _NotoFont('Noto Sans SC', [ @@ -660,21 +667,64 @@ class FallbackFontDownloadQueue { } class NotoDownloader { + int _debugActiveDownloadCount = 0; + + /// Returns a future that resolves when there are no pending downloads. + /// + /// Useful in tests to make sure that fonts are loaded before working with + /// text. + Future debugWhenIdle() async { + if (assertionsEnabled) { + // Some downloads begin asynchronously in a microtask or in a Timer.run. + // Let those run before waiting for downloads to finish. + await Future.delayed(Duration.zero); + while (_debugActiveDownloadCount > 0) { + await Future.delayed(const Duration(milliseconds: 100)); + // If we started with a non-zero count and hit zero while waiting, wait a + // little more to make sure another download doesn't get chained after + // the last one (e.g. font file download after font CSS download). + if (_debugActiveDownloadCount == 0) { + await Future.delayed(const Duration(milliseconds: 100)); + } + } + } else { + throw UnimplementedError(); + } + } + /// Downloads the [url] and returns it as a [ByteBuffer]. /// /// Override this for testing. Future downloadAsBytes(String url) { - return html.window.fetch(url).then((dynamic fetchResult) => fetchResult + if (assertionsEnabled) { + _debugActiveDownloadCount += 1; + } + final Future result = html.window.fetch(url).then((dynamic fetchResult) => fetchResult .arrayBuffer() .then((dynamic x) => x as ByteBuffer)); + if (assertionsEnabled) { + result.whenComplete(() { + _debugActiveDownloadCount -= 1; + }); + } + return result; } /// Downloads the [url] and returns is as a [String]. /// /// Override this for testing. Future downloadAsString(String url) { - return html.window.fetch(url).then((dynamic response) => + if (assertionsEnabled) { + _debugActiveDownloadCount += 1; + } + final Future result = html.window.fetch(url).then((dynamic response) => response.text().then((dynamic x) => x as String)); + if (assertionsEnabled) { + result.whenComplete(() { + _debugActiveDownloadCount -= 1; + }); + } + return result; } } diff --git a/lib/web_ui/lib/src/engine/canvaskit/text.dart b/lib/web_ui/lib/src/engine/canvaskit/text.dart index 3b7735e284086..a220f9b92d4fe 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/text.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/text.dart @@ -5,6 +5,7 @@ // @dart = 2.12 part of engine; +@immutable class CkParagraphStyle implements ui.ParagraphStyle { CkParagraphStyle({ ui.TextAlign? textAlign, @@ -32,20 +33,19 @@ class CkParagraphStyle implements ui.ParagraphStyle { strutStyle, ellipsis, locale, - ) { - _textDirection = textDirection ?? ui.TextDirection.ltr; - _fontFamily = fontFamily; - _fontSize = fontSize; - _fontWeight = fontWeight; - _fontStyle = fontStyle; - } - - SkParagraphStyle skParagraphStyle; - ui.TextDirection? _textDirection; - String? _fontFamily; - double? _fontSize; - ui.FontWeight? _fontWeight; - ui.FontStyle? _fontStyle; + ), + _textDirection = textDirection ?? ui.TextDirection.ltr, + _fontFamily = fontFamily, + _fontSize = fontSize, + _fontWeight = fontWeight, + _fontStyle = fontStyle; + + final SkParagraphStyle skParagraphStyle; + final ui.TextDirection? _textDirection; + final String? _fontFamily; + final double? _fontSize; + final ui.FontWeight? _fontWeight; + final ui.FontStyle? _fontStyle; static SkTextStyleProperties toSkTextStyleProperties( String? fontFamily, @@ -159,29 +159,8 @@ class CkParagraphStyle implements ui.ParagraphStyle { } } +@immutable class CkTextStyle implements ui.TextStyle { - SkTextStyle skTextStyle; - - ui.Color? color; - ui.TextDecoration? decoration; - ui.Color? decorationColor; - ui.TextDecorationStyle? decorationStyle; - double? decorationThickness; - ui.FontWeight? fontWeight; - ui.FontStyle? fontStyle; - ui.TextBaseline? textBaseline; - String? fontFamily; - List? fontFamilyFallback; - double? fontSize; - double? letterSpacing; - double? wordSpacing; - double? height; - ui.Locale? locale; - CkPaint? background; - CkPaint? foreground; - List? shadows; - List? fontFeatures; - factory CkTextStyle({ ui.Color? color, ui.TextDecoration? decoration, @@ -203,6 +182,126 @@ class CkTextStyle implements ui.TextStyle { List? shadows, List? fontFeatures, }) { + return CkTextStyle._( + color, + decoration, + decorationColor, + decorationStyle, + decorationThickness, + fontWeight, + fontStyle, + textBaseline, + fontFamily, + fontFamilyFallback, + fontSize, + letterSpacing, + wordSpacing, + height, + locale, + background, + foreground, + shadows, + fontFeatures, + ); + } + + CkTextStyle._( + this.color, + this.decoration, + this.decorationColor, + this.decorationStyle, + this.decorationThickness, + this.fontWeight, + this.fontStyle, + this.textBaseline, + this.fontFamily, + this.fontFamilyFallback, + this.fontSize, + this.letterSpacing, + this.wordSpacing, + this.height, + this.locale, + this.background, + this.foreground, + this.shadows, + this.fontFeatures, + ); + + final ui.Color? color; + final ui.TextDecoration? decoration; + final ui.Color? decorationColor; + final ui.TextDecorationStyle? decorationStyle; + final double? decorationThickness; + final ui.FontWeight? fontWeight; + final ui.FontStyle? fontStyle; + final ui.TextBaseline? textBaseline; + final String? fontFamily; + final List? fontFamilyFallback; + final double? fontSize; + final double? letterSpacing; + final double? wordSpacing; + final double? height; + final ui.Locale? locale; + final CkPaint? background; + final CkPaint? foreground; + final List? shadows; + final List? fontFeatures; + + /// Merges this text style with [other] and returns the new text style. + /// + /// The values in this text style are used unless [other] specifically + /// overrides it. + CkTextStyle mergeWith(CkTextStyle other) { + return CkTextStyle( + color: other.color ?? color, + decoration: other.decoration ?? decoration, + decorationColor: other.decorationColor ?? decorationColor, + decorationStyle: other.decorationStyle ?? decorationStyle, + decorationThickness: other.decorationThickness ?? decorationThickness, + fontWeight: other.fontWeight ?? fontWeight, + fontStyle: other.fontStyle ?? fontStyle, + textBaseline: other.textBaseline ?? textBaseline, + fontFamily: other.fontFamily ?? fontFamily, + fontFamilyFallback: other.fontFamilyFallback ?? fontFamilyFallback, + fontSize: other.fontSize ?? fontSize, + letterSpacing: other.letterSpacing ?? letterSpacing, + wordSpacing: other.wordSpacing ?? wordSpacing, + height: other.height ?? height, + locale: other.locale ?? locale, + background: other.background ?? background, + foreground: other.foreground ?? foreground, + shadows: other.shadows ?? shadows, + fontFeatures: other.fontFeatures ?? fontFeatures, + ); + } + + /// Lazy-initialized list of font families sent to Skia. + late final List effectiveFontFamilies = _getEffectiveFontFamilies(fontFamily, fontFamilyFallback); + + /// Lazy-initialized Skia style used to pass the style to Skia. + /// + /// This is lazy because not every style ends up being passed to Skia, so the + /// conversion would be wasteful. + late final SkTextStyle skTextStyle = () { + // Write field values to locals so null checks promote types to non-null. + final ui.Color? color = this.color; + final ui.TextDecoration? decoration = this.decoration; + final ui.Color? decorationColor = this.decorationColor; + final ui.TextDecorationStyle? decorationStyle = this.decorationStyle; + final double? decorationThickness = this.decorationThickness; + final ui.FontWeight? fontWeight = this.fontWeight; + final ui.FontStyle? fontStyle = this.fontStyle; + final ui.TextBaseline? textBaseline = this.textBaseline; + final double? fontSize = this.fontSize; + final double? letterSpacing = this.letterSpacing; + final double? wordSpacing = this.wordSpacing; + final double? height = this.height; + final ui.Locale? locale = this.locale; + final CkPaint? background = this.background; + final CkPaint? foreground = this.foreground; + final List? shadows = this.shadows; + final List? fontFeatures = this.fontFeatures; + final SkTextStyleProperties properties = SkTextStyleProperties(); if (background != null) { @@ -263,8 +362,7 @@ class CkTextStyle implements ui.TextStyle { properties.locale = locale.toLanguageTag(); } - properties.fontFamilies = - _getEffectiveFontFamilies(fontFamily, fontFamilyFallback); + properties.fontFamilies = effectiveFontFamilies; if (fontWeight != null || fontStyle != null) { properties.fontStyle = toSkFontStyle(fontWeight, fontStyle); @@ -287,90 +385,18 @@ class CkTextStyle implements ui.TextStyle { } if (fontFeatures != null) { - List ckFontFeatures = []; + List skFontFeatures = []; for (ui.FontFeature fontFeature in fontFeatures) { - SkFontFeature ckFontFeature = SkFontFeature(); - ckFontFeature.name = fontFeature.feature; - ckFontFeature.value = fontFeature.value; - ckFontFeatures.add(ckFontFeature); + SkFontFeature skFontFeature = SkFontFeature(); + skFontFeature.name = fontFeature.feature; + skFontFeature.value = fontFeature.value; + skFontFeatures.add(skFontFeature); } - properties.fontFeatures = ckFontFeatures; + properties.fontFeatures = skFontFeatures; } - return CkTextStyle._( - canvasKit.TextStyle(properties), - color, - decoration, - decorationColor, - decorationStyle, - decorationThickness, - fontWeight, - fontStyle, - textBaseline, - fontFamily, - fontFamilyFallback, - fontSize, - letterSpacing, - wordSpacing, - height, - locale, - background, - foreground, - shadows, - fontFeatures, - ); - } - - /// Merges this text style with [other] and returns the new text style. - /// - /// The values in this text style are used unless [other] specifically - /// overrides it. - CkTextStyle mergeWith(CkTextStyle other) { - return CkTextStyle( - color: other.color ?? color, - decoration: other.decoration ?? decoration, - decorationColor: other.decorationColor ?? decorationColor, - decorationStyle: other.decorationStyle ?? decorationStyle, - decorationThickness: other.decorationThickness ?? decorationThickness, - fontWeight: other.fontWeight ?? fontWeight, - fontStyle: other.fontStyle ?? fontStyle, - textBaseline: other.textBaseline ?? textBaseline, - fontFamily: other.fontFamily ?? fontFamily, - fontFamilyFallback: other.fontFamilyFallback ?? fontFamilyFallback, - fontSize: other.fontSize ?? fontSize, - letterSpacing: other.letterSpacing ?? letterSpacing, - wordSpacing: other.wordSpacing ?? wordSpacing, - height: other.height ?? height, - locale: other.locale ?? locale, - background: other.background ?? background, - foreground: other.foreground ?? foreground, - shadows: other.shadows ?? shadows, - fontFeatures: other.fontFeatures ?? fontFeatures, - ); - } - - CkTextStyle._( - this.skTextStyle, - this.color, - this.decoration, - this.decorationColor, - this.decorationStyle, - this.decorationThickness, - this.fontWeight, - this.fontStyle, - this.textBaseline, - this.fontFamily, - this.fontFamilyFallback, - this.fontSize, - this.letterSpacing, - this.wordSpacing, - this.height, - this.locale, - this.background, - this.foreground, - this.shadows, - this.fontFeatures, - ); + return canvasKit.TextStyle(properties); + }(); } SkFontStyle toSkFontStyle(ui.FontWeight? fontWeight, ui.FontStyle? fontStyle) { @@ -582,7 +608,9 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { _paragraphBuilder = canvasKit.ParagraphBuilder.MakeFromFontProvider( style.skParagraphStyle, skiaFontCollection.fontProvider, - ); + ) { + _styleStack.add(_style.getTextStyle()); + } @override void addPlaceholder( @@ -658,8 +686,7 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { return; } CkTextStyle style = _peekStyle(); - List fontFamilies = - _getEffectiveFontFamilies(style.fontFamily, style.fontFamilyFallback); + List fontFamilies = style.effectiveFontFamilies; List typefaces = []; for (var font in fontFamilies) { List? typefacesForFamily = @@ -668,14 +695,14 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { typefaces.addAll(typefacesForFamily); } } - List codeUnitsSupported = List.filled(text.length, false); + List codeUnits = text.runes.toList(); + List codeUnitsSupported = List.filled(codeUnits.length, false); for (SkTypeface typeface in typefaces) { SkFont font = SkFont(typeface); Uint8List glyphs = font.getGlyphIDs(text); assert(glyphs.length == codeUnitsSupported.length); for (int i = 0; i < glyphs.length; i++) { - codeUnitsSupported[i] |= - glyphs[i] != 0 || _isControlCode(text.codeUnitAt(i)); + codeUnitsSupported[i] |= glyphs[i] != 0 || _isControlCode(codeUnits[i]); } } @@ -683,7 +710,7 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { List missingCodeUnits = []; for (int i = 0; i < codeUnitsSupported.length; i++) { if (!codeUnitsSupported[i]) { - missingCodeUnits.add(text.codeUnitAt(i)); + missingCodeUnits.add(codeUnits[i]); } } _findFontsForMissingCodeunits(missingCodeUnits); @@ -723,13 +750,25 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { @override void pop() { + if (_styleStack.length <= 1) { + // The top-level text style is paragraph-level. We don't pop it off. + if (assertionsEnabled) { + html.window.console.warn( + 'Cannot pop text style in ParagraphBuilder. ' + 'Already popped all text styles from the style stack.', + ); + } + return; + } _commands.add(const _ParagraphCommand.pop()); _styleStack.removeLast(); _paragraphBuilder.pop(); } - CkTextStyle _peekStyle() => - _styleStack.isEmpty ? _style.getTextStyle() : _styleStack.last; + CkTextStyle _peekStyle() { + assert(_styleStack.isNotEmpty); + return _styleStack.last; + } // Used as the paint for background or foreground in the text style when // the other one is not specified. CanvasKit either both background and @@ -738,7 +777,8 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { // // This object is never deleted. It is effectively a static global constant. // Therefore it doesn't need to be wrapped in CkPaint. - static final SkPaint _defaultTextStylePaint = SkPaint(); + static final SkPaint _defaultTextForeground = SkPaint(); + static final SkPaint _defaultTextBackground = SkPaint()..setColorInt(0x00000000); @override void pushStyle(ui.TextStyle style) { @@ -748,10 +788,15 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { _styleStack.add(skStyle); _commands.add(_ParagraphCommand.pushStyle(ckStyle)); if (skStyle.foreground != null || skStyle.background != null) { - final SkPaint foreground = - skStyle.foreground?.skiaObject ?? _defaultTextStylePaint; - final SkPaint background = - skStyle.background?.skiaObject ?? _defaultTextStylePaint; + SkPaint? foreground = skStyle.foreground?.skiaObject; + if (foreground == null) { + _defaultTextForeground.setColorInt( + skStyle.color?.value ?? 0xFF000000, + ); + foreground = _defaultTextForeground; + } + + final SkPaint background = skStyle.background?.skiaObject ?? _defaultTextBackground; _paragraphBuilder.pushPaintStyle( skStyle.skTextStyle, foreground, background); } else { diff --git a/lib/web_ui/lib/src/engine/dom_canvas.dart b/lib/web_ui/lib/src/engine/dom_canvas.dart index c9e4cb8d4b377..7c7bf3451766c 100644 --- a/lib/web_ui/lib/src/engine/dom_canvas.dart +++ b/lib/web_ui/lib/src/engine/dom_canvas.dart @@ -256,6 +256,7 @@ html.Element _pathToSvgElement(SurfacePath path, SurfacePaintData paint, if (paint.style == ui.PaintingStyle.stroke) { sb.write('stroke="${colorToCssString(color)}" '); sb.write('stroke-width="${paint.strokeWidth}" '); + sb.write('fill="none" '); } else if (paint.color != null) { sb.write('fill="${colorToCssString(color)}" '); } else { diff --git a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart index af41960ec6c12..670a9b32cec50 100644 --- a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart +++ b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart @@ -20,6 +20,7 @@ class CanvasParagraph implements EngineParagraph { required this.paragraphStyle, required this.plainText, required this.placeholderCount, + required this.drawOnCanvas, }); /// The flat list of spans that make up this paragraph. @@ -34,6 +35,9 @@ class CanvasParagraph implements EngineParagraph { /// The number of placeholders in this paragraph. final int placeholderCount; + @override + final bool drawOnCanvas; + @override double get width => _layoutService.width; @@ -41,7 +45,7 @@ class CanvasParagraph implements EngineParagraph { double get height => _layoutService.height; @override - double get longestLine => _layoutService.longestLine; + double get longestLine => _layoutService.longestLine?.width ?? 0.0; @override double get minIntrinsicWidth => _layoutService.minIntrinsicWidth; @@ -137,6 +141,15 @@ class CanvasParagraph implements EngineParagraph { // to insert our own
breaks based on layout results. ..whiteSpace = 'pre'; + if (width > longestLine) { + // In this case, we set the width so that the CSS text-align property + // works correctly. + // When `longestLine` is >= `paragraph.width` that means the DOM element + // will automatically size itself to fit the longest line, so there's no + // need to set an explicit width. + cssStyle.width = '${width}px'; + } + if (paragraphStyle._maxLines != null || paragraphStyle._ellipsis != null) { cssStyle ..overflowY = 'hidden' @@ -199,15 +212,6 @@ class CanvasParagraph implements EngineParagraph { return _layoutService.getBoxesForPlaceholders(); } - // TODO(mdebbar): Check for child spans if any has styles that can't be drawn - // on a canvas. e.g: - // - decoration - // - word-spacing - // - shadows (may be possible? https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/shadowBlur) - // - font features - @override - final bool drawOnCanvas = true; - @override List getBoxesForRange( int start, @@ -234,9 +238,19 @@ class CanvasParagraph implements EngineParagraph { @override ui.TextRange getLineBoundary(ui.TextPosition position) { - // TODO(mdebbar): After layout, line metrics should be available and can be - // used to determine the line boundary of the given `position`. - return ui.TextRange.empty; + final int index = position.offset; + final List lines = computeLineMetrics(); + + int i; + for (i = 0; i < lines.length - 1; i++) { + final EngineLineMetrics line = lines[i]; + if (index >= line.startIndex && index < line.endIndex) { + break; + } + } + + final EngineLineMetrics line = lines[i]; + return ui.TextRange(start: line.startIndex, end: line.endIndex); } @override @@ -599,6 +613,8 @@ class CanvasParagraphBuilder implements ui.ParagraphBuilder { } } + bool _drawOnCanvas = true; + @override void addText(String text) { final EngineTextStyle style = _currentStyleNode.resolveStyle(); @@ -606,6 +622,20 @@ class CanvasParagraphBuilder implements ui.ParagraphBuilder { _plainTextBuffer.write(text); final int end = _plainTextBuffer.length; + if (_drawOnCanvas) { + final ui.TextDecoration? decoration = style._decoration; + if (decoration != null && decoration != ui.TextDecoration.none) { + _drawOnCanvas = false; + } + } + + if (_drawOnCanvas) { + final List? fontFeatures = style._fontFeatures; + if (fontFeatures != null && fontFeatures.isNotEmpty) { + _drawOnCanvas = false; + } + } + _spans.add(FlatTextSpan(style: style, start: start, end: end)); } @@ -616,6 +646,7 @@ class CanvasParagraphBuilder implements ui.ParagraphBuilder { paragraphStyle: _paragraphStyle, plainText: _plainTextBuffer.toString(), placeholderCount: _placeholderCount, + drawOnCanvas: _drawOnCanvas, ); } } diff --git a/lib/web_ui/lib/src/engine/text/font_collection.dart b/lib/web_ui/lib/src/engine/text/font_collection.dart index 3d09d8d393ac5..8587c9713c6e5 100644 --- a/lib/web_ui/lib/src/engine/text/font_collection.dart +++ b/lib/web_ui/lib/src/engine/text/font_collection.dart @@ -8,7 +8,7 @@ part of engine; const String _ahemFontFamily = 'Ahem'; const String _ahemFontUrl = 'packages/ui/assets/ahem.ttf'; const String _robotoFontFamily = 'Roboto'; -const String _robotoFontUrl = 'packages/ui/assets/Roboto-Regular.ttf'; +const String _robotoTestFontUrl = 'packages/ui/assets/Roboto-Regular.ttf'; /// This class is responsible for registering and loading fonts. /// @@ -79,7 +79,7 @@ class FontCollection { _testFontManager!.registerAsset( _ahemFontFamily, 'url($_ahemFontUrl)', const {}); _testFontManager!.registerAsset( - _robotoFontFamily, 'url($_robotoFontUrl)', const {}); + _robotoFontFamily, 'url($_robotoTestFontUrl)', const {}); } /// Returns a [Future] that completes when the registered fonts are loaded diff --git a/lib/web_ui/lib/src/engine/text/layout_service.dart b/lib/web_ui/lib/src/engine/text/layout_service.dart index 120b38c6d406a..57cf525004f71 100644 --- a/lib/web_ui/lib/src/engine/text/layout_service.dart +++ b/lib/web_ui/lib/src/engine/text/layout_service.dart @@ -23,7 +23,7 @@ class TextLayoutService { double height = 0.0; - double longestLine = 0.0; + EngineLineMetrics? longestLine; double minIntrinsicWidth = 0.0; @@ -65,7 +65,7 @@ class TextLayoutService { // Reset results from previous layout. width = constraints.width; height = 0.0; - longestLine = 0.0; + longestLine = null; minIntrinsicWidth = 0.0; maxIntrinsicWidth = 0.0; didExceedMaxLines = false; @@ -187,8 +187,9 @@ class TextLayoutService { alphabeticBaseline = line.baseline; ideographicBaseline = alphabeticBaseline * _baselineRatioHack; } - if (longestLine < line.width) { - longestLine = line.width; + final double longestLineWidth = longestLine?.width ?? 0.0; + if (longestLineWidth < line.width) { + longestLine = line; } } diff --git a/lib/web_ui/test/canvaskit/canvas_golden_test.dart b/lib/web_ui/test/canvaskit/canvas_golden_test.dart index f0f56cccefded..3688f67e0171b 100644 --- a/lib/web_ui/test/canvaskit/canvas_golden_test.dart +++ b/lib/web_ui/test/canvaskit/canvas_golden_test.dart @@ -212,11 +212,381 @@ void testMain() { dispatcher.rasterizer!.draw(layerTree); await matchGoldenFile('canvaskit_shadow_bounds.png', region: region); }); + + test('text styles - default', () async { + await testTextStyle('default'); + }); + + test('text styles - center aligned', () async { + await testTextStyle('center aligned', paragraphTextAlign: ui.TextAlign.center); + }); + + test('text styles - right aligned', () async { + await testTextStyle('right aligned', paragraphTextAlign: ui.TextAlign.right); + }); + + test('text styles - rtl', () async { + await testTextStyle('rtl', paragraphTextDirection: ui.TextDirection.rtl); + }); + + test('text styles - multiline', () async { + await testTextStyle('multiline', layoutWidth: 50); + }); + + test('text styles - max lines', () async { + await testTextStyle('max lines', paragraphMaxLines: 1, layoutWidth: 50); + }); + + test('text styles - ellipsis', () async { + await testTextStyle('ellipsis', paragraphMaxLines: 1, paragraphEllipsis: '...', layoutWidth: 60); + }); + + test('text styles - paragraph font family', () async { + await testTextStyle('paragraph font family', paragraphFontFamily: 'Ahem'); + }); + + test('text styles - paragraph font size', () async { + await testTextStyle('paragraph font size', paragraphFontSize: 22); + }); + + // TODO(yjbanov): paragraphHeight seems to have no effect, but maybe I'm using it wrong. + // https://github.com/flutter/flutter/issues/74337 + test('text styles - paragraph height', () async { + await testTextStyle('paragraph height', layoutWidth: 50, paragraphHeight: 1.5); + }); + + // TODO(yjbanov): paragraphTextHeightBehavior seems to have no effect. Unsure how to use it. + // https://github.com/flutter/flutter/issues/74337 + test('text styles - paragraph text height behavior', () async { + await testTextStyle('paragraph text height behavior', layoutWidth: 50, paragraphHeight: 1.5, paragraphTextHeightBehavior: ui.TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + )); + }); + + // TODO(yjbanov): paragraph fontWeight doesn't seem to work. + // https://github.com/flutter/flutter/issues/74338 + test('text styles - paragraph weight', () async { + await testTextStyle('paragraph weight', paragraphFontWeight: ui.FontWeight.w900); + }); + + // TODO(yjbanov): paragraph fontStyle doesn't seem to work. + // https://github.com/flutter/flutter/issues/74338 + test('text style - paragraph font style', () async { + await testTextStyle( + 'paragraph font style', + paragraphFontStyle: ui.FontStyle.italic, + ); + }); + + // TODO(yjbanov): locales specified in paragraph styles don't work: + // https://github.com/flutter/flutter/issues/74687 + // TODO(yjbanov): spaces are not rendered correctly: + // https://github.com/flutter/flutter/issues/74742 + test('text styles - paragraph locale zh_CN', () async { + await testTextStyle('paragraph locale zh_CN', outerText: '次 化 刃 直 入 令', innerText: '', paragraphLocale: const ui.Locale('zh', 'CN')); + }); + + test('text styles - paragraph locale zh_TW', () async { + await testTextStyle('paragraph locale zh_TW', outerText: '次 化 刃 直 入 令', innerText: '', paragraphLocale: const ui.Locale('zh', 'TW')); + }); + + test('text styles - paragraph locale ja', () async { + await testTextStyle('paragraph locale ja', outerText: '次 化 刃 直 入 令', innerText: '', paragraphLocale: const ui.Locale('ja')); + }); + + test('text styles - paragraph locale ko', () async { + await testTextStyle('paragraph locale ko', outerText: '次 化 刃 直 入 令', innerText: '', paragraphLocale: const ui.Locale('ko')); + }); + + test('text styles - color', () async { + await testTextStyle('color', color: const ui.Color(0xFF009900)); + }); + + test('text styles - decoration', () async { + await testTextStyle('decoration', decoration: ui.TextDecoration.underline); + }); + + test('text styles - decoration style', () async { + await testTextStyle('decoration style', decoration: ui.TextDecoration.underline, decorationStyle: ui.TextDecorationStyle.dashed); + }); + + test('text styles - decoration thickness', () async { + await testTextStyle('decoration thickness', decoration: ui.TextDecoration.underline, decorationThickness: 5.0); + }); + + test('text styles - font weight', () async { + await testTextStyle('font weight', fontWeight: ui.FontWeight.w900); + }); + + test('text styles - font style', () async { + await testTextStyle('font style', fontStyle: ui.FontStyle.italic); + }); + + // TODO(yjbanov): not sure how to test this. + test('text styles - baseline', () async { + await testTextStyle('baseline', textBaseline: ui.TextBaseline.ideographic); + }); + + test('text styles - font family', () async { + await testTextStyle('font family', fontFamily: 'Ahem'); + }); + + test('text styles - non-existent font family', () async { + await testTextStyle('non-existent font family', fontFamily: 'DoesNotExist'); + }); + + test('text styles - family fallback', () async { + await testTextStyle('family fallback', fontFamily: 'DoesNotExist', fontFamilyFallback: ['Ahem']); + }); + + test('text styles - font size', () async { + await testTextStyle('font size', fontSize: 24); + }); + + test('text styles - letter spacing', () async { + await testTextStyle('letter spacing', letterSpacing: 5); + }); + + test('text styles - word spacing', () async { + await testTextStyle('word spacing', innerText: 'Beautiful World!', wordSpacing: 25); + }); + + test('text styles - height', () async { + await testTextStyle('height', height: 2); + }); + + // TODO(yjbanov): locales specified in text styles don't work: + // https://github.com/flutter/flutter/issues/74687 + // TODO(yjbanov): spaces are not rendered correctly: + // https://github.com/flutter/flutter/issues/74742 + test('text styles - locale zh_CN', () async { + await testTextStyle('locale zh_CN', innerText: '次 化 刃 直 入 令', outerText: '', locale: const ui.Locale('zh', 'CN')); + }); + + test('text styles - locale zh_TW', () async { + await testTextStyle('locale zh_TW', innerText: '次 化 刃 直 入 令', outerText: '', locale: const ui.Locale('zh', 'TW')); + }); + + test('text styles - locale ja', () async { + await testTextStyle('locale ja', innerText: '次 化 刃 直 入 令', outerText: '', locale: const ui.Locale('ja')); + }); + + test('text styles - locale ko', () async { + await testTextStyle('locale ko', innerText: '次 化 刃 直 入 令', outerText: '', locale: const ui.Locale('ko')); + }); + + test('text styles - background', () async { + await testTextStyle('background', background: CkPaint()..color = const ui.Color(0xFF00FF00)); + }); + + test('text styles - foreground', () async { + await testTextStyle('foreground', foreground: CkPaint()..color = const ui.Color(0xFF0000FF)); + }); + + test('text styles - foreground and background', () async { + await testTextStyle( + 'foreground and background', + foreground: CkPaint()..color = const ui.Color(0xFFFF5555), + background: CkPaint()..color = const ui.Color(0xFF007700), + ); + }); + + test('text styles - background and color', () async { + await testTextStyle( + 'background and color', + color: const ui.Color(0xFFFFFF00), + background: CkPaint()..color = const ui.Color(0xFF007700), + ); + }); + + test('text styles - shadows', () async { + await testTextStyle('shadows', shadows: [ + ui.Shadow( + color: const ui.Color(0xFF999900), + offset: const ui.Offset(10, 10), + blurRadius: 5, + ), + ui.Shadow( + color: const ui.Color(0xFF009999), + offset: const ui.Offset(-10, -10), + blurRadius: 10, + ), + ]); + }); + + test('text styles - old style figures', () async { + // TODO(yjbanov): we should not need to reset the fallbacks, see + // https://github.com/flutter/flutter/issues/74741 + skiaFontCollection.debugResetFallbackFonts(); + await testTextStyle( + 'old style figures', + paragraphFontFamily: 'Roboto', + paragraphFontSize: 24, + outerText: '0 1 2 3 4 5 ', + innerText: '0 1 2 3 4 5', + fontFeatures: [const ui.FontFeature.oldstyleFigures()], + ); + }); + + test('text styles - stylistic set 1', () async { + // TODO(yjbanov): we should not need to reset the fallbacks, see + // https://github.com/flutter/flutter/issues/74741 + skiaFontCollection.debugResetFallbackFonts(); + await testTextStyle( + 'stylistic set 1', + paragraphFontFamily: 'Roboto', + paragraphFontSize: 24, + outerText: 'g', + innerText: 'g', + fontFeatures: [ui.FontFeature.stylisticSet(1)], + ); + }); + + test('text styles - stylistic set 2', () async { + // TODO(yjbanov): we should not need to reset the fallbacks, see + // https://github.com/flutter/flutter/issues/74741 + skiaFontCollection.debugResetFallbackFonts(); + await testTextStyle( + 'stylistic set 2', + paragraphFontFamily: 'Roboto', + paragraphFontSize: 24, + outerText: 'α', + innerText: 'α', + fontFeatures: [ui.FontFeature.stylisticSet(2)], + ); + }); + + test('text styles - override font family', () async { + await testTextStyle( + 'override font family', + paragraphFontFamily: 'Ahem', + fontFamily: 'Roboto', + ); + }); + + test('text styles - override font size', () async { + await testTextStyle( + 'override font size', + paragraphFontSize: 36, + fontSize: 18, + ); + }); + + // TODO(yjbanov): paragraph fontWeight doesn't seem to work. + // https://github.com/flutter/flutter/issues/74338 + test('text style - override font weight', () async { + await testTextStyle( + 'override font weight', + paragraphFontWeight: ui.FontWeight.w900, + fontWeight: ui.FontWeight.normal, + ); + }); + + // TODO(yjbanov): paragraph fontStyle doesn't seem to work. + // https://github.com/flutter/flutter/issues/74338 + test('text style - override font style', () async { + await testTextStyle( + 'override font style', + paragraphFontStyle: ui.FontStyle.italic, + fontStyle: ui.FontStyle.normal, + ); + }); + + test('text style - foreground/background/color do not leak across paragraphs', () async { + const double testWidth = 440; + const double middle = testWidth / 2; + CkParagraph createTestParagraph({ + ui.Color? color, + CkPaint? foreground, + CkPaint? background + }) { + final CkParagraphBuilder builder = CkParagraphBuilder(CkParagraphStyle()); + builder.pushStyle(CkTextStyle( + fontSize: 16, + color: color, + foreground: foreground, + background: background, + )); + final StringBuffer text = StringBuffer(); + if (color == null && foreground == null && background == null) { + text.write('Default'); + } else { + if (color != null) { + text.write('Color'); + } + if (foreground != null) { + if (text.isNotEmpty) { + text.write('+'); + } + text.write('Foreground'); + } + if (background != null) { + if (text.isNotEmpty) { + text.write('+'); + } + text.write('Background'); + } + } + builder.addText(text.toString()); + final CkParagraph paragraph = builder.build(); + paragraph.layout(ui.ParagraphConstraints(width: testWidth)); + return paragraph; + } + + final List variations = [ + () => createTestParagraph(), + () => createTestParagraph(color: ui.Color(0xFF009900)), + () => createTestParagraph(foreground: CkPaint()..color = ui.Color(0xFF990000)), + () => createTestParagraph(background: CkPaint()..color = ui.Color(0xFF7777FF)), + () => createTestParagraph( + color: ui.Color(0xFFFF00FF), + background: CkPaint()..color = ui.Color(0xFF0000FF), + ), + () => createTestParagraph( + foreground: CkPaint()..color = ui.Color(0xFF00FFFF), + background: CkPaint()..color = ui.Color(0xFF0000FF), + ), + ]; + + final CkPictureRecorder recorder = CkPictureRecorder(); + final CkCanvas canvas = recorder.beginRecording(ui.Rect.largest); + canvas.translate(10, 10); + + for (ParagraphFactory from in variations) { + for (ParagraphFactory to in variations) { + canvas.save(); + final CkParagraph fromParagraph = from(); + canvas.drawParagraph(fromParagraph, ui.Offset.zero); + + final ui.Offset leftEnd = ui.Offset(fromParagraph.maxIntrinsicWidth + 10, fromParagraph.height / 2); + final ui.Offset rightEnd = ui.Offset(middle - 10, leftEnd.dy); + final ui.Offset tipOffset = ui.Offset(-5, -5); + canvas.drawLine(leftEnd, rightEnd, CkPaint()); + canvas.drawLine(rightEnd, rightEnd + tipOffset, CkPaint()); + canvas.drawLine(rightEnd, rightEnd + tipOffset.scale(1, -1), CkPaint()); + + canvas.translate(middle, 0); + canvas.drawParagraph(to(), ui.Offset.zero); + canvas.restore(); + canvas.translate(0, 22); + } + } + + final CkPicture picture = recorder.endRecording(); + await matchPictureGolden( + 'canvaskit_text_styles_do_not_leak.png', + picture, + region: ui.Rect.fromLTRB(0, 0, testWidth, 850), + ); + }); // TODO: https://github.com/flutter/flutter/issues/60040 // TODO: https://github.com/flutter/flutter/issues/71520 }, skip: isIosSafari || isFirefox); } +typedef ParagraphFactory = CkParagraph Function(); + void drawTestPicture(CkCanvas canvas) { canvas.clear(ui.Color(0xFFFFFFF)); @@ -547,3 +917,165 @@ CkImage generateTestImage() { 4 * 20); return CkImage(skImage); } + +/// A convenience function for testing paragraph and text styles. +/// +/// Renders a paragraph with two pieces of text, [outerText] and [innerText]. +/// [outerText] is added to the root of the paragraph where only paragraph +/// style applies. [innerText] is added under a text style with properties +/// set from the arguments to this method. Parameters with prefix "paragraph" +/// are applied to the paragraph style. Others are applied to the text style. +/// +/// [name] is the name of the test used as the description on the golden as +/// well as in the golden file name. Avoid special characters. Spaces are OK; +/// they are replaced by "_" in the file name. +/// +/// Set [write] to true to overwrite the golden file. +/// +/// Use [layoutWidth] to customize the width of the paragraph constraints. +Future testTextStyle( + // Test properties + String name, { + bool write = false, + double? layoutWidth, + // Top-level text where only paragraph style applies + String outerText = 'Hello ', + // Second-level text where paragraph and text styles both apply. + String innerText = 'World!', + + // ParagraphStyle properties + ui.TextAlign? paragraphTextAlign, + ui.TextDirection? paragraphTextDirection, + int? paragraphMaxLines, + String? paragraphFontFamily, + double? paragraphFontSize, + double? paragraphHeight, + ui.TextHeightBehavior? paragraphTextHeightBehavior, + ui.FontWeight? paragraphFontWeight, + ui.FontStyle? paragraphFontStyle, + ui.StrutStyle? paragraphStrutStyle, + String? paragraphEllipsis, + ui.Locale? paragraphLocale, + + // TextStyle properties + ui.Color? color, + ui.TextDecoration? decoration, + ui.Color? decorationColor, + ui.TextDecorationStyle? decorationStyle, + double? decorationThickness, + ui.FontWeight? fontWeight, + ui.FontStyle? fontStyle, + ui.TextBaseline? textBaseline, + String? fontFamily, + List? fontFamilyFallback, + double? fontSize, + double? letterSpacing, + double? wordSpacing, + double? height, + ui.Locale? locale, + CkPaint? background, + CkPaint? foreground, + List? shadows, + List? fontFeatures, +}) async { + late ui.Rect region; + CkPicture renderPicture() { + const double testWidth = 512; + final CkPictureRecorder recorder = CkPictureRecorder(); + final CkCanvas canvas = recorder.beginRecording(ui.Rect.largest); + canvas.translate(30, 10); + final CkParagraphBuilder descriptionBuilder = CkParagraphBuilder(CkParagraphStyle()); + descriptionBuilder.addText(name); + final CkParagraph descriptionParagraph = descriptionBuilder.build(); + descriptionParagraph.layout(ui.ParagraphConstraints(width: testWidth / 2 - 70)); + final ui.Offset descriptionOffset = ui.Offset(testWidth / 2 + 30, 0); + canvas.drawParagraph(descriptionParagraph, descriptionOffset); + + final CkParagraphBuilder pb = CkParagraphBuilder(CkParagraphStyle( + textAlign: paragraphTextAlign, + textDirection: paragraphTextDirection, + maxLines: paragraphMaxLines, + fontFamily: paragraphFontFamily, + fontSize: paragraphFontSize, + height: paragraphHeight, + textHeightBehavior: paragraphTextHeightBehavior, + fontWeight: ui.FontWeight.normal, + fontStyle: ui.FontStyle.normal, + strutStyle: paragraphStrutStyle, + ellipsis: paragraphEllipsis, + locale: paragraphLocale, + )); + + pb.addText(outerText); + + pb.pushStyle(CkTextStyle( + color: color, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + decorationThickness: decorationThickness, + fontWeight: fontWeight, + fontStyle: fontStyle, + textBaseline: textBaseline, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSize: fontSize, + letterSpacing: letterSpacing, + wordSpacing: wordSpacing, + height: height, + locale: locale, + background: background, + foreground: foreground, + shadows: shadows, + fontFeatures: fontFeatures, + )); + pb.addText(innerText); + pb.pop(); + final CkParagraph p = pb.build(); + p.layout(ui.ParagraphConstraints(width: layoutWidth ?? testWidth / 2)); + canvas.drawParagraph(p, ui.Offset.zero); + + canvas.drawPath( + CkPath() + ..moveTo(-10, 0) + ..lineTo(-20, 0) + ..lineTo(-20, p.height) + ..lineTo(-10, p.height), + CkPaint() + ..style = ui.PaintingStyle.stroke + ..strokeWidth = 1.0, + ); + canvas.drawPath( + CkPath() + ..moveTo(testWidth / 2 + 10, 0) + ..lineTo(testWidth / 2 + 20, 0) + ..lineTo(testWidth / 2 + 20, p.height) + ..lineTo(testWidth / 2 + 10, p.height), + CkPaint() + ..style = ui.PaintingStyle.stroke + ..strokeWidth = 1.0, + ); + const double padding = 20; + region = ui.Rect.fromLTRB( + 0, 0, testWidth, + math.max( + descriptionOffset.dy + descriptionParagraph.height + padding, + p.height + padding, + ), + ); + return recorder.endRecording(); + } + + // Render once to trigger font downloads. + renderPicture(); + // Wait for fonts to finish loading. + await notoDownloadQueue.downloader.debugWhenIdle(); + // Render again for actual screenshotting. + final CkPicture picture = renderPicture(); + await matchPictureGolden( + 'canvaskit_text_styles_${name.replaceAll(' ', '_')}.png', + picture, + region: region, + write: write, + ); +} diff --git a/lib/web_ui/test/canvaskit/embedded_views_test.dart b/lib/web_ui/test/canvaskit/embedded_views_test.dart index a1dc4f99f1695..4788d61c5cead 100644 --- a/lib/web_ui/test/canvaskit/embedded_views_test.dart +++ b/lib/web_ui/test/canvaskit/embedded_views_test.dart @@ -287,9 +287,79 @@ void testMain() { } on AssertionError catch (error) { expect( error.toString(), - 'Assertion failed: "Cannot render platform view 0. It has not been created, or it has been deleted."', + 'Assertion failed: "Cannot render platform views: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9. These views have not been created, or they have been deleted."', ); } + + // Frame 7: + // Render: a platform view after error. + // Expect: success. Just checking the system is not left in a corrupted state. + await _createPlatformView(0, 'test-platform-view'); + renderTestScene(viewCount: 0); + }); + + test('embeds and disposes of a platform view', () async { + ui.platformViewRegistry.registerViewFactory( + 'test-platform-view', + (viewId) => html.DivElement()..id = 'view-0', + ); + await _createPlatformView(0, 'test-platform-view'); + + final EnginePlatformDispatcher dispatcher = + ui.window.platformDispatcher as EnginePlatformDispatcher; + + LayerSceneBuilder sb = LayerSceneBuilder(); + sb.pushOffset(0, 0); + sb.addPlatformView(0, width: 10, height: 10); + dispatcher.rasterizer!.draw(sb.build().layerTree); + + expect( + domRenderer.sceneElement!.querySelectorAll('#view-0'), + hasLength(1), + ); + + await _disposePlatformView(0); + + sb = LayerSceneBuilder(); + sb.pushOffset(0, 0); + dispatcher.rasterizer!.draw(sb.build().layerTree); + + expect( + domRenderer.sceneElement!.querySelectorAll('#view-0'), + hasLength(0), + ); + }); + + test('removed the DOM node of an unrendered platform view', () async { + ui.platformViewRegistry.registerViewFactory( + 'test-platform-view', + (viewId) => html.DivElement()..id = 'view-0', + ); + await _createPlatformView(0, 'test-platform-view'); + + final EnginePlatformDispatcher dispatcher = + ui.window.platformDispatcher as EnginePlatformDispatcher; + + LayerSceneBuilder sb = LayerSceneBuilder(); + sb.pushOffset(0, 0); + sb.addPlatformView(0, width: 10, height: 10); + dispatcher.rasterizer!.draw(sb.build().layerTree); + + expect( + domRenderer.sceneElement!.querySelectorAll('#view-0'), + hasLength(1), + ); + + // Render a frame without a platform view, but also without disposing of + // the platform view. + sb = LayerSceneBuilder(); + sb.pushOffset(0, 0); + dispatcher.rasterizer!.draw(sb.build().layerTree); + + expect( + domRenderer.sceneElement!.querySelectorAll('#view-0'), + hasLength(0), + ); }); // TODO: https://github.com/flutter/flutter/issues/60040 }, skip: isIosSafari); @@ -311,3 +381,13 @@ Future _createPlatformView(int id, String viewType) { ); return completer.future; } + +Future _disposePlatformView(int id) { + final completer = Completer(); + window.sendPlatformMessage( + 'flutter/platform_views', + codec.encodeMethodCall(MethodCall('dispose', id)), + (dynamic _) => completer.complete(), + ); + return completer.future; +} diff --git a/lib/web_ui/test/canvaskit/fallback_fonts_golden_test.dart b/lib/web_ui/test/canvaskit/fallback_fonts_golden_test.dart index 955ea4c54e48a..cc2fd0d1cff55 100644 --- a/lib/web_ui/test/canvaskit/fallback_fonts_golden_test.dart +++ b/lib/web_ui/test/canvaskit/fallback_fonts_golden_test.dart @@ -19,7 +19,7 @@ void main() { internalBootstrapBrowserTest(() => testMain); } -const ui.Rect kDefaultRegion = const ui.Rect.fromLTRB(0, 0, 500, 250); +const ui.Rect kDefaultRegion = const ui.Rect.fromLTRB(0, 0, 100, 50); Future matchPictureGolden(String goldenFile, CkPicture picture, {ui.Rect region = kDefaultRegion, bool write = false}) async { @@ -105,15 +105,15 @@ void testMain() { final CkCanvas canvas = recorder.beginRecording(kDefaultRegion); pb = CkParagraphBuilder( - CkParagraphStyle( - fontSize: 32, - ), + CkParagraphStyle(), ); + pb.pushStyle(ui.TextStyle(fontSize: 32)); pb.addText('مرحبا'); + pb.pop(); final CkParagraph paragraph = pb.build(); paragraph.layout(ui.ParagraphConstraints(width: 1000)); - canvas.drawParagraph(paragraph, ui.Offset(200, 120)); + canvas.drawParagraph(paragraph, ui.Offset(0, 0)); await matchPictureGolden( 'canvaskit_font_fallback_arabic.png', recorder.endRecording()); @@ -121,6 +121,70 @@ void testMain() { // TODO: https://github.com/flutter/flutter/issues/71520 }, skip: isIosSafari || isFirefox); + test('will download Noto Emojis and Noto Symbols if no matching Noto Font', + () async { + final Completer fontChangeCompleter = Completer(); + // Intercept the system font change message. + ui.window.onPlatformMessage = (String name, ByteData? data, + ui.PlatformMessageResponseCallback? callback) { + if (name == 'flutter/system') { + const JSONMessageCodec codec = JSONMessageCodec(); + final dynamic message = codec.decodeMessage(data); + if (message is Map) { + if (message['type'] == 'fontsChange') { + fontChangeCompleter.complete(); + } + } + } + if (savedCallback != null) { + savedCallback!(name, data, callback); + } + }; + + TestDownloader.mockDownloads[ + 'https://fonts.googleapis.com/css2?family=Noto+Color+Emoji+Compat'] = + ''' +/* arabic */ +@font-face { + font-family: 'Noto Color Emoji'; + src: url(packages/ui/assets/NotoColorEmoji.ttf) format('ttf'); +} +'''; + + expect(skiaFontCollection.globalFontFallbacks, ['Roboto']); + + // Creating this paragraph should cause us to start to download the + // fallback font. + CkParagraphBuilder pb = CkParagraphBuilder( + CkParagraphStyle(), + ); + pb.addText('Hello 😊'); + + await fontChangeCompleter.future; + + expect(skiaFontCollection.globalFontFallbacks, + contains('Noto Color Emoji Compat 0')); + + final CkPictureRecorder recorder = CkPictureRecorder(); + final CkCanvas canvas = recorder.beginRecording(kDefaultRegion); + + pb = CkParagraphBuilder( + CkParagraphStyle(), + ); + pb.pushStyle(ui.TextStyle(fontSize: 26)); + pb.addText('Hello 😊'); + pb.pop(); + final CkParagraph paragraph = pb.build(); + paragraph.layout(ui.ParagraphConstraints(width: 1000)); + + canvas.drawParagraph(paragraph, ui.Offset(0, 0)); + + await matchPictureGolden( + 'canvaskit_font_fallback_emoji.png', recorder.endRecording()); + // TODO: https://github.com/flutter/flutter/issues/60040 + // TODO: https://github.com/flutter/flutter/issues/71520 + }, skip: isIosSafari || isFirefox); + test('will gracefully fail if we cannot parse the Google Fonts CSS', () async { TestDownloader.mockDownloads[ diff --git a/lib/web_ui/test/golden_tests/engine/canvas_paragraph/general_test.dart b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/general_test.dart index ba9cb5a74b13b..8c4538897ddb0 100644 --- a/lib/web_ui/test/golden_tests/engine/canvas_paragraph/general_test.dart +++ b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/general_test.dart @@ -4,6 +4,7 @@ // @dart = 2.6 import 'dart:async'; +import 'dart:math' as math; import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; @@ -112,6 +113,91 @@ void testMain() async { return takeScreenshot(canvas, bounds, 'canvas_paragraph_align'); }); + test('respects alignment in DOM mode', () { + final canvas = DomCanvas(domRenderer.createElement('flt-picture')); + + Offset offset = Offset.zero; + CanvasParagraph paragraph; + + void build(CanvasParagraphBuilder builder) { + builder.pushStyle(EngineTextStyle.only(color: black)); + builder.addText('Lorem '); + builder.pushStyle(EngineTextStyle.only(color: blue)); + builder.addText('ipsum '); + builder.pushStyle(EngineTextStyle.only(color: green)); + builder.addText('dolor '); + builder.pushStyle(EngineTextStyle.only(color: red)); + builder.addText('sit'); + } + + paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto', textAlign: TextAlign.left), + build, + )..layout(constrain(100.0)); + canvas.drawParagraph(paragraph, offset); + offset = offset.translate(0, paragraph.height + 10); + + paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto', textAlign: TextAlign.center), + build, + )..layout(constrain(100.0)); + canvas.drawParagraph(paragraph, offset); + offset = offset.translate(0, paragraph.height + 10); + + paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto', textAlign: TextAlign.right), + build, + )..layout(constrain(100.0)); + canvas.drawParagraph(paragraph, offset); + offset = offset.translate(0, paragraph.height + 10); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_align_dom'); + }); + + void testAlignAndTransform(EngineCanvas canvas) { + CanvasParagraph paragraph; + + void build(CanvasParagraphBuilder builder) { + builder.pushStyle(EngineTextStyle.only(color: white)); + builder.addText('Lorem '); + builder.pushStyle(EngineTextStyle.only(color: red)); + builder.addText('ipsum\n'); + builder.pushStyle(EngineTextStyle.only(color: yellow)); + builder.addText('dolor'); + } + + void drawParagraphAt(Offset offset, TextAlign align) { + paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto', fontSize: 20.0, textAlign: align), + build, + )..layout(constrain(150.0)); + canvas.save(); + canvas.translate(offset.dx, offset.dy); + canvas.rotate(math.pi / 4); + final Rect rect = + Rect.fromLTRB(0.0, 0.0, 150.0, paragraph.height); + canvas.drawRect(rect, SurfacePaintData()..color = black); + canvas.drawParagraph(paragraph, Offset.zero); + canvas.restore(); + } + + drawParagraphAt(Offset(50.0, 0.0), TextAlign.left); + drawParagraphAt(Offset(150.0, 0.0), TextAlign.center); + drawParagraphAt(Offset(250.0, 0.0), TextAlign.right); + } + + test('alignment and transform', () { + final canvas = BitmapCanvas(bounds, RenderStrategy()); + testAlignAndTransform(canvas); + return takeScreenshot(canvas, bounds, 'canvas_paragraph_align_transform'); + }); + + test('alignment and transform (DOM)', () { + final canvas = DomCanvas(domRenderer.createElement('flt-picture')); + testAlignAndTransform(canvas); + return takeScreenshot(canvas, bounds, 'canvas_paragraph_align_transform_dom'); + }); + test('paints spans with varying heights/baselines', () { final canvas = BitmapCanvas(bounds, RenderStrategy()); @@ -165,4 +251,37 @@ void testMain() async { return takeScreenshot(canvas, bounds, 'canvas_paragraph_letter_spacing'); }); + + test('draws text decorations', () { + final canvas = BitmapCanvas(bounds, RenderStrategy()); + final List decorationStyles = [ + TextDecorationStyle.solid, + TextDecorationStyle.double, + TextDecorationStyle.dotted, + TextDecorationStyle.dashed, + TextDecorationStyle.wavy, + ]; + + final CanvasParagraph paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto'), + (builder) { + for (TextDecorationStyle decorationStyle in decorationStyles) { + builder.pushStyle(EngineTextStyle.only( + color: const Color.fromRGBO(50, 50, 255, 1.0), + decoration: TextDecoration.underline, + decorationStyle: decorationStyle, + decorationColor: red, + fontFamily: 'Roboto', + fontSize: 30, + )); + builder.addText('Hello World'); + builder.pop(); + builder.addText(' '); + } + }, + )..layout(constrain(double.infinity)); + + canvas.drawParagraph(paragraph, Offset.zero); + return takeScreenshot(canvas, bounds, 'canvas_paragraph_decoration'); + }); } diff --git a/lib/web_ui/test/golden_tests/engine/canvas_paragraph/placeholders_test.dart b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/placeholders_test.dart index fbede70d3b8c7..45440b03dc3a9 100644 --- a/lib/web_ui/test/golden_tests/engine/canvas_paragraph/placeholders_test.dart +++ b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/placeholders_test.dart @@ -95,4 +95,40 @@ void testMain() async { return takeScreenshot(canvas, bounds, 'canvas_paragraph_placeholders_align'); }); + + test('draws paragraphs with placeholders and text align in DOM mode', () { + final canvas = DomCanvas(domRenderer.createElement('flt-picture')); + + const List aligns = [ + TextAlign.left, + TextAlign.center, + TextAlign.right, + ]; + + Offset offset = Offset.zero; + for (TextAlign align in aligns) { + final CanvasParagraph paragraph = rich( + ParagraphStyle(fontFamily: 'Roboto', fontSize: 14.0, textAlign: align), + (builder) { + builder.pushStyle(TextStyle(color: black)); + builder.addText('Lorem'); + builder.addPlaceholder(80.0, 50.0, PlaceholderAlignment.bottom); + builder.pushStyle(TextStyle(color: blue)); + builder.addText('ipsum.'); + }, + )..layout(constrain(200.0)); + + // Draw the paragraph. + canvas.drawParagraph(paragraph, offset); + + // Then fill the placeholders. + final TextBox placeholderBox = paragraph.getBoxesForPlaceholders().single; + final SurfacePaint redPaint = Paint()..color = red; + canvas.drawRect(placeholderBox.toRect().shift(offset), redPaint.paintData); + + offset = offset.translate(0.0, paragraph.height + 30.0); + } + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_placeholders_align_dom'); + }); } diff --git a/lib/web_ui/test/golden_tests/engine/path_to_svg_golden_test.dart b/lib/web_ui/test/golden_tests/engine/path_to_svg_golden_test.dart index 3054dac3d5607..bc20029381544 100644 --- a/lib/web_ui/test/golden_tests/engine/path_to_svg_golden_test.dart +++ b/lib/web_ui/test/golden_tests/engine/path_to_svg_golden_test.dart @@ -19,26 +19,33 @@ void main() { void testMain() async { final Rect region = Rect.fromLTWH(8, 8, 600, 800); // Compensate for old scuba tester padding - Future testPath(Path path, String scubaFileName, {Paint paint, double maxDiffRatePercent = null}) async { + Future testPath(Path path, String scubaFileName, + {Paint paint, double maxDiffRatePercent = null, bool write = false, + bool strokeEnabled = true, bool enableFill = true}) async { const Rect canvasBounds = Rect.fromLTWH(0, 0, 600, 800); final BitmapCanvas bitmapCanvas = BitmapCanvas(canvasBounds, RenderStrategy()); final RecordingCanvas canvas = RecordingCanvas(canvasBounds); - paint ??= Paint() - ..color = const Color(0x807F7F7F) - ..style = PaintingStyle.fill; - - canvas.drawPath(path, paint); + if (enableFill) { + paint ??= Paint() + ..color = const Color(0x807F7F7F) + ..style = PaintingStyle.fill; + canvas.drawPath(path, paint); + } - paint = Paint() - ..strokeWidth = 2 - ..color = const Color(0xFFFF0000) - ..style = PaintingStyle.stroke; + if (strokeEnabled) { + paint = Paint() + ..strokeWidth = 2 + ..color = enableFill ? const Color(0xFFFF0000) : + const Color(0xFF000000) + ..style = PaintingStyle.stroke; + } canvas.drawPath(path, paint); - final html.Element svgElement = pathToSvgElement(path, paint); + final html.Element svgElement = pathToSvgElement(path, paint, + enableFill); html.document.body.append(bitmapCanvas.rootElement); html.document.body.append(svgElement); @@ -46,7 +53,8 @@ void testMain() async { canvas.endRecording(); canvas.apply(bitmapCanvas, canvasBounds); - await matchGoldenFile('$scubaFileName.png', region: region, maxDiffRatePercent: maxDiffRatePercent); + await matchGoldenFile('$scubaFileName.png', region: region, + maxDiffRatePercent: maxDiffRatePercent, write: write); bitmapCanvas.rootElement.remove(); svgElement.remove(); @@ -131,17 +139,42 @@ void testMain() async { path.lineTo(0, 10); await testPath(path, 'svg_notch'); }); + + /// Regression test for https://github.com/flutter/flutter/issues/70980 + test('render notch', () async { + const double w = 0.7; + final Path path = Path(); + path.moveTo(0.5, 14); + path.conicTo(0.5, 10.5, 4, 10.5, w); + path.moveTo(4, 10.5); + path.lineTo(6.5, 10.5); + path.moveTo(36.0, 10.5); + path.lineTo(158, 10.5); + path.conicTo(161.5, 10.5, 161.5, 14, w); + path.moveTo(161.5, 14); + path.lineTo(161.5, 48); + path.conicTo(161.5, 51.5, 158, 51.5, w); + path.lineTo(4, 51.5); + path.conicTo(0.5, 51.5, 0.5, 48, w); + path.lineTo(0.5, 14); + await testPath(path, 'svg_editoutline', enableFill: false); + }); } -html.Element pathToSvgElement(Path path, Paint paint) { +html.Element pathToSvgElement(Path path, Paint paint, + bool enableFill) { final Rect bounds = path.getBounds(); final StringBuffer sb = StringBuffer(); sb.write( - ''); + ''); sb.write('' + '

' '' 'Hello' '' @@ -349,7 +349,7 @@ void testMain() async { paragraph.layout(ParagraphConstraints(width: 180.0)); expect( paragraph.toDomElement().outerHtml, - '

' + '

' '' 'First
Second
' '
' diff --git a/lib/web_ui/test/text/canvas_paragraph_test.dart b/lib/web_ui/test/text/canvas_paragraph_test.dart index 601003fe4b669..d1f5d2b0642e8 100644 --- a/lib/web_ui/test/text/canvas_paragraph_test.dart +++ b/lib/web_ui/test/text/canvas_paragraph_test.dart @@ -547,6 +547,60 @@ void testMain() async { ); }); }); + + group('$CanvasParagraph.getLineBoundary', () { + test('single-line', () { + final CanvasParagraph paragraph = rich(ahemStyle, (builder) { + builder.addText('One single line'); + }) + ..layout(constrain(400.0)); + + // "One single line".length == 15 + for (int i = 0; i < 15; i++) { + expect( + paragraph.getLineBoundary(ui.TextPosition(offset: i)), + ui.TextRange(start: 0, end: 15), + reason: 'failed at offset $i', + ); + } + }); + + test('multi-line', () { + final CanvasParagraph paragraph = rich(ahemStyle, (builder) { + builder.addText('First line\n'); + builder.addText('Second line\n'); + builder.addText('Third line'); + }) + ..layout(constrain(400.0)); + + // "First line\n".length == 11 + for (int i = 0; i < 11; i++) { + expect( + paragraph.getLineBoundary(ui.TextPosition(offset: i)), + ui.TextRange(start: 0, end: 11), + reason: 'failed at offset $i', + ); + } + + // "Second line\n".length == 12 + for (int i = 11; i < 23; i++) { + expect( + paragraph.getLineBoundary(ui.TextPosition(offset: i)), + ui.TextRange(start: 11, end: 23), + reason: 'failed at offset $i', + ); + } + + // "Third line".length == 10 + for (int i = 23; i < 33; i++) { + expect( + paragraph.getLineBoundary(ui.TextPosition(offset: i)), + ui.TextRange(start: 23, end: 33), + reason: 'failed at offset $i', + ); + } + }); + }); } /// Shortcut to create a [ui.TextBox] with an optional [ui.TextDirection]. diff --git a/shell/platform/android/io/flutter/embedding/engine/FlutterEngineGroup.java b/shell/platform/android/io/flutter/embedding/engine/FlutterEngineGroup.java index 0e9d76350d00f..68389cfade641 100644 --- a/shell/platform/android/io/flutter/embedding/engine/FlutterEngineGroup.java +++ b/shell/platform/android/io/flutter/embedding/engine/FlutterEngineGroup.java @@ -8,7 +8,9 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import io.flutter.FlutterInjector; import io.flutter.embedding.engine.dart.DartExecutor.DartEntrypoint; +import io.flutter.embedding.engine.loader.FlutterLoader; import java.util.ArrayList; import java.util.List; @@ -25,6 +27,9 @@ * io.flutter.embedding.engine.FlutterEngine}s are created, resources from an existing living {@link * io.flutter.embedding.engine.FlutterEngine} is re-used. * + *

The shared resources are kept until the last surviving {@link + * io.flutter.embedding.engine.FlutterEngine} is destroyed. + * *

Deleting a FlutterEngineGroup doesn't invalidate its existing {@link * io.flutter.embedding.engine.FlutterEngine}s, but it eliminates the possibility to create more * {@link io.flutter.embedding.engine.FlutterEngine}s in that group. @@ -33,6 +38,23 @@ public class FlutterEngineGroup { /* package */ @VisibleForTesting final List activeEngines = new ArrayList<>(); + /** Create a FlutterEngineGroup whose child engines will share resources. */ + public FlutterEngineGroup(@NonNull Context context) { + this(context, null); + } + + /** + * Create a FlutterEngineGroup whose child engines will share resources. Use {@code dartVmArgs} to + * pass flags to the Dart VM during initialization. + */ + public FlutterEngineGroup(@NonNull Context context, @Nullable String[] dartVmArgs) { + FlutterLoader loader = FlutterInjector.instance().flutterLoader(); + if (!loader.initialized()) { + loader.startInitialization(context.getApplicationContext()); + loader.ensureInitializationComplete(context, dartVmArgs); + } + } + /** * Creates a {@link io.flutter.embedding.engine.FlutterEngine} in this group and run its {@link * io.flutter.embedding.engine.dart.DartExecutor} with a default entrypoint of the "main" function @@ -67,18 +89,13 @@ public FlutterEngine createAndRunDefaultEngine(@NonNull Context context) { public FlutterEngine createAndRunEngine( @NonNull Context context, @Nullable DartEntrypoint dartEntrypoint) { FlutterEngine engine = null; - // This is done up here because an engine needs to be created first in order to be able to use - // DartEntrypoint.createDefault. The engine creation initializes the FlutterLoader so - // DartEntrypoint known where to find the assets for the AOT or kernel code. - if (activeEngines.size() == 0) { - engine = createEngine(context); - } if (dartEntrypoint == null) { dartEntrypoint = DartEntrypoint.createDefault(); } if (activeEngines.size() == 0) { + engine = createEngine(context); engine.getDartExecutor().executeDartEntrypoint(dartEntrypoint); } else { engine = activeEngines.get(0).spawn(context, dartEntrypoint); diff --git a/shell/platform/android/test/io/flutter/embedding/engine/FlutterEngineGroupComponentTest.java b/shell/platform/android/test/io/flutter/embedding/engine/FlutterEngineGroupComponentTest.java index 8e709d86c43c1..42e1296505e5a 100644 --- a/shell/platform/android/test/io/flutter/embedding/engine/FlutterEngineGroupComponentTest.java +++ b/shell/platform/android/test/io/flutter/embedding/engine/FlutterEngineGroupComponentTest.java @@ -8,12 +8,17 @@ import static org.mockito.Mockito.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import android.content.Context; -import io.flutter.embedding.engine.dart.DartExecutor; +import android.content.res.AssetManager; +import io.flutter.FlutterInjector; import io.flutter.embedding.engine.dart.DartExecutor.DartEntrypoint; import io.flutter.embedding.engine.loader.FlutterLoader; import io.flutter.plugins.GeneratedPluginRegistrant; @@ -27,34 +32,41 @@ import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; -// It's a component test because it tests both FlutterEngineGroup and FlutterEngine. +// It's a component test because it tests the FlutterEngineGroup its components such as the +// FlutterEngine and the DartExecutor. @Config(manifest = Config.NONE) @RunWith(RobolectricTestRunner.class) public class FlutterEngineGroupComponentTest { - @Mock FlutterJNI flutterJNI; + @Mock FlutterJNI mockflutterJNI; + @Mock FlutterLoader mockFlutterLoader; FlutterEngineGroup engineGroupUnderTest; FlutterEngine firstEngineUnderTest; boolean jniAttached; @Before public void setUp() { + FlutterInjector.reset(); + MockitoAnnotations.initMocks(this); jniAttached = false; - when(flutterJNI.isAttached()).thenAnswer(invocation -> jniAttached); - doAnswer(invocation -> jniAttached = true).when(flutterJNI).attachToNative(false); + when(mockflutterJNI.isAttached()).thenAnswer(invocation -> jniAttached); + doAnswer(invocation -> jniAttached = true).when(mockflutterJNI).attachToNative(false); GeneratedPluginRegistrant.clearRegisteredEngines(); + when(mockFlutterLoader.findAppBundlePath()).thenReturn("some/path/to/flutter_assets"); + FlutterInjector.setInstance( + new FlutterInjector.Builder().setFlutterLoader(mockFlutterLoader).build()); + firstEngineUnderTest = spy( new FlutterEngine( RuntimeEnvironment.application, mock(FlutterLoader.class), - flutterJNI, + mockflutterJNI, /*dartVmArgs=*/ new String[] {}, /*automaticallyRegisterPlugins=*/ false)); - when(firstEngineUnderTest.getDartExecutor()).thenReturn(mock(DartExecutor.class)); engineGroupUnderTest = - new FlutterEngineGroup() { + new FlutterEngineGroup(RuntimeEnvironment.application) { @Override FlutterEngine createEngine(Context context) { return firstEngineUnderTest; @@ -127,4 +139,21 @@ public void canSpawnMoreEngines() { RuntimeEnvironment.application, mock(DartEntrypoint.class)); assertEquals(2, engineGroupUnderTest.activeEngines.size()); } + + @Test + public void canCreateAndRunCustomEntrypoints() { + FlutterEngine firstEngine = + engineGroupUnderTest.createAndRunEngine( + RuntimeEnvironment.application, + new DartEntrypoint( + FlutterInjector.instance().flutterLoader().findAppBundlePath(), + "other entrypoint")); + assertEquals(1, engineGroupUnderTest.activeEngines.size()); + verify(mockflutterJNI, times(1)) + .runBundleAndSnapshotFromLibrary( + eq("some/path/to/flutter_assets"), + eq("other entrypoint"), + isNull(String.class), + any(AssetManager.class)); + } } diff --git a/shell/platform/windows/flutter_windows_view.cc b/shell/platform/windows/flutter_windows_view.cc index 2f3b4c282698c..60ac0cb6954bc 100644 --- a/shell/platform/windows/flutter_windows_view.cc +++ b/shell/platform/windows/flutter_windows_view.cc @@ -8,6 +8,24 @@ namespace flutter { +/// Returns true if the surface will be updated as part of the resize process. +/// +/// This is called on window resize to determine if the platform thread needs +/// to be blocked until the frame with the right size has been rendered. It +/// should be kept in-sync with how the engine deals with a new surface request +/// as seen in `CreateOrUpdateSurface` in `GPUSurfaceGL`. +static bool SurfaceWillUpdate(size_t cur_width, + size_t cur_height, + size_t target_width, + size_t target_height) { + // TODO (https://github.com/flutter/flutter/issues/65061) : Avoid special + // handling for zero dimensions. + bool non_zero_dims = target_height > 0 && target_width > 0; + bool not_same_size = + (cur_height != target_height) || (cur_width != target_width); + return non_zero_dims && not_same_size; +} + FlutterWindowsView::FlutterWindowsView( std::unique_ptr window_binding) { surface_manager_ = std::make_unique(); @@ -80,12 +98,18 @@ uint32_t FlutterWindowsView::GetFrameBufferId(size_t width, size_t height) { void FlutterWindowsView::OnWindowSizeChanged(size_t width, size_t height) { // Called on the platform thread. std::unique_lock lock(resize_mutex_); - resize_status_ = ResizeState::kResizeStarted; - resize_target_width_ = width; - resize_target_height_ = height; + + bool surface_will_update = SurfaceWillUpdate( + resize_target_width_, resize_target_height_, width, height); + if (surface_will_update) { + resize_status_ = ResizeState::kResizeStarted; + resize_target_width_ = width; + resize_target_height_ = height; + } + SendWindowMetrics(width, height, binding_handler_->GetDpiScale()); - if (width > 0 && height > 0) { + if (surface_will_update) { // Block the platform thread until: // 1. GetFrameBufferId is called with the right frame size. // 2. Any pending SwapBuffers calls have been invoked. diff --git a/testing/scenario_app/android/app/src/main/java/dev/flutter/scenarios/SpawnedEngineActivity.java b/testing/scenario_app/android/app/src/main/java/dev/flutter/scenarios/SpawnedEngineActivity.java index 0d8c2fe831a75..52ab54b8e3a9e 100644 --- a/testing/scenario_app/android/app/src/main/java/dev/flutter/scenarios/SpawnedEngineActivity.java +++ b/testing/scenario_app/android/app/src/main/java/dev/flutter/scenarios/SpawnedEngineActivity.java @@ -14,7 +14,7 @@ public class SpawnedEngineActivity extends TestActivity { @Override public FlutterEngine provideFlutterEngine(@NonNull Context context) { - FlutterEngineGroup engineGroup = new FlutterEngineGroup(); + FlutterEngineGroup engineGroup = new FlutterEngineGroup(context); engineGroup.createAndRunDefaultEngine(context); FlutterEngine secondEngine = engineGroup.createAndRunDefaultEngine(context);