diff --git a/.cirrus.yml b/.cirrus.yml index c56db8d7275c1..5b5db83c454a0 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -32,7 +32,7 @@ task: mv $CIRRUS_WORKING_DIR flutter gclient sync matrix: - # The following test depends on Flutter framework repo. It may fail if the + # The following test depends on Flutter framework repo. It will fail if the # framework repo is currently broken. - name: build_and_test_linux_unopt_debug compile_host_script: | diff --git a/DEPS b/DEPS index c0b87a7c058a5..5819ef0ea0f5c 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': '7c8c6b3053d0ba91fcf15f81c45650184dc27e88', # 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..e2cd114259297 100644 --- a/ci/licenses_golden/licenses_third_party +++ b/ci/licenses_golden/licenses_third_party @@ -1,4 +1,4 @@ -Signature: 8d4521c3fe82b7be926a7af5f8b15557 +Signature: 5c79cd2b2923476fb5c2db8b6fa7b779 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/flow/compositor_context.h b/flow/compositor_context.h index ca1bb662f68cf..7c10f3b147e58 100644 --- a/flow/compositor_context.h +++ b/flow/compositor_context.h @@ -46,8 +46,12 @@ enum class RasterStatus { kEnqueuePipeline, // Failed to rasterize the frame. kFailed, - // Layer tree was discarded due to LayerTreeDiscardCallback - kDiscarded + // Layer tree was discarded due to LayerTreeDiscardCallback or inability to + // access the GPU. + kDiscarded, + // Drawing was yielded to allow the correct thread to draw as a result of the + // RasterThreadMerger. + kYielded, }; class CompositorContext { diff --git a/flow/embedded_views.cc b/flow/embedded_views.cc index 273006c8d5e1f..9441c8dc9470c 100644 --- a/flow/embedded_views.cc +++ b/flow/embedded_views.cc @@ -6,10 +6,8 @@ namespace flutter { -void ExternalViewEmbedder::SubmitFrame( - GrDirectContext* context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch) { +void ExternalViewEmbedder::SubmitFrame(GrDirectContext* context, + std::unique_ptr frame) { frame->Submit(); }; diff --git a/flow/embedded_views.h b/flow/embedded_views.h index 307282ff4fd18..9544e61e99697 100644 --- a/flow/embedded_views.h +++ b/flow/embedded_views.h @@ -10,7 +10,6 @@ #include "flutter/flow/surface_frame.h" #include "flutter/fml/memory/ref_counted.h" #include "flutter/fml/raster_thread_merger.h" -#include "flutter/fml/synchronization/sync_switch.h" #include "third_party/skia/include/core/SkCanvas.h" #include "third_party/skia/include/core/SkPath.h" #include "third_party/skia/include/core/SkPoint.h" @@ -310,10 +309,8 @@ class ExternalViewEmbedder { // This method can mutate the root Skia canvas before submitting the frame. // // It can also allocate frames for overlay surfaces to compose hybrid views. - virtual void SubmitFrame( - GrDirectContext* context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch); + virtual void SubmitFrame(GrDirectContext* context, + std::unique_ptr frame); // This method provides the embedder a way to do additional tasks after // |SubmitFrame|. For example, merge task runners if `should_resubmit_frame` diff --git a/flow/surface.cc b/flow/surface.cc index 7dbe56c1e8e78..79c8c8d7245d8 100644 --- a/flow/surface.cc +++ b/flow/surface.cc @@ -18,4 +18,8 @@ bool Surface::ClearRenderContext() { return false; } +bool Surface::AllowsDrawingWhenGpuDisabled() const { + return true; +} + } // namespace flutter diff --git a/flow/surface.h b/flow/surface.h index 21b248c074d4c..fc8daaebfa771 100644 --- a/flow/surface.h +++ b/flow/surface.h @@ -33,6 +33,8 @@ class Surface { virtual bool ClearRenderContext(); + virtual bool AllowsDrawingWhenGpuDisabled() const; + private: FML_DISALLOW_COPY_AND_ASSIGN(Surface); }; 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/ui/painting.dart b/lib/ui/painting.dart index eb507943b8ad1..95b742f180e43 100644 --- a/lib/ui/painting.dart +++ b/lib/ui/painting.dart @@ -1939,12 +1939,13 @@ class Codec extends NativeFieldWrapperClass2 { final Completer completer = Completer.sync(); final String? error = _getNextFrame((_Image? image, int durationMilliseconds) { if (image == null) { - throw Exception('Codec failed to produce an image, possibly due to invalid image data.'); + completer.completeError(Exception('Codec failed to produce an image, possibly due to invalid image data.')); + } else { + completer.complete(FrameInfo._( + image: Image._(image), + duration: Duration(milliseconds: durationMilliseconds), + )); } - completer.complete(FrameInfo._( - image: Image._(image), - duration: Duration(milliseconds: durationMilliseconds), - )); }); if (error != null) { throw Exception(error); diff --git a/lib/web_ui/dev/goldens_lock.yaml b/lib/web_ui/dev/goldens_lock.yaml index 1b80d23509540..b13434a53e5b7 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: 4b4c256d6124a135b70c1a9a7ff10cf2827df31c 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/canvaskit_api.dart b/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart index 880f8b776a1bc..5742bc1a92f20 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart @@ -742,7 +742,7 @@ class SkImage { Float32List? matrix, // 3x3 matrix ); external Uint8List readPixels(int srcX, int srcY, SkImageInfo imageInfo); - external SkData encodeToData(); + external Uint8List? encodeToBytes(); external bool isAliasOf(SkImage other); external bool isDeleted(); } @@ -1643,6 +1643,8 @@ class SkTypeface {} class SkFont { external SkFont(SkTypeface typeface); external Uint8List getGlyphIDs(String text); + external void getGlyphBounds( + List glyphs, SkPaint? paint, Uint8List? output); } @JS() 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..f4ea4a20407f1 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/font_fallbacks.dart @@ -5,48 +5,108 @@ // @dart = 2.12 part of engine; -/// Whether or not "Noto Sans Symbols" and "Noto Color Emoji" fonts have been -/// downloaded. We download these as fallbacks when no other font covers the -/// given code units. -bool _registeredSymbolsAndEmoji = false; +/// Global static font fallback data. +class FontFallbackData { + static FontFallbackData get instance => _instance; + static FontFallbackData _instance = FontFallbackData(); -final Set codeUnitsWithNoKnownFont = {}; + /// Resets the fallback font data. + /// + /// After calling this method fallback fonts will be loaded from scratch. + /// + /// Used for tests. + static void debugReset() { + _instance = FontFallbackData(); + } + + /// Whether or not "Noto Sans Symbols" and "Noto Color Emoji" fonts have been + /// downloaded. We download these as fallbacks when no other font covers the + /// given code units. + bool registeredSymbolsAndEmoji = false; + + /// Code units that no known font has a glyph for. + final Set codeUnitsWithNoKnownFont = {}; + + /// Code units which are known to be covered by at least one fallback font. + final Set knownCoveredCodeUnits = {}; + + /// Index of all font families by code unit range. + final IntervalTree notoTree = createNotoFontTree(); + + static IntervalTree createNotoFontTree() { + Map> ranges = + >{}; + + for (NotoFont font in _notoFonts) { + // TODO(yjbanov): instead of mutating the font tree during reset, it's + // better to construct an immutable tree of resolved fonts + // pointing back to the original NotoFont objects. Then + // resetting the tree would be a matter of reconstructing + // the new resolved tree. + font.reset(); + for (CodeunitRange range in font.approximateUnicodeRanges) { + ranges.putIfAbsent(font, () => []).add(range); + } + } + + return IntervalTree.createFromRanges(ranges); + } + + /// Fallback fonts which have been registered and loaded. + final List<_RegisteredFont> registeredFallbackFonts = <_RegisteredFont>[]; -Future _findFontsForMissingCodeunits(List codeunits) async { - _ensureNotoFontTreeCreated(); + final List globalFontFallbacks = ['Roboto']; + + final Map fontFallbackCounts = {}; + + void registerFallbackFont(String family, Uint8List bytes) { + fontFallbackCounts.putIfAbsent(family, () => 0); + int fontFallbackTag = fontFallbackCounts[family]!; + fontFallbackCounts[family] = fontFallbackCounts[family]! + 1; + String countedFamily = '$family $fontFallbackTag'; + registeredFallbackFonts.add(_RegisteredFont(bytes, countedFamily)); + globalFontFallbacks.add(countedFamily); + } +} + +Future findFontsForMissingCodeunits(List codeUnits) async { + final FontFallbackData data = FontFallbackData.instance; // If all of the code units are known to have no Noto Font which covers them, // then just give up. We have already logged a warning. - if (codeunits.every((u) => codeUnitsWithNoKnownFont.contains(u))) { + if (codeUnits.every((u) => data.codeUnitsWithNoKnownFont.contains(u))) { return; } - Set<_NotoFont> fonts = <_NotoFont>{}; + Set fonts = {}; Set coveredCodeUnits = {}; Set missingCodeUnits = {}; - for (int codeunit in codeunits) { - List<_NotoFont> fontsForUnit = _notoTree!.intersections(codeunit); + for (int codeUnit in codeUnits) { + List fontsForUnit = data.notoTree.intersections(codeUnit); fonts.addAll(fontsForUnit); if (fontsForUnit.isNotEmpty) { - coveredCodeUnits.add(codeunit); + coveredCodeUnits.add(codeUnit); } else { - missingCodeUnits.add(codeunit); + missingCodeUnits.add(codeUnit); } } - fonts = _findMinimumFontsForCodeunits(coveredCodeUnits, fonts); - - for (_NotoFont font in fonts) { + for (NotoFont font in fonts) { await font.ensureResolved(); } + // The call to `findMinimumFontsForCodeUnits` will remove all code units that + // were matched by `fonts` from `unmatchedCodeUnits`. + final Set unmatchedCodeUnits = Set.from(coveredCodeUnits); + fonts = findMinimumFontsForCodeUnits(unmatchedCodeUnits, fonts); + Set<_ResolvedNotoSubset> resolvedFonts = <_ResolvedNotoSubset>{}; - for (int codeunit in coveredCodeUnits) { - for (_NotoFont font in fonts) { + for (int codeUnit in coveredCodeUnits) { + for (NotoFont font in fonts) { if (font.resolvedFont == null) { // We failed to resolve the font earlier. continue; } - resolvedFonts.addAll(font.resolvedFont!.tree.intersections(codeunit)); + resolvedFonts.addAll(font.resolvedFont!.tree.intersections(codeUnit)); } } @@ -54,8 +114,12 @@ Future _findFontsForMissingCodeunits(List codeunits) async { notoDownloadQueue.add(resolvedFont); } - if (missingCodeUnits.isNotEmpty && !notoDownloadQueue.isPending) { - if (!_registeredSymbolsAndEmoji) { + // We looked through the Noto font tree and didn't find any font families + // covering some code units, or we did find a font family, but when we + // downloaded the fonts we found that they actually didn't cover them. So + // we try looking them up in the symbols and emojis fonts. + if (missingCodeUnits.isNotEmpty || unmatchedCodeUnits.isNotEmpty) { + if (!data.registeredSymbolsAndEmoji) { _registerSymbolsAndEmoji(); } else { if (!notoDownloadQueue.isPending) { @@ -63,7 +127,7 @@ Future _findFontsForMissingCodeunits(List codeunits) async { 'Could not find a set of Noto fonts to display all missing ' 'characters. Please add a font asset for the missing characters.' ' See: https://flutter.dev/docs/cookbook/design/fonts'); - codeUnitsWithNoKnownFont.addAll(missingCodeUnits); + data.codeUnitsWithNoKnownFont.addAll(missingCodeUnits); } } } @@ -168,6 +232,11 @@ _ResolvedNotoFont? _makeResolvedNotoFontFromCss(String css, String name) { } } + if (rangesMap.isEmpty) { + html.window.console.warn('Parsed Google Fonts CSS was empty: $css'); + return null; + } + IntervalTree<_ResolvedNotoSubset> tree = IntervalTree<_ResolvedNotoSubset>.createFromRanges(rangesMap); @@ -178,10 +247,11 @@ _ResolvedNotoFont? _makeResolvedNotoFontFromCss(String css, String name) { /// try the Symbols and Emoji fonts. We don't know the exact range of code units /// that are covered by these fonts, so we download them and hope for the best. Future _registerSymbolsAndEmoji() async { - if (_registeredSymbolsAndEmoji) { + final FontFallbackData data = FontFallbackData.instance; + if (data.registeredSymbolsAndEmoji) { return; } - _registeredSymbolsAndEmoji = true; + data.registeredSymbolsAndEmoji = true; const String symbolsUrl = 'https://fonts.googleapis.com/css2?family=Noto+Sans+Symbols'; const String emojiUrl = @@ -211,54 +281,65 @@ 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]. +/// Finds the minimum set of fonts which covers all of the [codeUnits]. +/// +/// Removes all code units covered by [fonts] from [codeUnits]. The code +/// units remaining in the [codeUnits] set after calling this function do not +/// have a font that covers them and can be omitted next time to avoid +/// searching for fonts unnecessarily. /// /// Since set cover is NP-complete, we approximate using a greedy algorithm -/// which finds the font which covers the most codeunits. If multiple CJK -/// fonts match the same number of codeunits, we choose one based on the user's +/// which finds the font which covers the most code units. If multiple CJK +/// fonts match the same number of code units, we choose one based on the user's /// locale. -Set<_NotoFont> _findMinimumFontsForCodeunits( - Iterable codeunits, Set<_NotoFont> fonts) { - List unmatchedCodeunits = List.from(codeunits); - Set<_NotoFont> minimumFonts = <_NotoFont>{}; - List<_NotoFont> bestFonts = <_NotoFont>[]; - int maxCodeunitsCovered = 0; +Set findMinimumFontsForCodeUnits( + Set codeUnits, Set fonts) { + assert(fonts.isNotEmpty || codeUnits.isEmpty); + Set minimumFonts = {}; + List bestFonts = []; String language = html.window.navigator.language; - // This is guaranteed to terminate because [codeunits] is a list of fonts - // which we've already determined are covered by [fonts]. - while (unmatchedCodeunits.isNotEmpty) { + while (codeUnits.isNotEmpty) { + int maxCodeUnitsCovered = 0; + bestFonts.clear(); for (var font in fonts) { - int codeunitsCovered = 0; - for (int codeunit in unmatchedCodeunits) { - if (font.matchesCodeunit(codeunit)) { - codeunitsCovered++; + int codeUnitsCovered = 0; + for (int codeUnit in codeUnits) { + if (font.resolvedFont?.tree.containsDeep(codeUnit) == true) { + codeUnitsCovered++; } } - if (codeunitsCovered > maxCodeunitsCovered) { + if (codeUnitsCovered > maxCodeUnitsCovered) { bestFonts.clear(); bestFonts.add(font); - maxCodeunitsCovered = codeunitsCovered; - } else if (codeunitsCovered == maxCodeunitsCovered) { + maxCodeUnitsCovered = codeUnitsCovered; + } else if (codeUnitsCovered == maxCodeUnitsCovered) { bestFonts.add(font); } } - assert(bestFonts.isNotEmpty); + if (maxCodeUnitsCovered == 0) { + // Fonts cannot cover remaining unmatched characters. + break; + } // If the list of best fonts are all CJK fonts, choose the best one based // on locale. Otherwise just choose the first font. - _NotoFont bestFont = bestFonts.first; + NotoFont bestFont = bestFonts.first; if (bestFonts.length > 1) { if (bestFonts.every((font) => _cjkFonts.contains(font))) { if (language == 'zh-Hans' || @@ -285,48 +366,22 @@ Set<_NotoFont> _findMinimumFontsForCodeunits( } } } - unmatchedCodeunits - .removeWhere((codeunit) => bestFont.matchesCodeunit(codeunit)); - minimumFonts.add(bestFont); + codeUnits.removeWhere((codeUnit) { + return bestFont.resolvedFont!.tree.containsDeep(codeUnit); + }); + minimumFonts.addAll(bestFonts); } return minimumFonts; } -void _ensureNotoFontTreeCreated() { - if (_notoTree != null) { - return; - } - - Map<_NotoFont, List> ranges = - <_NotoFont, List>{}; - - for (_NotoFont font in _notoFonts) { - for (CodeunitRange range in font.unicodeRanges) { - ranges.putIfAbsent(font, () => []).add(range); - } - } - - _notoTree = IntervalTree<_NotoFont>.createFromRanges(ranges); -} - -class _NotoFont { +class NotoFont { final String name; - final List unicodeRanges; + final List approximateUnicodeRanges; Completer? _decodingCompleter; - _ResolvedNotoFont? resolvedFont; - _NotoFont(this.name, this.unicodeRanges); - - bool matchesCodeunit(int codeunit) { - for (CodeunitRange range in unicodeRanges) { - if (range.contains(codeunit)) { - return true; - } - } - return false; - } + NotoFont(this.name, this.approximateUnicodeRanges); String get googleFontsCssUrl => 'https://fonts.googleapis.com/css2?family=${name.replaceAll(' ', '+')}'; @@ -346,6 +401,11 @@ class _NotoFont { } } } + + void reset() { + resolvedFont = null; + _decodingCompleter = null; + } } class CodeunitRange { @@ -388,9 +448,12 @@ class _ResolvedNotoSubset { final List ranges; _ResolvedNotoSubset(this.url, this.family, this.ranges); + + @override + String toString() => '_ResolvedNotoSubset($family, $url)'; } -_NotoFont _notoSansSC = _NotoFont('Noto Sans SC', [ +NotoFont _notoSansSC = NotoFont('Noto Sans SC', [ CodeunitRange(12288, 12591), CodeunitRange(12800, 13311), CodeunitRange(19968, 40959), @@ -398,37 +461,37 @@ _NotoFont _notoSansSC = _NotoFont('Noto Sans SC', [ CodeunitRange(65280, 65519), ]); -_NotoFont _notoSansTC = _NotoFont('Noto Sans TC', [ +NotoFont _notoSansTC = NotoFont('Noto Sans TC', [ CodeunitRange(12288, 12351), CodeunitRange(12549, 12585), CodeunitRange(19968, 40959), ]); -_NotoFont _notoSansHK = _NotoFont('Noto Sans HK', [ +NotoFont _notoSansHK = NotoFont('Noto Sans HK', [ CodeunitRange(12288, 12351), CodeunitRange(12549, 12585), CodeunitRange(19968, 40959), ]); -_NotoFont _notoSansJP = _NotoFont('Noto Sans JP', [ +NotoFont _notoSansJP = NotoFont('Noto Sans JP', [ CodeunitRange(12288, 12543), CodeunitRange(19968, 40959), CodeunitRange(65280, 65519), ]); -List<_NotoFont> _cjkFonts = <_NotoFont>[ +List _cjkFonts = [ _notoSansSC, _notoSansTC, _notoSansHK, _notoSansJP, ]; -List<_NotoFont> _notoFonts = <_NotoFont>[ +List _notoFonts = [ _notoSansSC, _notoSansTC, _notoSansHK, _notoSansJP, - _NotoFont('Noto Naskh Arabic UI', [ + NotoFont('Noto Naskh Arabic UI', [ CodeunitRange(1536, 1791), CodeunitRange(8204, 8206), CodeunitRange(8208, 8209), @@ -437,36 +500,36 @@ List<_NotoFont> _notoFonts = <_NotoFont>[ CodeunitRange(64336, 65023), CodeunitRange(65132, 65276), ]), - _NotoFont('Noto Sans Armenian', [ + NotoFont('Noto Sans Armenian', [ CodeunitRange(1328, 1424), CodeunitRange(64275, 64279), ]), - _NotoFont('Noto Sans Bengali UI', [ + NotoFont('Noto Sans Bengali UI', [ CodeunitRange(2404, 2405), CodeunitRange(2433, 2555), CodeunitRange(8204, 8205), CodeunitRange(8377, 8377), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Myanmar UI', [ + NotoFont('Noto Sans Myanmar UI', [ CodeunitRange(4096, 4255), CodeunitRange(8204, 8205), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Egyptian Hieroglyphs', [ + NotoFont('Noto Sans Egyptian Hieroglyphs', [ CodeunitRange(77824, 78894), ]), - _NotoFont('Noto Sans Ethiopic', [ + NotoFont('Noto Sans Ethiopic', [ CodeunitRange(4608, 5017), CodeunitRange(11648, 11742), CodeunitRange(43777, 43822), ]), - _NotoFont('Noto Sans Georgian', [ + NotoFont('Noto Sans Georgian', [ CodeunitRange(1417, 1417), CodeunitRange(4256, 4351), CodeunitRange(11520, 11567), ]), - _NotoFont('Noto Sans Gujarati UI', [ + NotoFont('Noto Sans Gujarati UI', [ CodeunitRange(2404, 2405), CodeunitRange(2688, 2815), CodeunitRange(8204, 8205), @@ -474,7 +537,7 @@ List<_NotoFont> _notoFonts = <_NotoFont>[ CodeunitRange(9676, 9676), CodeunitRange(43056, 43065), ]), - _NotoFont('Noto Sans Gurmukhi UI', [ + NotoFont('Noto Sans Gurmukhi UI', [ CodeunitRange(2404, 2405), CodeunitRange(2561, 2677), CodeunitRange(8204, 8205), @@ -483,46 +546,46 @@ List<_NotoFont> _notoFonts = <_NotoFont>[ CodeunitRange(9772, 9772), CodeunitRange(43056, 43065), ]), - _NotoFont('Noto Sans Hebrew', [ + NotoFont('Noto Sans Hebrew', [ CodeunitRange(1424, 1535), CodeunitRange(8362, 8362), CodeunitRange(9676, 9676), CodeunitRange(64285, 64335), ]), - _NotoFont('Noto Sans Devanagari UI', [ + NotoFont('Noto Sans Devanagari UI', [ CodeunitRange(2304, 2431), CodeunitRange(7376, 7414), CodeunitRange(7416, 7417), - CodeunitRange(8204, 9205), + CodeunitRange(8204, 8205), CodeunitRange(8360, 8360), CodeunitRange(8377, 8377), CodeunitRange(9676, 9676), CodeunitRange(43056, 43065), CodeunitRange(43232, 43259), ]), - _NotoFont('Noto Sans Kannada UI', [ + NotoFont('Noto Sans Kannada UI', [ CodeunitRange(2404, 2405), CodeunitRange(3202, 3314), CodeunitRange(8204, 8205), CodeunitRange(8377, 8377), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Khmer UI', [ + NotoFont('Noto Sans Khmer UI', [ CodeunitRange(6016, 6143), CodeunitRange(8204, 8204), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans KR', [ + NotoFont('Noto Sans KR', [ CodeunitRange(12593, 12686), CodeunitRange(12800, 12828), CodeunitRange(12896, 12923), CodeunitRange(44032, 55215), ]), - _NotoFont('Noto Sans Lao UI', [ + NotoFont('Noto Sans Lao UI', [ CodeunitRange(3713, 3807), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Malayalam UI', [ + NotoFont('Noto Sans Malayalam UI', [ CodeunitRange(775, 775), CodeunitRange(803, 803), CodeunitRange(2404, 2405), @@ -531,20 +594,20 @@ List<_NotoFont> _notoFonts = <_NotoFont>[ CodeunitRange(8377, 8377), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Sinhala', [ + NotoFont('Noto Sans Sinhala', [ CodeunitRange(2404, 2405), CodeunitRange(3458, 3572), CodeunitRange(8204, 8205), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Tamil UI', [ + NotoFont('Noto Sans Tamil UI', [ CodeunitRange(2404, 2405), CodeunitRange(2946, 3066), CodeunitRange(8204, 8205), CodeunitRange(8377, 8377), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Telugu UI', [ + NotoFont('Noto Sans Telugu UI', [ CodeunitRange(2385, 2386), CodeunitRange(2404, 2405), CodeunitRange(3072, 3199), @@ -552,12 +615,12 @@ List<_NotoFont> _notoFonts = <_NotoFont>[ CodeunitRange(8204, 8205), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans Thai UI', [ + NotoFont('Noto Sans Thai UI', [ CodeunitRange(3585, 3675), CodeunitRange(8204, 8205), CodeunitRange(9676, 9676), ]), - _NotoFont('Noto Sans', [ + NotoFont('Noto Sans', [ CodeunitRange(0, 255), CodeunitRange(305, 305), CodeunitRange(338, 339), @@ -611,48 +674,88 @@ class FallbackFontDownloadQueue { NotoDownloader downloader = NotoDownloader(); final Set<_ResolvedNotoSubset> downloadedSubsets = <_ResolvedNotoSubset>{}; - final Set<_ResolvedNotoSubset> pendingSubsets = <_ResolvedNotoSubset>{}; + final Map pendingSubsets = + {}; + + bool get isPending => pendingSubsets.isNotEmpty || _fontsLoading != null; - bool get isPending => pendingSubsets.isNotEmpty; + Future? _fontsLoading; + bool get debugIsLoadingFonts => _fontsLoading != null; + + Future debugWhenIdle() async { + if (assertionsEnabled) { + await Future.delayed(Duration.zero); + while (isPending) { + if (_fontsLoading != null) { + await _fontsLoading; + } + if (pendingSubsets.isNotEmpty) { + await Future.delayed(const Duration(milliseconds: 100)); + if (pendingSubsets.isEmpty) { + await Future.delayed(const Duration(milliseconds: 100)); + } + } + } + } else { + throw UnimplementedError(); + } + } void add(_ResolvedNotoSubset subset) { - if (downloadedSubsets.contains(subset) || pendingSubsets.contains(subset)) { + if (downloadedSubsets.contains(subset) || + pendingSubsets.containsKey(subset.url)) { return; } bool firstInBatch = pendingSubsets.isEmpty; - pendingSubsets.add(subset); + pendingSubsets[subset.url] = subset; if (firstInBatch) { Timer.run(startDownloads); } } Future startDownloads() async { - List> downloads = []; - for (_ResolvedNotoSubset subset in pendingSubsets) { - downloads.add(Future(() async { + final Map> downloads = >{}; + final Map downloadedData = {}; + for (_ResolvedNotoSubset subset in pendingSubsets.values) { + downloads[subset.url] = Future(() async { ByteBuffer buffer; try { - buffer = await downloader.downloadAsBytes(subset.url); + buffer = await downloader.downloadAsBytes(subset.url, + debugDescription: subset.family); } catch (e) { + pendingSubsets.remove(subset.url); html.window.console .warn('Failed to load font ${subset.family} at ${subset.url}'); html.window.console.warn(e); return; } - - final Uint8List bytes = buffer.asUint8List(); - skiaFontCollection.registerFallbackFont(subset.family, bytes); - - pendingSubsets.remove(subset); downloadedSubsets.add(subset); - if (pendingSubsets.isEmpty) { - await skiaFontCollection.ensureFontsLoaded(); - sendFontChangeMessage(); + downloadedData[subset.url] = buffer.asUint8List(); + }); + } + + await Future.wait(downloads.values); + + // Register fallback fonts in a predictable order. Otherwise, the fonts + // change their precedence depending on the download order causing + // visual differences between app reloads. + final List downloadOrder = + (downloadedData.keys.toList()..sort()).reversed.toList(); + for (String url in downloadOrder) { + final _ResolvedNotoSubset subset = pendingSubsets.remove(url)!; + final Uint8List bytes = downloadedData[url]!; + FontFallbackData.instance.registerFallbackFont(subset.family, bytes); + if (pendingSubsets.isEmpty) { + _fontsLoading = skiaFontCollection.ensureFontsLoaded(); + try { + await _fontsLoading; + } finally { + _fontsLoading = null; } - })); + sendFontChangeMessage(); + } } - await Future.wait(downloads); if (pendingSubsets.isNotEmpty) { await startDownloads(); } @@ -660,25 +763,66 @@ class FallbackFontDownloadQueue { } class NotoDownloader { + int get debugActiveDownloadCount => _debugActiveDownloadCount; + 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 + Future downloadAsBytes(String url, {String? debugDescription}) { + 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) => + Future downloadAsString(String url, {String? debugDescription}) { + 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; } } -/// The Noto font interval tree. -IntervalTree<_NotoFont>? _notoTree; - FallbackFontDownloadQueue notoDownloadQueue = FallbackFontDownloadQueue(); diff --git a/lib/web_ui/lib/src/engine/canvaskit/fonts.dart b/lib/web_ui/lib/src/engine/canvaskit/fonts.dart index 4b7b169cc58a3..0465928d7f4e8 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/fonts.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/fonts.dart @@ -22,16 +22,9 @@ class SkiaFontCollection { /// Fonts which have been registered and loaded. final List<_RegisteredFont> _registeredFonts = <_RegisteredFont>[]; - /// Fallback fonts which have been registered and loaded. - final List<_RegisteredFont> _registeredFallbackFonts = <_RegisteredFont>[]; - final Map> familyToTypefaceMap = >{}; - final List globalFontFallbacks = ['Roboto']; - - final Map _fontFallbackCounts = {}; - Future ensureFontsLoaded() async { await _loadFonts(); @@ -49,7 +42,7 @@ class SkiaFontCollection { .add(font.typeface); } - for (var font in _registeredFallbackFonts) { + for (var font in FontFallbackData.instance.registeredFallbackFonts) { fontProvider!.registerFont(font.bytes, font.family); familyToTypefaceMap .putIfAbsent(font.family, () => []) @@ -151,15 +144,6 @@ class SkiaFontCollection { return _RegisteredFont(bytes, family); } - void registerFallbackFont(String family, Uint8List bytes) { - _fontFallbackCounts.putIfAbsent(family, () => 0); - int fontFallbackTag = _fontFallbackCounts[family]!; - _fontFallbackCounts[family] = _fontFallbackCounts[family]! + 1; - String countedFamily = '$family $fontFallbackTag'; - _registeredFallbackFonts.add(_RegisteredFont(bytes, countedFamily)); - globalFontFallbacks.add(countedFamily); - } - String? _readActualFamilyName(Uint8List bytes) { final SkFontMgr tmpFontMgr = canvasKit.FontMgr.FromData([bytes])!; String? actualFamily = tmpFontMgr.getFamilyName(0); @@ -174,14 +158,6 @@ class SkiaFontCollection { .then((dynamic x) => x as ByteBuffer); } - /// Resets the fallback fonts. Used for tests. - void debugResetFallbackFonts() { - _registeredFallbackFonts.clear(); - globalFontFallbacks.clear(); - globalFontFallbacks.add('Roboto'); - _fontFallbackCounts.clear(); - } - SkFontMgr? skFontMgr; TypefaceFontProvider? fontProvider; } @@ -201,5 +177,9 @@ class _RegisteredFont { _RegisteredFont(this.bytes, this.family) : this.typeface = - canvasKit.FontMgr.RefDefault().MakeTypefaceFromData(bytes); + canvasKit.FontMgr.RefDefault().MakeTypefaceFromData(bytes) { + // This is a hack which causes Skia to cache the decoded font. + SkFont skFont = SkFont(typeface); + skFont.getGlyphBounds([0], null, null); + } } diff --git a/lib/web_ui/lib/src/engine/canvaskit/image.dart b/lib/web_ui/lib/src/engine/canvaskit/image.dart index 658dca082aec1..e0c9bcc76c519 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/image.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/image.dart @@ -172,13 +172,18 @@ class CkImage implements ui.Image, StackTraceDebugger { // IMPORTANT: the alphaType, colorType, and colorSpace passed to // _encodeImage and to canvasKit.MakeImage must be the same. Otherwise // Skia will misinterpret the pixels and corrupt the image. - final ByteData originalBytes = _encodeImage( + final ByteData? originalBytes = _encodeImage( skImage: skImage, format: ui.ImageByteFormat.rawRgba, alphaType: canvasKit.AlphaType.Premul, colorType: canvasKit.ColorType.RGBA_8888, colorSpace: SkColorSpaceSRGB, ); + if (originalBytes == null) { + html.window.console.warn('Unable to encode image to bytes. We will not ' + 'be able to resurrect it once it has been garbage collected.'); + return; + } final int originalWidth = skImage.width(); final int originalHeight = skImage.height(); box = SkiaObjectBox.resurrectable(this, skImage, () { @@ -276,23 +281,28 @@ class CkImage implements ui.Image, StackTraceDebugger { ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba, }) { assert(_debugCheckIsNotDisposed()); - return Future.value(_encodeImage( + ByteData? data = _encodeImage( skImage: skImage, format: format, alphaType: canvasKit.AlphaType.Premul, colorType: canvasKit.ColorType.RGBA_8888, colorSpace: SkColorSpaceSRGB, - )); + ); + if (data == null) { + return Future.error('Failed to encode the image into bytes.'); + } else { + return Future.value(data); + } } - static ByteData _encodeImage({ + static ByteData? _encodeImage({ required SkImage skImage, required ui.ImageByteFormat format, required SkAlphaType alphaType, required SkColorType colorType, required ColorSpace colorSpace, }) { - Uint8List bytes; + Uint8List? bytes; if (format == ui.ImageByteFormat.rawRgba) { final SkImageInfo imageInfo = SkImageInfo( @@ -304,13 +314,10 @@ class CkImage implements ui.Image, StackTraceDebugger { ); bytes = skImage.readPixels(0, 0, imageInfo); } else { - final SkData skData = skImage.encodeToData(); //defaults to PNG 100% - // make a copy that we can return - bytes = Uint8List.fromList(canvasKit.getDataBytes(skData)); - skData.delete(); + bytes = skImage.encodeToBytes(); //defaults to PNG 100% } - return bytes.buffer.asByteData(0, bytes.length); + return bytes?.buffer.asByteData(0, bytes.length); } @override diff --git a/lib/web_ui/lib/src/engine/canvaskit/initialization.dart b/lib/web_ui/lib/src/engine/canvaskit/initialization.dart index da7204a953b86..50369938d65d2 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/initialization.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/initialization.dart @@ -75,10 +75,12 @@ const bool canvasKitForceCpuOnly = bool.fromEnvironment( /// NPM, update this URL to `https://unpkg.com/canvaskit-wasm@0.34.0/bin/`. const String canvasKitBaseUrl = String.fromEnvironment( 'FLUTTER_WEB_CANVASKIT_URL', - defaultValue: 'https://unpkg.com/canvaskit-wasm@0.22.0/bin/', + defaultValue: 'https://unpkg.com/canvaskit-wasm@0.24.0/bin/', ); -final String canvasKitBuildUrl = canvasKitBaseUrl + (kProfileMode ? 'profiling/' : ''); -final String canvasKitJavaScriptBindingsUrl = canvasKitBuildUrl + 'canvaskit.js'; +final String canvasKitBuildUrl = + canvasKitBaseUrl + (kProfileMode ? 'profiling/' : ''); +final String canvasKitJavaScriptBindingsUrl = + canvasKitBuildUrl + 'canvaskit.js'; String canvasKitWasmModuleUrl(String file) => canvasKitBuildUrl + file; /// Initialize CanvasKit. @@ -89,8 +91,10 @@ Future initializeCanvasKit() { late StreamSubscription loadSubscription; loadSubscription = domRenderer.canvasKitScript!.onLoad.listen((_) { loadSubscription.cancel(); - final CanvasKitInitPromise canvasKitInitPromise = CanvasKitInit(CanvasKitInitOptions( - locateFile: js.allowInterop((String file, String unusedBase) => canvasKitWasmModuleUrl(file)), + final CanvasKitInitPromise canvasKitInitPromise = + CanvasKitInit(CanvasKitInitOptions( + locateFile: js.allowInterop( + (String file, String unusedBase) => canvasKitWasmModuleUrl(file)), )); canvasKitInitPromise.then(js.allowInterop((CanvasKit ck) { canvasKit = ck; diff --git a/lib/web_ui/lib/src/engine/canvaskit/interval_tree.dart b/lib/web_ui/lib/src/engine/canvaskit/interval_tree.dart index 88a097e65369a..b42e504004815 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/interval_tree.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/interval_tree.dart @@ -17,13 +17,15 @@ class IntervalTree { /// When the interval tree is queried, it will return a list of [T]s which /// have a range which contains the point. factory IntervalTree.createFromRanges(Map> rangesMap) { + assert(rangesMap.isNotEmpty); // Get a list of all the ranges ordered by start index. - List> intervals = >[]; + final List> intervals = >[]; rangesMap.forEach((T key, List rangeList) { for (CodeunitRange range in rangeList) { intervals.add(IntervalTreeNode(key, range.start, range.end)); } }); + assert(intervals.isNotEmpty); intervals .sort((IntervalTreeNode a, IntervalTreeNode b) => a.low - b.low); @@ -80,6 +82,11 @@ class IntervalTree { root.searchForPoint(x, results); return results; } + + /// Whether this tree contains at least one interval that includes [x]. + bool containsDeep(int x) { + return root.containsDeep(x); + } } class IntervalTreeNode { @@ -93,17 +100,52 @@ class IntervalTreeNode { IntervalTreeNode(this.value, this.low, this.high) : computedHigh = high; - bool contains(int x) { + Iterable enumerateAllElements() sync* { + if (left != null) { + yield* left!.enumerateAllElements(); + } + yield value; + if (right != null) { + yield* right!.enumerateAllElements(); + } + } + + /// Whether this node contains [x]. + /// + /// Does not recursively check whether child nodes contain [x]. + bool containsShallow(int x) { return low <= x && x <= high; } + /// Whether this sub-tree contains [x]. + /// + /// Recursively checks whether child nodes contain [x]. + bool containsDeep(int x) { + if (x > computedHigh) { + // x is above the highest possible value stored in this subtree. + // Don't bother checking intervals. + return false; + } + if (this.containsShallow(x)) { + return true; + } + if (left?.containsDeep(x) == true) { + return true; + } + if (x < low) { + // The right tree can't possible contain x. Don't bother checking. + return false; + } + return right?.containsDeep(x) == true; + } + // Searches the tree rooted at this node for all T containing [x]. void searchForPoint(int x, List result) { if (x > computedHigh) { return; } left?.searchForPoint(x, result); - if (this.contains(x)) { + if (this.containsShallow(x)) { result.add(value); } if (x < low) { diff --git a/lib/web_ui/lib/src/engine/canvaskit/path.dart b/lib/web_ui/lib/src/engine/canvaskit/path.dart index e8518dc6e6828..d2d1a333d92ee 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/path.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/path.dart @@ -13,7 +13,7 @@ class CkPath extends ManagedSkiaObject implements ui.Path { CkPath.from(CkPath other) : _fillType = other.fillType, - super(SkPath(other.skiaObject)) { + super(other.skiaObject.copy()) { skiaObject.setFillType(toSkFillType(_fillType)); } diff --git a/lib/web_ui/lib/src/engine/canvaskit/surface.dart b/lib/web_ui/lib/src/engine/canvaskit/surface.dart index de9c75aa3b29c..7ad4b2aec1c93 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/surface.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/surface.dart @@ -59,6 +59,11 @@ class Surface { /// due to the browser tab becoming dormant. final html.Element htmlElement = html.Element.tag('flt-canvas-container'); + /// The underlying `` element used for this surface. + html.CanvasElement? htmlCanvas; + int _pixelWidth = -1; + int _pixelHeight = -1; + /// Specify the GPU resource cache limits. void setSkiaResourceCacheMaxBytes(int bytes) { _skiaCacheBytes = bytes; @@ -102,6 +107,7 @@ class Surface { } ui.Size? _currentSize; + double _currentDevicePixelRatio = -1; CkSurface _createOrUpdateSurfaces(ui.Size size) { if (size.isEmpty) { @@ -116,9 +122,13 @@ class Surface { size.width <= previousSize.width && size.height <= previousSize.height) { // The existing surface is still reusable. + if (window.devicePixelRatio != _currentDevicePixelRatio) { + _updateLogicalHtmlCanvasSize(); + } return _surface!; } + _currentDevicePixelRatio = window.devicePixelRatio; _currentSize = _currentSize == null // First frame. Allocate a canvas of the exact size as the window. The // window is frequently never resized, particularly on mobile, so using @@ -131,36 +141,44 @@ class Surface { _surface = null; _addedToScene = false; - return _surface = _wrapHtmlCanvas(_currentSize!); + return _surface = _createNewSurface(_currentSize!); } - CkSurface _wrapHtmlCanvas(ui.Size physicalSize) { - // Clear the container, if it's not empty. - while (htmlElement.firstChild != null) { - htmlElement.firstChild!.remove(); - } + /// Sets the CSS size of the canvas so that canvas pixels are 1:1 with device + /// pixels. + /// + /// The logical size of the canvas is not based on the size of the window + /// but on the size of the canvas, which, due to `ceil()` above, may not be + /// the same as the window. We do not round/floor/ceil the logical size as + /// CSS pixels can contain more than one physical pixel and therefore to + /// match the size of the window precisely we use the most precise floating + /// point value we can get. + void _updateLogicalHtmlCanvasSize() { + final double logicalWidth = _pixelWidth / ui.window.devicePixelRatio; + final double logicalHeight = _pixelHeight / ui.window.devicePixelRatio; + htmlCanvas!.style + ..width = '${logicalWidth}px' + ..height = '${logicalHeight}px'; + } + + /// This function is expensive. + /// + /// It's better to reuse surface if possible. + CkSurface _createNewSurface(ui.Size physicalSize) { + // Clear the container, if it's not empty. We're going to create a new . + this.htmlCanvas?.remove(); // If `physicalSize` is not precise, use a slightly bigger canvas. This way // we ensure that the rendred picture covers the entire browser window. - final int pixelWidth = physicalSize.width.ceil(); - final int pixelHeight = physicalSize.height.ceil(); + _pixelWidth = physicalSize.width.ceil(); + _pixelHeight = physicalSize.height.ceil(); final html.CanvasElement htmlCanvas = html.CanvasElement( - width: pixelWidth, - height: pixelHeight, + width: _pixelWidth, + height: _pixelHeight, ); - - // The logical size of the canvas is not based on the size of the window - // but on the size of the canvas, which, due to `ceil()` above, may not be - // the same as the window. We do not round/floor/ceil the logical size as - // CSS pixels can contain more than one physical pixel and therefore to - // match the size of the window precisely we use the most precise floating - // point value we can get. - final double logicalWidth = pixelWidth / ui.window.devicePixelRatio; - final double logicalHeight = pixelHeight / ui.window.devicePixelRatio; - htmlCanvas.style - ..position = 'absolute' - ..width = '${logicalWidth}px' - ..height = '${logicalHeight}px'; + this.htmlCanvas = htmlCanvas; + htmlCanvas.style.position = 'absolute'; + _updateLogicalHtmlCanvasSize(); // When the browser tab using WebGL goes dormant the browser and/or OS may // decide to clear GPU resources to let other tabs/programs use the GPU. @@ -212,8 +230,8 @@ class Surface { SkSurface? skSurface = canvasKit.MakeOnScreenGLSurface( _grContext!, - pixelWidth, - pixelHeight, + _pixelWidth, + _pixelHeight, SkColorSpaceSRGB, ); diff --git a/lib/web_ui/lib/src/engine/canvaskit/text.dart b/lib/web_ui/lib/src/engine/canvaskit/text.dart index 3b7735e284086..f7c50e8b0452e 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( @@ -646,6 +674,26 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { // TODO(hterkelsen): Make this faster for the common case where the text // is supported by the given fonts. + // A list of unique code units in the text. + final List codeUnits = text.runes.toSet().toList(); + + // First, check if every code unit in the text is known to be covered by one + // of our global fallback fonts. We cache the set of code units covered by + // the global fallback fonts since this set is growing monotonically over + // the lifetime of the app. + if (_checkIfGlobalFallbacksSupport(codeUnits)) { + return; + } + + // Next, check if all of the remaining code units are ones which are known + // to have no global font fallback. This means we know of no font we can + // download which will cover the remaining code units. In this case we can + // just skip the checks below, since we know there's nothing we can do to + // cover the code units. + if (_checkIfNoFallbackFontSupports(codeUnits)) { + return; + } + // If the text is ASCII, then skip this check. bool isAscii = true; for (int i = 0; i < text.length; i++) { @@ -657,9 +705,15 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { if (isAscii) { return; } + CkTextStyle style = _peekStyle(); - List fontFamilies = - _getEffectiveFontFamilies(style.fontFamily, style.fontFamilyFallback); + List fontFamilies = []; + if (style.fontFamily != null) { + fontFamilies.add(style.fontFamily!); + } + if (style.fontFamilyFallback != null) { + fontFamilies.addAll(style.fontFamilyFallback!); + } List typefaces = []; for (var font in fontFamilies) { List? typefacesForFamily = @@ -668,14 +722,14 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { typefaces.addAll(typefacesForFamily); } } - List codeUnitsSupported = List.filled(text.length, false); + List codeUnitsSupported = List.filled(codeUnits.length, false); + String testString = String.fromCharCodes(codeUnits); for (SkTypeface typeface in typefaces) { SkFont font = SkFont(typeface); - Uint8List glyphs = font.getGlyphIDs(text); + Uint8List glyphs = font.getGlyphIDs(testString); 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,10 +737,10 @@ 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); + findFontsForMissingCodeunits(missingCodeUnits); } } @@ -695,6 +749,89 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { return codepoint < 32 || (codepoint > 127 && codepoint < 160); } + /// Returns `true` if every code unit in [codeUnits] is covered by a global + /// fallback font. + /// + /// Calling this method has 2 side effects: + /// 1. Updating the cache of known covered code units in the + /// [FontFallbackData] instance. + /// 2. Removing known covered code units from [codeUnits]. When the list + /// is used again in [_ensureFontsSupportText] + bool _checkIfGlobalFallbacksSupport(List codeUnits) { + final FontFallbackData fallbackData = FontFallbackData.instance; + codeUnits.removeWhere((int codeUnit) => + fallbackData.knownCoveredCodeUnits.contains(codeUnit)); + if (codeUnits.isEmpty) { + return true; + } + + // We don't know if the remaining code units are covered by our fallback + // fonts. Check them and update the cache. + List codeUnitsSupported = List.filled(codeUnits.length, false); + String testString = String.fromCharCodes(codeUnits); + + for (String font in fallbackData.globalFontFallbacks) { + List? typefacesForFamily = + skiaFontCollection.familyToTypefaceMap[font]; + if (typefacesForFamily == null) { + html.window.console.warn('A fallback font was registered but we ' + 'cannot retrieve the typeface for it.'); + continue; + } + for (SkTypeface typeface in typefacesForFamily) { + SkFont font = SkFont(typeface); + Uint8List glyphs = font.getGlyphIDs(testString); + assert(glyphs.length == codeUnitsSupported.length); + for (int i = 0; i < glyphs.length; i++) { + bool codeUnitSupported = glyphs[i] != 0; + if (codeUnitSupported) { + fallbackData.knownCoveredCodeUnits.add(codeUnits[i]); + } + codeUnitsSupported[i] |= + codeUnitSupported || _isControlCode(codeUnits[i]); + } + } + + // Once we've checked every typeface for this family, check to see if + // every code unit has been covered in order to avoid unnecessary checks. + bool keepGoing = false; + for (bool supported in codeUnitsSupported) { + if (!supported) { + keepGoing = true; + break; + } + } + + if (!keepGoing) { + // Every code unit is supported, clear [codeUnits] and return `true`. + codeUnits.clear(); + return true; + } + } + + // If we reached here, then there are some code units which aren't covered + // by the global fallback fonts. Remove the ones which were covered and + // return false. + for (int i = codeUnits.length - 1; i >= 0; i--) { + if (codeUnitsSupported[i]) { + codeUnits.removeAt(i); + } + } + return false; + } + + /// Returns `true` if every code unit in [codeUnits] is known to not have any + /// fallback font which can cover it. + /// + /// This method has a side effect of removing every code unit from [codeUnits] + /// which is known not to have a fallback font which covers it. + bool _checkIfNoFallbackFontSupports(List codeUnits) { + final FontFallbackData fallbackData = FontFallbackData.instance; + codeUnits.removeWhere((int codeUnit) => + fallbackData.codeUnitsWithNoKnownFont.contains(codeUnit)); + return codeUnits.isEmpty; + } + @override void addText(String text) { _ensureFontsSupportText(text); @@ -723,13 +860,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 +887,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 +898,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 { @@ -821,6 +976,6 @@ List _getEffectiveFontFamilies(String? fontFamily, !fontFamilyFallback.every((font) => fontFamily == font)) { fontFamilies.addAll(fontFamilyFallback); } - fontFamilies.addAll(skiaFontCollection.globalFontFallbacks); + fontFamilies.addAll(FontFallbackData.instance.globalFontFallbacks); return fontFamilies; } 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/pointer_binding.dart b/lib/web_ui/lib/src/engine/pointer_binding.dart index 47c7092e515f8..c8247c39894fd 100644 --- a/lib/web_ui/lib/src/engine/pointer_binding.dart +++ b/lib/web_ui/lib/src/engine/pointer_binding.dart @@ -415,41 +415,43 @@ class _ButtonSanitizer { ); } - _SanitizedDetails? sanitizeUpEvent() { + _SanitizedDetails? sanitizeMissingRightClickUp({required int buttons}) { + final int newPressedButtons = _htmlButtonsToFlutterButtons(buttons); + // This could happen when RMB is clicked and released but no pointerup + // event was received because context menu was shown. + if (_pressedButtons != 0 && newPressedButtons == 0) { + _pressedButtons = 0; + return _SanitizedDetails( + change: ui.PointerChange.up, + buttons: _pressedButtons, + ); + } + return null; + } + + _SanitizedDetails? sanitizeUpEvent({required int? buttons}) { // The pointer could have been released by a `pointerout` event, in which // case `pointerup` should have no effect. if (_pressedButtons == 0) { return null; } - _pressedButtons = 0; - return _SanitizedDetails( - change: ui.PointerChange.up, - buttons: _pressedButtons, - ); - } - _SanitizedDetails? sanitizeUpEventWithButtons({required int buttons}) { - final int newPressedButtons = _htmlButtonsToFlutterButtons(buttons); - // This could happen when the context menu is active and the user clicks - // RMB somewhere else. The browser sends a down event with `buttons:0`. - // - // In this case, we keep the old `buttons` value so we don't confuse the - // framework. - if (_pressedButtons != 0 && newPressedButtons == 0) { + _pressedButtons = _htmlButtonsToFlutterButtons(buttons ?? 0); + + if (_pressedButtons == 0) { + // All buttons have been released. + return _SanitizedDetails( + change: ui.PointerChange.up, + buttons: _pressedButtons, + ); + } else { + // There are still some unreleased buttons, we shouldn't send an up event + // yet. Instead we send a move event to update the position of the pointer. return _SanitizedDetails( change: ui.PointerChange.move, buttons: _pressedButtons, ); } - - _pressedButtons = newPressedButtons; - - return _SanitizedDetails( - change: _pressedButtons == 0 - ? ui.PointerChange.hover - : ui.PointerChange.move, - buttons: _pressedButtons, - ); } _SanitizedDetails sanitizeCancelEvent() { @@ -462,7 +464,6 @@ class _ButtonSanitizer { } typedef _PointerEventListener = dynamic Function(html.PointerEvent event); -const int kContextMenuButton = 2; /// Adapter class to be used with browsers that support native pointer events. /// @@ -512,19 +513,17 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { final int device = event.pointerId!; final List pointerData = []; final _ButtonSanitizer sanitizer = _ensureSanitizer(device); - if (event.button == kContextMenuButton) { - _handleMissingRightMouseUpEvent(sanitizer, - sanitizer._pressedButtons, - sanitizer._pressedButtons & ~kContextMenuButton, - event, - pointerData); + final _SanitizedDetails? up = + sanitizer.sanitizeMissingRightClickUp(buttons: event.buttons!); + if (up != null) { + _convertEventsToPointerData(data: pointerData, event: event, details: up); } - final _SanitizedDetails details = + final _SanitizedDetails down = sanitizer.sanitizeDownEvent( button: event.button, buttons: event.buttons!, ); - _convertEventsToPointerData(data: pointerData, event: event, details: details); + _convertEventsToPointerData(data: pointerData, event: event, details: down); _callback(pointerData); }); @@ -532,21 +531,14 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { final int device = event.pointerId!; final _ButtonSanitizer sanitizer = _ensureSanitizer(device); final List pointerData = []; - final int buttonsBeforeEvent = sanitizer._pressedButtons; - final Iterable<_SanitizedDetails> detailsList = _expandEvents(event).map( - (html.PointerEvent expandedEvent) { - return sanitizer.sanitizeMoveEvent(buttons: expandedEvent.buttons!); - }, - ); - _handleMissingRightMouseUpEvent( - sanitizer, - buttonsBeforeEvent, - (sanitizer._inferDownFlutterButtons(event.button, event.buttons!) - & kContextMenuButton), - event, - pointerData); - for (_SanitizedDetails details in detailsList) { - _convertEventsToPointerData(data: pointerData, event: event, details: details); + final List expandedEvents = _expandEvents(event); + for (final html.PointerEvent event in expandedEvents) { + final _SanitizedDetails? up = sanitizer.sanitizeMissingRightClickUp(buttons: event.buttons!); + if (up != null) { + _convertEventsToPointerData(data: pointerData, event: event, details: up); + } + final _SanitizedDetails move = sanitizer.sanitizeMoveEvent(buttons: event.buttons!); + _convertEventsToPointerData(data: pointerData, event: event, details: move); } _callback(pointerData); }, acceptOutsideGlasspane: true); @@ -554,12 +546,12 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { _addPointerEventListener('pointerup', (html.PointerEvent event) { final int device = event.pointerId!; final List pointerData = []; - final _SanitizedDetails? details = _getSanitizer(device).sanitizeUpEvent(); + final _SanitizedDetails? details = _getSanitizer(device).sanitizeUpEvent(buttons: event.buttons); _removePointerIfUnhoverable(event); if (details != null) { _convertEventsToPointerData(data: pointerData, event: event, details: details); + _callback(pointerData); } - _callback(pointerData); }, acceptOutsideGlasspane: true); // A browser fires cancel event if it concludes the pointer will no longer @@ -578,39 +570,6 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { }); } - // Handle special case where right mouse button no longer is pressed. - // We need to synthesize right mouse up, otherwise drag gesture will fail - // to complete or multiple RMB down events will lead to wrong state. - void _handleMissingRightMouseUpEvent(_ButtonSanitizer sanitizer, - int buttonsBeforeEvent, int buttonsAfterEvent, html.PointerEvent event, - List pointerData) { - if ((buttonsBeforeEvent & kContextMenuButton) != 0 && - buttonsAfterEvent == 0) { - final ui.PointerDeviceKind kind = - _pointerTypeToDeviceKind(event.pointerType!); - final int device = kind == ui.PointerDeviceKind.mouse - ? _mouseDeviceId : event.pointerId!; - final double tilt = _computeHighestTilt(event); - final Duration timeStamp = _BaseAdapter._eventTimeStampToDuration(event.timeStamp!); - sanitizer._pressedButtons &= ~kContextMenuButton; - _pointerDataConverter.convert( - pointerData, - change: ui.PointerChange.up, - timeStamp: timeStamp, - kind: kind, - signalKind: ui.PointerSignalKind.none, - device: device, - physicalX: event.client.x.toDouble() * ui.window.devicePixelRatio, - physicalY: event.client.y.toDouble() * ui.window.devicePixelRatio, - buttons: sanitizer._pressedButtons, - pressure: event.pressure as double, - pressureMin: 0.0, - pressureMax: 1.0, - tilt: tilt, - ); - } - } - // For each event that is de-coalesced from `event` and described in // `details`, convert it to pointer data and store in `data`. void _convertEventsToPointerData({ @@ -852,12 +811,10 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin { void setup() { _addMouseEventListener('mousedown', (html.MouseEvent event) { final List pointerData = []; - if (event.button == kContextMenuButton) { - _handleMissingRightMouseUpEvent(_sanitizer, - _sanitizer._pressedButtons, - _sanitizer._pressedButtons & ~kContextMenuButton, - event, - pointerData); + final _SanitizedDetails? up = + _sanitizer.sanitizeMissingRightClickUp(buttons: event.buttons!); + if (up != null) { + _convertEventsToPointerData(data: pointerData, event: event, details: up); } final _SanitizedDetails sanitizedDetails = _sanitizer.sanitizeDownEvent( @@ -870,27 +827,22 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin { _addMouseEventListener('mousemove', (html.MouseEvent event) { final List pointerData = []; - final int buttonsBeforeEvent = _sanitizer._pressedButtons; - _handleMissingRightMouseUpEvent( - _sanitizer, - buttonsBeforeEvent, - (_sanitizer._inferDownFlutterButtons(event.button, event.buttons!) - & kContextMenuButton), - event, - pointerData); - final _SanitizedDetails sanitizedDetails = _sanitizer.sanitizeMoveEvent(buttons: event.buttons!); - _convertEventsToPointerData(data: pointerData, event: event, details: sanitizedDetails); + final _SanitizedDetails? up = _sanitizer.sanitizeMissingRightClickUp(buttons: event.buttons!); + if (up != null) { + _convertEventsToPointerData(data: pointerData, event: event, details: up); + } + final _SanitizedDetails move = _sanitizer.sanitizeMoveEvent(buttons: event.buttons!); + _convertEventsToPointerData(data: pointerData, event: event, details: move); _callback(pointerData); }, acceptOutsideGlasspane: true); _addMouseEventListener('mouseup', (html.MouseEvent event) { final List pointerData = []; - final bool isEndOfDrag = event.buttons == 0; - final _SanitizedDetails sanitizedDetails = isEndOfDrag ? - _sanitizer.sanitizeUpEvent()! : - _sanitizer.sanitizeUpEventWithButtons(buttons: event.buttons!)!; - _convertEventsToPointerData(data: pointerData, event: event, details: sanitizedDetails); - _callback(pointerData); + final _SanitizedDetails? sanitizedDetails = _sanitizer.sanitizeUpEvent(buttons: event.buttons); + if (sanitizedDetails != null) { + _convertEventsToPointerData(data: pointerData, event: event, details: sanitizedDetails); + _callback(pointerData); + } }, acceptOutsideGlasspane: true); _addWheelEventListener((html.Event event) { @@ -898,32 +850,6 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin { }); } - // Handle special case where right mouse button no longer is pressed. - // We need to synthesize right mouse up, otherwise drag gesture will fail - // to complete or multiple RMB down events will lead to wrong state. - void _handleMissingRightMouseUpEvent(_ButtonSanitizer sanitizer, - int buttonsBeforeEvent, int buttonsAfterEvent, html.MouseEvent event, - List pointerData) { - if ((buttonsBeforeEvent & kContextMenuButton) != 0 && - buttonsAfterEvent == 0) { - sanitizer._pressedButtons &= ~2; - _pointerDataConverter.convert( - pointerData, - change: ui.PointerChange.up, - timeStamp: _BaseAdapter._eventTimeStampToDuration(event.timeStamp!), - kind: ui.PointerDeviceKind.mouse, - signalKind: ui.PointerSignalKind.none, - device: _mouseDeviceId, - physicalX: event.client.x.toDouble() * ui.window.devicePixelRatio, - physicalY: event.client.y.toDouble() * ui.window.devicePixelRatio, - buttons: _sanitizer._pressedButtons, - pressure: 1.0, - pressureMin: 0.0, - pressureMax: 1.0, - ); - } - } - // For each event that is de-coalesced from `event` and described in // `detailsList`, convert it to pointer data and store in `data`. void _convertEventsToPointerData({ diff --git a/lib/web_ui/lib/src/engine/pointer_converter.dart b/lib/web_ui/lib/src/engine/pointer_converter.dart index 26b72a08f9b85..c2370d8ea187e 100644 --- a/lib/web_ui/lib/src/engine/pointer_converter.dart +++ b/lib/web_ui/lib/src/engine/pointer_converter.dart @@ -19,8 +19,6 @@ class _PointerState { _pointer = _pointerCount; } - bool down = false; - double x; double y; } @@ -240,8 +238,9 @@ class PointerDataConverter { double scrollDeltaY = 0.0, }) { if (_debugLogPointerConverter) { - print('>> device=$device change = $change buttons = $buttons'); + print('>> device=$device change=$change buttons=$buttons'); } + final bool isDown = buttons != 0; assert(change != null); // ignore: unnecessary_null_comparison if (signalKind == null || signalKind == ui.PointerSignalKind.none) { @@ -281,9 +280,8 @@ class PointerDataConverter { break; case ui.PointerChange.hover: final bool alreadyAdded = _pointers.containsKey(device); - final _PointerState state = _ensureStateForPointer( - device, physicalX, physicalY); - assert(!state.down); + _ensureStateForPointer(device, physicalX, physicalY); + assert(!isDown); if (!alreadyAdded) { // Synthesizes an add pointer data. result.add( @@ -348,7 +346,7 @@ class PointerDataConverter { final bool alreadyAdded = _pointers.containsKey(device); final _PointerState state = _ensureStateForPointer( device, physicalX, physicalY); - assert(!state.down); + assert(isDown); state.startNewPointer(); if (!alreadyAdded) { // Synthesizes an add pointer data. @@ -412,7 +410,6 @@ class PointerDataConverter { ) ); } - state.down = true; result.add( _generateCompletePointerData( timeStamp: timeStamp, @@ -445,8 +442,7 @@ class PointerDataConverter { break; case ui.PointerChange.move: assert(_pointers.containsKey(device)); - final _PointerState state = _pointers[device]!; - assert(state.down); + assert(isDown); result.add( _generateCompletePointerData( timeStamp: timeStamp, @@ -481,7 +477,7 @@ class PointerDataConverter { case ui.PointerChange.cancel: assert(_pointers.containsKey(device)); final _PointerState state = _pointers[device]!; - assert(state.down); + assert(!isDown); // Cancel events can have different coordinates due to various // reasons (window lost focus which is accompanied by window // movement, or PointerEvent simply always gives 0). Instead of @@ -522,7 +518,6 @@ class PointerDataConverter { ) ); } - state.down = false; result.add( _generateCompletePointerData( timeStamp: timeStamp, @@ -588,7 +583,7 @@ class PointerDataConverter { case ui.PointerChange.remove: assert(_pointers.containsKey(device)); final _PointerState state = _pointers[device]!; - assert(!state.down); + assert(!isDown); result.add( _generateCompletePointerData( timeStamp: timeStamp, @@ -624,8 +619,7 @@ class PointerDataConverter { switch (signalKind) { case ui.PointerSignalKind.scroll: final bool alreadyAdded = _pointers.containsKey(device); - final _PointerState state = _ensureStateForPointer( - device, physicalX, physicalY); + _ensureStateForPointer(device, physicalX, physicalY); if (!alreadyAdded) { // Synthesizes an add pointer data. result.add( @@ -661,7 +655,7 @@ class PointerDataConverter { // before sending the scroll event, if necessary, so that clients // don't have to worry about native ordering of hover and scroll // events. - if (state.down) { + if (isDown) { result.add( _synthesizePointerData( timeStamp: timeStamp, 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..f264daf912325 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; @@ -78,7 +78,6 @@ class TextLayoutService { final Spanometer spanometer = Spanometer(paragraph, context); int spanIndex = 0; - ParagraphSpan span = paragraph.spans[0]; LineBuilder currentLine = LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width); @@ -86,27 +85,34 @@ class TextLayoutService { // statements (e.g. when we reach `endOfText`, when ellipsis has been // appended). while (true) { - // *********************************************** // - // *** HANDLE HARD LINE BREAKS AND END OF TEXT *** // - // *********************************************** // - - if (currentLine.end.isHard) { - if (currentLine.isNotEmpty) { + // ************************** // + // *** HANDLE END OF TEXT *** // + // ************************** // + + // All spans have been consumed. + final bool reachedEnd = spanIndex == spanCount; + if (reachedEnd) { + // In some cases, we need to extend the line to the end of text and + // build it: + // + // 1. Line is not empty. This could happen when the last span is a + // placeholder. + // + // 2. We haven't reached `LineBreakType.endOfText` yet. This could + // happen when the last character is a new line. + if (currentLine.isNotEmpty || currentLine.end.type != LineBreakType.endOfText) { + currentLine.extendToEndOfText(); lines.add(currentLine.build()); - if (currentLine.end.type != LineBreakType.endOfText) { - currentLine = currentLine.nextLine(); - } - } - - if (currentLine.end.type == LineBreakType.endOfText) { - break; } + break; } // ********************************* // // *** THE MAIN MEASUREMENT PART *** // // ********************************* // + final ParagraphSpan span = paragraph.spans[spanIndex]; + if (span is PlaceholderSpan) { if (currentLine.widthIncludingSpace + span.width <= constraints.width) { // The placeholder fits on the current line. @@ -119,6 +125,7 @@ class TextLayoutService { } currentLine.addPlaceholder(span); } + spanIndex++; } else if (span is FlatTextSpan) { spanometer.currentSpan = span; final LineBreakResult nextBreak = currentLine.findNextBreak(span.end); @@ -131,6 +138,10 @@ class TextLayoutService { // The line can extend to `nextBreak` without overflowing. currentLine.extendTo(nextBreak); + if (nextBreak.type == LineBreakType.mandatory) { + lines.add(currentLine.build()); + currentLine = currentLine.nextLine(); + } } else { // The chunk of text can't fit into the current line. final bool isLastLine = @@ -158,6 +169,12 @@ class TextLayoutService { currentLine = currentLine.nextLine(); } } + + // Only go to the next span if we've reached the end of this span. + if (currentLine.end.index >= span.end) { + currentLine.createBox(); + ++spanIndex; + } } else { throw UnimplementedError('Unknown span type: ${span.runtimeType}'); } @@ -165,16 +182,6 @@ class TextLayoutService { if (lines.length == maxLines) { break; } - - // ********************************************* // - // *** ADVANCE TO THE NEXT SPAN IF NECESSARY *** // - // ********************************************* // - - // Only go to the next span if we've reached the end of this span. - if (currentLine.end.index >= span.end && spanIndex < spanCount - 1) { - currentLine.createBox(); - span = paragraph.spans[++spanIndex]; - } } // ************************************************** // @@ -187,8 +194,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; } } @@ -197,13 +205,16 @@ class TextLayoutService { // ******************************** // spanIndex = 0; - span = paragraph.spans[0]; currentLine = LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width); - while (currentLine.end.type != LineBreakType.endOfText) { + while (spanIndex < spanCount) { + final ParagraphSpan span = paragraph.spans[spanIndex]; + bool breakToNextLine = false; + if (span is PlaceholderSpan) { currentLine.addPlaceholder(span); + spanIndex++; } else if (span is FlatTextSpan) { spanometer.currentSpan = span; final LineBreakResult nextBreak = currentLine.findNextBreak(span.end); @@ -211,6 +222,16 @@ class TextLayoutService { // For the purpose of max intrinsic width, we don't care if the line // fits within the constraints or not. So we always extend it. currentLine.extendTo(nextBreak); + if (nextBreak.type == LineBreakType.mandatory) { + // We don't want to break the line now because we want to update + // min/max intrinsic widths below first. + breakToNextLine = true; + } + + // Only go to the next span if we've reached the end of this span. + if (currentLine.end.index >= span.end) { + spanIndex++; + } } final double widthOfLastSegment = currentLine.lastSegment.width; @@ -218,17 +239,13 @@ class TextLayoutService { minIntrinsicWidth = widthOfLastSegment; } - if (currentLine.end.isHard) { - // Max intrinsic width includes the width of trailing spaces. - if (maxIntrinsicWidth < currentLine.widthIncludingSpace) { - maxIntrinsicWidth = currentLine.widthIncludingSpace; - } - currentLine = currentLine.nextLine(); + // Max intrinsic width includes the width of trailing spaces. + if (maxIntrinsicWidth < currentLine.widthIncludingSpace) { + maxIntrinsicWidth = currentLine.widthIncludingSpace; } - // Only go to the next span if we've reached the end of this span. - if (currentLine.end.index >= span.end && spanIndex < spanCount - 1) { - span = paragraph.spans[++spanIndex]; + if (breakToNextLine) { + currentLine = currentLine.nextLine(); } } } @@ -631,7 +648,10 @@ class LineSegment { double get widthOfTrailingSpace => widthIncludingSpace - width; /// Whether this segment is made of only white space. - bool get isSpaceOnly => start.index == end.indexWithoutTrailingSpaces; + /// + /// We rely on the [width] to determine this because relying on incides + /// doesn't work well for placeholders (they are zero-length strings). + bool get isSpaceOnly => width == 0; } /// Builds instances of [EngineLineMetrics] for the given [paragraph]. @@ -744,12 +764,19 @@ class LineBuilder { return widthOfTrailingSpace + spanometer.measure(end, newEnd); } + bool get _isLastBoxAPlaceholder { + if (_boxes.isEmpty) { + return false; + } + return (_boxes.last is PlaceholderBox); + } + /// Extends the line by setting a [newEnd]. void extendTo(LineBreakResult newEnd) { // If the current end of the line is a hard break, the line shouldn't be // extended any further. assert( - isEmpty || !end.isHard, + isEmpty || !end.isHard || _isLastBoxAPlaceholder, 'Cannot extend a line that ends with a hard break.', ); @@ -759,6 +786,28 @@ class LineBuilder { _addSegment(_createSegment(newEnd)); } + /// Extends the line to the end of the paragraph. + void extendToEndOfText() { + if (end.type == LineBreakType.endOfText) { + return; + } + + final LineBreakResult endOfText = LineBreakResult.sameIndex( + paragraph.toPlainText().length, + LineBreakType.endOfText, + ); + + // The spanometer may not be ready in some cases. E.g. when the paragraph + // is made up of only placeholders and no text. + if (spanometer.isReady) { + ascent = math.max(ascent, spanometer.ascent); + descent = math.max(descent, spanometer.descent); + _addSegment(_createSegment(endOfText)); + } else { + end = endOfText; + } + } + void addPlaceholder(PlaceholderSpan placeholder) { // Increase the line's height to fit the placeholder, if necessary. final double ascent, descent; @@ -1007,7 +1056,7 @@ class LineBuilder { final LineBreakResult boxEnd = end; // Avoid creating empty boxes. This could happen when the end of a span // coincides with the end of a line. In this case, `createBox` is called twice. - if (boxStart == boxEnd) { + if (boxStart.index == boxEnd.index) { return; } @@ -1028,13 +1077,20 @@ class LineBuilder { final double ellipsisWidth = ellipsis == null ? 0.0 : spanometer.measureText(ellipsis); + final int endIndexWithoutNewlines = math.max(start.index, end.indexWithoutTrailingNewlines); + final bool hardBreak; + if (end.type != LineBreakType.endOfText && _isLastBoxAPlaceholder) { + hardBreak = false; + } else { + hardBreak = end.isHard; + } return EngineLineMetrics.rich( lineNumber, ellipsis: ellipsis, startIndex: start.index, endIndex: end.index, - endIndexWithoutNewlines: end.indexWithoutTrailingNewlines, - hardBreak: end.isHard, + endIndexWithoutNewlines: endIndexWithoutNewlines, + hardBreak: hardBreak, width: width + ellipsisWidth, widthWithTrailingSpaces: widthIncludingSpace + ellipsisWidth, left: alignOffset, @@ -1133,6 +1189,9 @@ class Spanometer { } } + /// Whether the spanometer is ready to take measurements. + bool get isReady => _currentSpan != null; + /// The distance from the top of the current span to the alphabetic baseline. double get ascent => _currentRuler!.alphabeticBaseline; diff --git a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart index b131a08fa153f..d2a7eb0c6cf23 100644 --- a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart +++ b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart @@ -373,7 +373,11 @@ class AutofillInfo { /// The current text and selection state of a text field. @visibleForTesting class EditingState { - EditingState({this.text, this.baseOffset = 0, this.extentOffset = 0}); + EditingState({this.text, int? baseOffset, int? extentOffset}) : + // Don't allow negative numbers. Pick the smallest selection index for base. + baseOffset = math.max(0, math.min(baseOffset ?? 0, extentOffset ?? 0)), + // Don't allow negative numbers. Pick the greatest selection index for extent. + extentOffset = math.max(0, math.max(baseOffset ?? 0, extentOffset ?? 0)); /// Creates an [EditingState] instance using values from an editing state Map /// coming from Flutter. @@ -401,9 +405,10 @@ class EditingState { final String? text = flutterEditingState['text']; return EditingState( - text: text, - baseOffset: math.max(0, selectionBase), - extentOffset: math.max(0, selectionExtent)); + text: text, + baseOffset: selectionBase, + extentOffset: selectionExtent, + ); } /// Creates an [EditingState] instance using values from the editing element diff --git a/lib/web_ui/test/canvaskit/canvas_golden_test.dart b/lib/web_ui/test/canvaskit/canvas_golden_test.dart index f0f56cccefded..d17341be34c48 100644 --- a/lib/web_ui/test/canvaskit/canvas_golden_test.dart +++ b/lib/web_ui/test/canvaskit/canvas_golden_test.dart @@ -35,6 +35,16 @@ void testMain() { group('CkCanvas', () { setUpCanvasKitTest(); + setUp(() { + expect(notoDownloadQueue.downloader.debugActiveDownloadCount, 0); + expect(notoDownloadQueue.isPending, false); + }); + + tearDown(() { + expect(notoDownloadQueue.downloader.debugActiveDownloadCount, 0); + expect(notoDownloadQueue.isPending, false); + }); + test('renders using non-recording canvas if weak refs are supported', () async { expect(browserSupportsFinalizationRegistry, isTrue, @@ -212,11 +222,530 @@ 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 { + 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 { + 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 { + 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 - characters from multiple fallback fonts', () async { + await testTextStyle( + 'multi-font characters', + // This character is claimed by multiple fonts. This test makes sure + // we can find a font supporting it. + outerText: '欢', + innerText: '', + ); + }); + + test('text style - symbols', () async { + // One of the CJK fonts loaded in one of the tests above also contains + // some of these symbols. To make sure the test produces predictable + // results we reset the fallback data forcing the engine to reload + // fallbacks, which for this test will only load Noto Symbols. + FontFallbackData.debugReset(); + await testTextStyle( + 'symbols', + outerText: '← ↑ → ↓ ', + innerText: '', + ); + }); + + 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), + ); + }); + + test('sample Chinese text', () async { + await testSampleText( + 'chinese', + '也称乱数假文或者哑元文本, ' + '是印刷及排版领域所常用的虚拟文字。' + '由于曾经一台匿名的打印机刻意打乱了' + '一盒印刷字体从而造出一本字体样品书', + ); + }); + + test('sample Armenian text', () async { + await testSampleText( + 'armenian', + 'տպագրության և տպագրական արդյունաբերության համար նախատեսված մոդելային տեքստ է', + ); + }); + + test('sample Albanian text', () async { + await testSampleText( + 'albanian', + 'është një tekst shabllon i industrisë së printimit dhe shtypshkronjave Lorem Ipsum ka qenë teksti shabllon', + ); + }); + + test('sample Arabic text', () async { + await testSampleText( + 'arabic', + 'هناك حقيقة مثبتة منذ زمن طويل وهي أن المحتوى المقروء لصفحة ما سيلهي', + textDirection: ui.TextDirection.rtl, + ); + }); + + test('sample Bulgarian text', () async { + await testSampleText( + 'bulgarian', + 'е елементарен примерен текст използван в печатарската и типографската индустрия', + ); + }); + + test('sample Catalan text', () async { + await testSampleText( + 'catalan', + 'és un text de farciment usat per la indústria de la tipografia i la impremta', + ); + }); + + test('sample English text', () async { + await testSampleText( + 'english', + 'Lorem Ipsum is simply dummy text of the printing and typesetting industry', + ); + }); + + test('sample Greek text', () async { + await testSampleText( + 'greek', + 'είναι απλά ένα κείμενο χωρίς νόημα για τους επαγγελματίες της τυπογραφίας και στοιχειοθεσίας', + ); + }); + + test('sample Hebrew text', () async { + await testSampleText( + 'hebrew', + 'זוהי עובדה מבוססת שדעתו של הקורא תהיה מוסחת על ידי טקטס קריא כאשר הוא יביט בפריסתו', + textDirection: ui.TextDirection.rtl, + ); + }); + + test('sample Hindi text', () async { + await testSampleText( + 'hindi', + 'छपाई और अक्षर योजन उद्योग का एक साधारण डमी पाठ है सन १५०० के बाद से अभी तक इस उद्योग का मानक डमी पाठ मन गया जब एक अज्ञात मुद्रक ने नमूना लेकर एक नमूना किताब बनाई', + ); + }); + + test('sample Thai text', () async { + await testSampleText( + 'thai', + 'คือ เนื้อหาจำลองแบบเรียบๆ ที่ใช้กันในธุรกิจงานพิมพ์หรืองานเรียงพิมพ์ มันได้กลายมาเป็นเนื้อหาจำลองมาตรฐานของธุรกิจดังกล่าวมาตั้งแต่ศตวรรษที่', + ); + }); + + test('sample Georgian text', () async { + await testSampleText( + 'georgian', + 'საბეჭდი და ტიპოგრაფიული ინდუსტრიის უშინაარსო ტექსტია. იგი სტანდარტად', + ); + }); + + // We've seen text break when we load many fonts simultaneously. This test + // combines text in multiple languages into one long paragraph to make sure + // we can handle it. + test('sample multilingual text', () async { + await testSampleText( + 'multilingual', + '也称乱数假文或者哑元文本, 是印刷及排版领域所常用的虚拟文字。 ' + 'տպագրության և տպագրական արդյունաբերության համար ' + 'është një tekst shabllon i industrisë së printimit ' + ' زمن طويل وهي أن المحتوى المقروء لصفحة ما سيلهي ' + 'е елементарен примерен текст използван в печатарската ' + 'és un text de farciment usat per la indústria de la ' + 'Lorem Ipsum is simply dummy text of the printing ' + 'είναι απλά ένα κείμενο χωρίς νόημα για τους επαγγελματίες ' + ' זוהי עובדה מבוססת שדעתו של הקורא תהיה מוסחת על ידי טקטס קריא ' + 'छपाई और अक्षर योजन उद्योग का एक साधारण डमी पाठ है सन ' + 'คือ เนื้อหาจำลองแบบเรียบๆ ที่ใช้กันในธุรกิจงานพิมพ์หรืองานเรียงพิมพ์ ' + 'საბეჭდი და ტიპოგრაფიული ინდუსტრიის უშინაარსო ტექსტია ', + ); + }); // TODO: https://github.com/flutter/flutter/issues/60040 // TODO: https://github.com/flutter/flutter/issues/71520 }, skip: isIosSafari || isFirefox); } +Future testSampleText(String language, String text, { ui.TextDirection textDirection = ui.TextDirection.ltr, bool write = false }) async { + FontFallbackData.debugReset(); + const double testWidth = 300; + double paragraphHeight = 0; + final CkPicture picture = await generatePictureWhenFontsStable(() { + final CkPictureRecorder recorder = CkPictureRecorder(); + final CkCanvas canvas = recorder.beginRecording(ui.Rect.largest); + final CkParagraphBuilder paragraphBuilder = CkParagraphBuilder(CkParagraphStyle( + textDirection: textDirection, + )); + paragraphBuilder.addText(text); + final CkParagraph paragraph = paragraphBuilder.build(); + paragraph.layout(ui.ParagraphConstraints(width: testWidth - 20)); + canvas.drawParagraph(paragraph, const ui.Offset(10, 10)); + paragraphHeight = paragraph.height; + return recorder.endRecording(); + }); + await matchPictureGolden( + 'canvaskit_sample_text_$language.png', + picture, + region: ui.Rect.fromLTRB(0, 0, testWidth, paragraphHeight + 20), + write: write, + ); +} + +typedef ParagraphFactory = CkParagraph Function(); + void drawTestPicture(CkCanvas canvas) { canvas.clear(ui.Color(0xFFFFFFF)); @@ -547,3 +1076,180 @@ 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. + CkPicture picture = await generatePictureWhenFontsStable(renderPicture); + await matchPictureGolden( + 'canvaskit_text_styles_${name.replaceAll(' ', '_')}.png', + picture, + region: region, + write: write, + ); + expect(notoDownloadQueue.debugIsLoadingFonts, isFalse); + expect(notoDownloadQueue.pendingSubsets, isEmpty); + expect(notoDownloadQueue.downloader.debugActiveDownloadCount, 0); +} + +typedef PictureGenerator = CkPicture Function(); + +Future generatePictureWhenFontsStable(PictureGenerator generator) async { + CkPicture picture = generator(); + // Font downloading begins asynchronously so we inject a timer before checking the download queue. + await Future.delayed(Duration.zero); + while (notoDownloadQueue.isPending || notoDownloadQueue.downloader.debugActiveDownloadCount > 0) { + await notoDownloadQueue.debugWhenIdle(); + await notoDownloadQueue.downloader.debugWhenIdle(); + picture = generator(); + // Dummy timer for the same reason as above. + await Future.delayed(Duration.zero); + } + return picture; +} diff --git a/lib/web_ui/test/canvaskit/canvaskit_api_test.dart b/lib/web_ui/test/canvaskit/canvaskit_api_test.dart index dea114a7b0282..2bc13f045ab8f 100644 --- a/lib/web_ui/test/canvaskit/canvaskit_api_test.dart +++ b/lib/web_ui/test/canvaskit/canvaskit_api_test.dart @@ -1313,7 +1313,7 @@ void _paragraphTests() { expect(paragraph.getIdeographicBaseline(), within(distance: 0.5, from: 28)); expect(paragraph.getLongestLine(), 50); expect(paragraph.getMaxIntrinsicWidth(), 50); - expect(paragraph.getMinIntrinsicWidth(), 0); + expect(paragraph.getMinIntrinsicWidth(), 50); expect(paragraph.getMaxWidth(), 55); expect(paragraph.getRectsForRange(1, 3, canvasKit.RectHeightStyle.Tight, canvasKit.RectWidthStyle.Max), []); expect(paragraph.getRectsForPlaceholders(), hasLength(1)); 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..0c49dcc8d88bd 100644 --- a/lib/web_ui/test/canvaskit/fallback_fonts_golden_test.dart +++ b/lib/web_ui/test/canvaskit/fallback_fonts_golden_test.dart @@ -4,6 +4,7 @@ // @dart = 2.12 import 'dart:async'; +import 'dart:math' as math; import 'dart:typed_data'; import 'package:ui/ui.dart' as ui; @@ -19,7 +20,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 { @@ -44,7 +45,7 @@ void testMain() { notoDownloadQueue.downloader = TestDownloader(); TestDownloader.mockDownloads.clear(); savedCallback = ui.window.onPlatformMessage; - skiaFontCollection.debugResetFallbackFonts(); + FontFallbackData.debugReset(); }); tearDown(() { @@ -52,7 +53,7 @@ void testMain() { }); test('Roboto is always a fallback font', () { - expect(skiaFontCollection.globalFontFallbacks, contains('Roboto')); + expect(FontFallbackData.instance.globalFontFallbacks, contains('Roboto')); }); test('will download Noto Naskh Arabic if Arabic text is added', () async { @@ -87,7 +88,7 @@ void testMain() { } '''; - expect(skiaFontCollection.globalFontFallbacks, ['Roboto']); + expect(FontFallbackData.instance.globalFontFallbacks, ['Roboto']); // Creating this paragraph should cause us to start to download the // fallback font. @@ -98,22 +99,22 @@ void testMain() { await fontChangeCompleter.future; - expect(skiaFontCollection.globalFontFallbacks, + expect(FontFallbackData.instance.globalFontFallbacks, contains('Noto Naskh Arabic UI 0')); final CkPictureRecorder recorder = CkPictureRecorder(); 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,13 +122,77 @@ 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(FontFallbackData.instance.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(FontFallbackData.instance.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[ 'https://fonts.googleapis.com/css2?family=Noto+Naskh+Arabic+UI'] = 'invalid CSS... this should cause our parser to fail'; - expect(skiaFontCollection.globalFontFallbacks, ['Roboto']); + expect(FontFallbackData.instance.globalFontFallbacks, ['Roboto']); // Creating this paragraph should cause us to start to download the // fallback font. @@ -140,7 +205,117 @@ void testMain() { await Future.delayed(Duration.zero); expect(notoDownloadQueue.isPending, isFalse); - expect(skiaFontCollection.globalFontFallbacks, ['Roboto']); + expect(FontFallbackData.instance.globalFontFallbacks, ['Roboto']); + }); + + // Regression test for https://github.com/flutter/flutter/issues/75836 + // When we had this bug our font fallback resolution logic would end up in an + // infinite loop and this test would freeze and time out. + test('Can find fonts for two adjacent unmatched code units from different fonts', () async { + final LoggingDownloader loggingDownloader = LoggingDownloader(NotoDownloader()); + notoDownloadQueue.downloader = loggingDownloader; + // Try rendering text that requires fallback fonts, initially before the fonts are loaded. + + CkParagraphBuilder(CkParagraphStyle()).addText('ヽಠ'); + await notoDownloadQueue.downloader.debugWhenIdle(); + expect( + loggingDownloader.log, + [ + 'https://fonts.googleapis.com/css2?family=Noto+Sans+SC', + 'https://fonts.googleapis.com/css2?family=Noto+Sans+JP', + 'https://fonts.googleapis.com/css2?family=Noto+Sans+Kannada+UI', + 'Noto Sans SC', + 'Noto Sans JP', + 'Noto Sans Kannada UI', + ], + ); + + // Do the same thing but this time with loaded fonts. + loggingDownloader.log.clear(); + CkParagraphBuilder(CkParagraphStyle()).addText('ヽಠ'); + await notoDownloadQueue.downloader.debugWhenIdle(); + expect(loggingDownloader.log, isEmpty); + }); + + test('findMinimumFontsForCodeunits for all supported code units', () async { + final LoggingDownloader loggingDownloader = LoggingDownloader(NotoDownloader()); + notoDownloadQueue.downloader = loggingDownloader; + + // Collect all supported code units from all fallback fonts in the Noto + // font tree. + final Set testedFonts = {}; + final Set supportedUniqueCodeUnits = {}; + final IntervalTree notoTree = FontFallbackData.instance.notoTree; + for (NotoFont font in notoTree.root.enumerateAllElements()) { + testedFonts.add(font.name); + for (CodeunitRange range in font.approximateUnicodeRanges) { + for (int codeUnit = range.start; codeUnit < range.end; codeUnit += 1) { + supportedUniqueCodeUnits.add(codeUnit); + } + } + } + + expect(supportedUniqueCodeUnits.length, greaterThan(10000)); // sanity check + expect(testedFonts, unorderedEquals({ + 'Noto Sans', + 'Noto Sans Malayalam UI', + 'Noto Sans Armenian', + 'Noto Sans Georgian', + 'Noto Sans Hebrew', + 'Noto Naskh Arabic UI', + 'Noto Sans Devanagari UI', + 'Noto Sans Telugu UI', + 'Noto Sans Tamil UI', + 'Noto Sans Kannada UI', + 'Noto Sans Sinhala', + 'Noto Sans Gurmukhi UI', + 'Noto Sans Gujarati UI', + 'Noto Sans Bengali UI', + 'Noto Sans Thai UI', + 'Noto Sans Lao UI', + 'Noto Sans Myanmar UI', + 'Noto Sans Ethiopic', + 'Noto Sans Khmer UI', + 'Noto Sans SC', + 'Noto Sans JP', + 'Noto Sans TC', + 'Noto Sans HK', + 'Noto Sans KR', + 'Noto Sans Egyptian Hieroglyphs', + })); + + // Construct random paragraphs out of supported code units. + final math.Random random = math.Random(0); + final List supportedCodeUnits = supportedUniqueCodeUnits.toList()..shuffle(random); + const int paragraphLength = 3; + + for (int batchStart = 0; batchStart < supportedCodeUnits.length; batchStart += paragraphLength) { + final int batchEnd = math.min(batchStart + paragraphLength, supportedCodeUnits.length); + final Set codeUnits = {}; + for (int i = batchStart; i < batchEnd; i += 1) { + codeUnits.add(supportedCodeUnits[i]); + } + final Set fonts = {}; + for (int codeUnit in codeUnits) { + List fontsForUnit = notoTree.intersections(codeUnit); + + // All code units are extracted from the same tree, so there must + // be at least one font supporting each code unit + expect(fontsForUnit, isNotEmpty); + fonts.addAll(fontsForUnit); + } + + try { + findMinimumFontsForCodeUnits(codeUnits, fonts); + } catch (e) { + print( + 'findMinimumFontsForCodeunits failed:\n' + ' Code units: ${codeUnits.join(', ')}\n' + ' Fonts: ${fonts.map((f) => f.name).join(', ')}', + ); + rethrow; + } + } }); // TODO: https://github.com/flutter/flutter/issues/60040 }, skip: isIosSafari); @@ -149,7 +324,7 @@ void testMain() { class TestDownloader extends NotoDownloader { static final Map mockDownloads = {}; @override - Future downloadAsString(String url) async { + Future downloadAsString(String url, {String? debugDescription}) async { if (mockDownloads.containsKey(url)) { return mockDownloads[url]!; } else { @@ -157,3 +332,31 @@ class TestDownloader extends NotoDownloader { } } } + +class LoggingDownloader implements NotoDownloader { + final List log = []; + + LoggingDownloader(this.delegate); + + final NotoDownloader delegate; + + @override + Future debugWhenIdle() { + return delegate.debugWhenIdle(); + } + + @override + Future downloadAsBytes(String url, {String? debugDescription}) { + log.add(debugDescription ?? url); + return delegate.downloadAsBytes(url); + } + + @override + Future downloadAsString(String url, {String? debugDescription}) { + log.add(debugDescription ?? url); + return delegate.downloadAsString(url); + } + + @override + int get debugActiveDownloadCount => delegate.debugActiveDownloadCount; +} diff --git a/lib/web_ui/test/canvaskit/path_test.dart b/lib/web_ui/test/canvaskit/path_test.dart index 151323f5303eb..5c5072ce0c8ae 100644 --- a/lib/web_ui/test/canvaskit/path_test.dart +++ b/lib/web_ui/test/canvaskit/path_test.dart @@ -157,6 +157,25 @@ void testMain() { expect(measure1.contourIndex, 1); expect(measure1.extractPath(0, 15).getBounds(), ui.Rect.fromLTRB(20, 20, 30, 25)); }); + + test('Path.from', () { + final ui.Rect rect1 = ui.Rect.fromLTRB(0, 0, 10, 10); + final ui.Rect rect2 = ui.Rect.fromLTRB(10, 10, 20, 20); + + final ui.Path original = ui.Path(); + original.addRect(rect1); + expect(original, isA()); + expect(original.getBounds(), rect1); + + final ui.Path copy = ui.Path.from(original); + expect(copy, isA()); + expect(copy.getBounds(), rect1); + + // Test that when copy is mutated, the original is not affected + copy.addRect(rect2); + expect(original.getBounds(), rect1); + expect(copy.getBounds(), rect1.expandToInclude(rect2)); + }); }, skip: isIosSafari); // TODO: https://github.com/flutter/flutter/issues/60040 diff --git a/lib/web_ui/test/canvaskit/surface_test.dart b/lib/web_ui/test/canvaskit/surface_test.dart index b5c98bae3661c..ddf45c1f9c926 100644 --- a/lib/web_ui/test/canvaskit/surface_test.dart +++ b/lib/web_ui/test/canvaskit/surface_test.dart @@ -27,10 +27,14 @@ void testMain() { // Expect exact requested dimensions. expect(original.width(), 9); expect(original.height(), 19); + expect(surface.htmlCanvas!.style.width, '9px'); + expect(surface.htmlCanvas!.style.height, '19px'); // Shrinking reuses the existing surface straight-up. final CkSurface shrunk = surface.acquireFrame(ui.Size(5, 15)).skiaSurface; expect(shrunk, same(original)); + expect(surface.htmlCanvas!.style.width, '9px'); + expect(surface.htmlCanvas!.style.height, '19px'); // The first increase will allocate a new surface, but will overallocate // by 40% to accommodate future increases. @@ -40,6 +44,8 @@ void testMain() { // Expect overallocated dimensions expect(firstIncrease.width(), 14); expect(firstIncrease.height(), 28); + expect(surface.htmlCanvas!.style.width, '14px'); + expect(surface.htmlCanvas!.style.height, '28px'); // Subsequent increases within 40% reuse the old surface. final CkSurface secondIncrease = surface.acquireFrame(ui.Size(11, 22)).skiaSurface; @@ -52,6 +58,8 @@ void testMain() { // Also over-allocated expect(huge.width(), 28); expect(huge.height(), 56); + expect(surface.htmlCanvas!.style.width, '28px'); + expect(surface.htmlCanvas!.style.height, '56px'); // Shrink again. Reuse the last allocated surface. final CkSurface shrunk2 = surface.acquireFrame(ui.Size(5, 15)).skiaSurface; @@ -88,5 +96,34 @@ void testMain() { // Firefox doesn't have the WEBGL_lose_context extension. skip: isFirefox || isIosSafari, ); + + // Regression test for https://github.com/flutter/flutter/issues/75286 + test('updates canvas logical size when device-pixel ratio changes', () { + final Surface surface = Surface(HtmlViewEmbedder()); + final CkSurface original = surface.acquireFrame(ui.Size(10, 16)).skiaSurface; + + expect(original.width(), 10); + expect(original.height(), 16); + expect(surface.htmlCanvas!.style.width, '10px'); + expect(surface.htmlCanvas!.style.height, '16px'); + + // Increase device-pixel ratio: this makes CSS pixels bigger, so we need + // fewer of them to cover the browser window. + window.debugOverrideDevicePixelRatio(2.0); + final CkSurface highDpr = surface.acquireFrame(ui.Size(10, 16)).skiaSurface; + expect(highDpr.width(), 10); + expect(highDpr.height(), 16); + expect(surface.htmlCanvas!.style.width, '5px'); + expect(surface.htmlCanvas!.style.height, '8px'); + + // Decrease device-pixel ratio: this makes CSS pixels smaller, so we need + // more of them to cover the browser window. + window.debugOverrideDevicePixelRatio(0.5); + final CkSurface lowDpr = surface.acquireFrame(ui.Size(10, 16)).skiaSurface; + expect(lowDpr.width(), 10); + expect(lowDpr.height(), 16); + expect(surface.htmlCanvas!.style.width, '20px'); + expect(surface.htmlCanvas!.style.height, '32px'); + }); }, skip: isIosSafari); } diff --git a/lib/web_ui/test/engine/pointer_binding_test.dart b/lib/web_ui/test/engine/pointer_binding_test.dart index 693f43cc34994..8aa7efd4f2edc 100644 --- a/lib/web_ui/test/engine/pointer_binding_test.dart +++ b/lib/web_ui/test/engine/pointer_binding_test.dart @@ -6,6 +6,7 @@ import 'dart:html' as html; import 'dart:js_util' as js_util; +import 'package:meta/meta.dart'; import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; @@ -596,18 +597,16 @@ void testMain() { packets.add(packet); }; - glassPane.dispatchEvent(html.WheelEvent( - 'wheel', - button: 1, + glassPane.dispatchEvent(context.wheel( + buttons: 0, clientX: 10, clientY: 10, deltaX: 10, deltaY: 10, )); - glassPane.dispatchEvent(html.WheelEvent( - 'wheel', - button: 1, + glassPane.dispatchEvent(context.wheel( + buttons: 0, clientX: 20, clientY: 50, deltaX: 10, @@ -621,9 +620,8 @@ void testMain() { clientY: 50.0, )); - glassPane.dispatchEvent(html.WheelEvent( - 'wheel', - button: 1, + glassPane.dispatchEvent(context.wheel( + buttons: 1, clientX: 30, clientY: 60, deltaX: 10, @@ -1485,6 +1483,8 @@ void testMain() { expect(packets[0].data[0].change, equals(ui.PointerChange.move)); expect(packets[0].data[0].synthesized, equals(true)); expect(packets[0].data[0].buttons, equals(2)); + expect(packets[0].data[0].physicalX, equals(20.0 * dpi)); + expect(packets[0].data[0].physicalY, equals(20.0 * dpi)); expect(packets[0].data[1].change, equals(ui.PointerChange.up)); expect(packets[0].data[1].synthesized, equals(false)); expect(packets[0].data[1].buttons, equals(0)); @@ -1661,6 +1661,90 @@ void testMain() { }, ); + _testEach<_ButtonedEventMixin>( + [ + _PointerEventContext(), + _MouseEventContext(), + ], + 'handles overlapping left/right down and up events', + (_ButtonedEventMixin context) { + PointerBinding.instance.debugOverrideDetector(context); + // This can happen with the following gesture sequence: + // + // LMB: down-------------------up + // RMB: down------------------up + // Flutter: down-------move-------move-------up + + List packets = []; + ui.window.onPointerDataPacket = (ui.PointerDataPacket packet) { + packets.add(packet); + }; + + // Press and hold LMB. + glassPane.dispatchEvent(context.mouseDown( + button: 0, + buttons: 1, + clientX: 5.0, + clientY: 100.0, + )); + expect(packets, hasLength(1)); + expect(packets[0].data, hasLength(2)); + expect(packets[0].data[0].change, equals(ui.PointerChange.add)); + expect(packets[0].data[0].synthesized, equals(true)); + expect(packets[0].data[1].change, equals(ui.PointerChange.down)); + expect(packets[0].data[1].synthesized, equals(false)); + expect(packets[0].data[1].buttons, equals(1)); + expect(packets[0].data[1].physicalX, equals(5.0 * dpi)); + expect(packets[0].data[1].physicalY, equals(100.0 * dpi)); + packets.clear(); + + // Press and hold RMB. The pointer is already down, so we only send a move + // to update the position of the pointer. + glassPane.dispatchEvent(context.mouseDown( + button: 2, + buttons: 3, + clientX: 20.0, + clientY: 100.0, + )); + expect(packets, hasLength(1)); + expect(packets[0].data, hasLength(1)); + expect(packets[0].data[0].change, equals(ui.PointerChange.move)); + expect(packets[0].data[0].buttons, equals(3)); + expect(packets[0].data[0].physicalX, equals(20.0 * dpi)); + expect(packets[0].data[0].physicalY, equals(100.0 * dpi)); + packets.clear(); + + // Release LMB. The pointer is still down (RMB), so we only send a move to + // update the position of the pointer. + glassPane.dispatchEvent(context.mouseUp( + button: 0, + buttons: 2, + clientX: 30.0, + clientY: 100.0, + )); + expect(packets, hasLength(1)); + expect(packets[0].data, hasLength(1)); + expect(packets[0].data[0].change, equals(ui.PointerChange.move)); + expect(packets[0].data[0].buttons, equals(2)); + expect(packets[0].data[0].physicalX, equals(30.0 * dpi)); + expect(packets[0].data[0].physicalY, equals(100.0 * dpi)); + packets.clear(); + + // Release RMB. There's no more buttons down, so we send an up event. + glassPane.dispatchEvent(context.mouseUp( + button: 2, + buttons: 0, + clientX: 30.0, + clientY: 100.0, + )); + expect(packets, hasLength(1)); + expect(packets[0].data, hasLength(1)); + expect(packets[0].data[0].change, equals(ui.PointerChange.up)); + expect(packets[0].data[0].buttons, equals(0)); + packets.clear(); + }, + ); + _testEach<_ButtonedEventMixin>( [ if (!isIosSafari) _PointerEventContext(), @@ -2141,7 +2225,7 @@ mixin _ButtonedEventMixin on _BasicEventContext { {double clientX, double clientY, int button, int buttons}); // Generate an event that releases all mouse buttons. - html.Event mouseUp({double clientX, double clientY, int button}); + html.Event mouseUp({double clientX, double clientY, int button, int buttons}); html.Event hover({double clientX, double clientY}) { return mouseMove( @@ -2180,6 +2264,27 @@ mixin _ButtonedEventMixin on _BasicEventContext { clientY: clientY, ); } + + html.Event wheel({ + @required int buttons, + @required double clientX, + @required double clientY, + @required double deltaX, + @required double deltaY, + }) { + final Function jsWheelEvent = js_util.getProperty(html.window, 'WheelEvent'); + final List eventArgs = [ + 'wheel', + { + 'buttons': buttons, + 'clientX': clientX, + 'clientY': clientY, + 'deltaX': deltaX, + 'deltaY': deltaY, + } + ]; + return js_util.callConstructor(jsWheelEvent, js_util.jsify(eventArgs)); + } } class _TouchDetails { @@ -2362,10 +2467,10 @@ class _MouseEventContext extends _BasicEventContext } @override - html.Event mouseUp({double clientX, double clientY, int button}) { + html.Event mouseUp({double clientX, double clientY, int button, int buttons}) { return _createMouseEvent( 'mouseup', - buttons: 0, + buttons: buttons, button: button, clientX: clientX, clientY: clientY, @@ -2518,10 +2623,11 @@ class _PointerEventContext extends _BasicEventContext } @override - html.Event mouseUp({double clientX, double clientY, int button}) { + html.Event mouseUp({double clientX, double clientY, int button, int buttons}) { return _upWithFullDetails( pointer: 1, button: button, + buttons: buttons, clientX: clientX, clientY: clientY, pointerType: 'mouse', @@ -2532,12 +2638,13 @@ class _PointerEventContext extends _BasicEventContext {double clientX, double clientY, int button, + int buttons, int pointer, String pointerType}) { return html.PointerEvent('pointerup', { 'pointerId': pointer, 'button': button, - 'buttons': 0, + 'buttons': buttons, 'clientX': clientX, 'clientY': clientY, 'pointerType': pointerType, 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..a1764aa33ddd8 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 @@ -50,9 +50,7 @@ void testMain() async { 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); + fillPlaceholder(canvas, offset, paragraph); offset = offset.translate(0.0, paragraph.height + 30.0); } @@ -86,13 +84,123 @@ void testMain() async { 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); + fillPlaceholder(canvas, offset, paragraph); offset = offset.translate(0.0, paragraph.height + 30.0); } 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. + fillPlaceholder(canvas, offset, paragraph); + + offset = offset.translate(0.0, paragraph.height + 30.0); + } + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_placeholders_align_dom'); + }); + + test('draws paragraphs starting or ending with a placeholder', () { + const Rect bounds = Rect.fromLTWH(0, 0, 420, 300); + final canvas = BitmapCanvas(bounds, RenderStrategy()); + + Offset offset = Offset(10, 10); + + // First paragraph with a placeholder at the beginning. + final CanvasParagraph paragraph1 = rich( + ParagraphStyle(fontFamily: 'Roboto', fontSize: 24.0, textAlign: TextAlign.center), + (builder) { + builder.addPlaceholder(80.0, 50.0, PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic); + builder.pushStyle(TextStyle(color: black)); + builder.addText(' Lorem ipsum.'); + }, + )..layout(constrain(400.0)); + + // Draw the paragraph. + canvas.drawParagraph(paragraph1, offset); + fillPlaceholder(canvas, offset, paragraph1); + surroundParagraph(canvas, offset, paragraph1); + + offset = offset.translate(0.0, paragraph1.height + 30.0); + + // Second paragraph with a placeholder at the end. + final CanvasParagraph paragraph2 = rich( + ParagraphStyle(fontFamily: 'Roboto', fontSize: 24.0, textAlign: TextAlign.center), + (builder) { + builder.pushStyle(TextStyle(color: black)); + builder.addText('Lorem ipsum '); + builder.addPlaceholder(80.0, 50.0, PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic); + }, + )..layout(constrain(400.0)); + + // Draw the paragraph. + canvas.drawParagraph(paragraph2, offset); + fillPlaceholder(canvas, offset, paragraph2); + surroundParagraph(canvas, offset, paragraph2); + + offset = offset.translate(0.0, paragraph2.height + 30.0); + + // Third paragraph with a placeholder alone in the second line. + final CanvasParagraph paragraph3 = rich( + ParagraphStyle(fontFamily: 'Roboto', fontSize: 24.0, textAlign: TextAlign.center), + (builder) { + builder.pushStyle(TextStyle(color: black)); + builder.addText('Lorem ipsum '); + builder.addPlaceholder(80.0, 50.0, PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic); + }, + )..layout(constrain(200.0)); + + // Draw the paragraph. + canvas.drawParagraph(paragraph3, offset); + fillPlaceholder(canvas, offset, paragraph3); + surroundParagraph(canvas, offset, paragraph3); + + return takeScreenshot(canvas, bounds, 'canvas_paragraph_placeholders_start_and_end'); + }); +} + +void surroundParagraph( + EngineCanvas canvas, + Offset offset, + CanvasParagraph paragraph, +) { + final Rect rect = offset & Size(paragraph.width, paragraph.height); + final SurfacePaint paint = Paint()..color = blue..style = PaintingStyle.stroke; + canvas.drawRect(rect, paint.paintData); +} + +void fillPlaceholder( + EngineCanvas canvas, + Offset offset, + CanvasParagraph paragraph, +) { + final TextBox placeholderBox = paragraph.getBoxesForPlaceholders().single; + final SurfacePaint paint = Paint()..color = red; + canvas.drawRect(placeholderBox.toRect().shift(offset), paint.paintData); } 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/lib/web_ui/test/text/layout_service_rich_test.dart b/lib/web_ui/test/text/layout_service_rich_test.dart index d6b9df01e9bb9..8ed16095a2692 100644 --- a/lib/web_ui/test/text/layout_service_rich_test.dart +++ b/lib/web_ui/test/text/layout_service_rich_test.dart @@ -149,4 +149,64 @@ void testMain() async { l('ipsum', 6, 11, hardBreak: true, width: 50.0, left: 0.0), ]); }); + + test('should handle placeholder-only paragraphs', () { + final EngineParagraphStyle paragraphStyle = EngineParagraphStyle( + fontFamily: 'ahem', + fontSize: 10, + textAlign: ui.TextAlign.center, + ); + final CanvasParagraph paragraph = rich(paragraphStyle, (builder) { + builder.addPlaceholder(300.0, 50.0, ui.PlaceholderAlignment.baseline, baseline: ui.TextBaseline.alphabetic); + })..layout(constrain(500.0)); + + expect(paragraph.maxIntrinsicWidth, 300.0); + expect(paragraph.minIntrinsicWidth, 300.0); + expect(paragraph.height, 50.0); + expectLines(paragraph, [ + l('', 0, 0, hardBreak: true, width: 300.0, left: 100.0), + ]); + }); + + test('correct maxIntrinsicWidth when paragraph ends with placeholder', () { + final EngineParagraphStyle paragraphStyle = EngineParagraphStyle( + fontFamily: 'ahem', + fontSize: 10, + textAlign: ui.TextAlign.center, + ); + final CanvasParagraph paragraph = rich(paragraphStyle, (builder) { + builder.addText('abcd'); + builder.addPlaceholder(300.0, 50.0, ui.PlaceholderAlignment.bottom); + })..layout(constrain(400.0)); + + expect(paragraph.maxIntrinsicWidth, 340.0); + expect(paragraph.minIntrinsicWidth, 300.0); + expect(paragraph.height, 50.0); + expectLines(paragraph, [ + l('abcd', 0, 4, hardBreak: true, width: 340.0, left: 30.0), + ]); + }); + + test('handles new line followed by a placeholder', () { + final EngineParagraphStyle paragraphStyle = EngineParagraphStyle( + fontFamily: 'ahem', + fontSize: 10, + textAlign: ui.TextAlign.center, + ); + final CanvasParagraph paragraph = rich(paragraphStyle, (builder) { + builder.addText('Lorem\n'); + builder.addPlaceholder(300.0, 40.0, ui.PlaceholderAlignment.bottom); + builder.addText('ipsum'); + })..layout(constrain(300.0)); + + // The placeholder's width + "ipsum" + expect(paragraph.maxIntrinsicWidth, 300.0 + 50.0); + expect(paragraph.minIntrinsicWidth, 300.0); + expect(paragraph.height, 10.0 + 40.0 + 10.0); + expectLines(paragraph, [ + l('Lorem', 0, 6, hardBreak: true, width: 50.0, height: 10.0, left: 125.0), + l('', 6, 6, hardBreak: false, width: 300.0, height: 40.0, left: 0.0), + l('ipsum', 6, 11, hardBreak: true, width: 50.0, height: 10.0, left: 125.0), + ]); + }); } diff --git a/lib/web_ui/test/text_editing_test.dart b/lib/web_ui/test/text_editing_test.dart index efcf24df89585..d573b1dbcacac 100644 --- a/lib/web_ui/test/text_editing_test.dart +++ b/lib/web_ui/test/text_editing_test.dart @@ -2095,6 +2095,24 @@ void testMain() { ); }); + test('Fix flipped base and extent offsets', () { + expect( + EditingState(baseOffset: 10, extentOffset: 4), + EditingState(baseOffset: 4, extentOffset: 10), + ); + + expect( + EditingState.fromFrameworkMessage({ + 'selectionBase': 10, + 'selectionExtent': 4, + }), + EditingState.fromFrameworkMessage({ + 'selectionBase': 4, + 'selectionExtent': 10, + }), + ); + }); + test('Configure input element from the editing state', () { final InputElement input = document.getElementsByTagName('input')[0]; _editingState = diff --git a/runtime/runtime_controller.cc b/runtime/runtime_controller.cc index 42a6ea6bc9d67..06cf0f16cc103 100644 --- a/runtime/runtime_controller.cc +++ b/runtime/runtime_controller.cc @@ -392,7 +392,8 @@ bool RuntimeController::LaunchRootIsolate( dart_entrypoint, // dart_entrypoint_library, // std::move(isolate_configuration), // - volatile_path_tracker_ // + volatile_path_tracker_, // + spawning_isolate_.lock().get() // ) .lock(); @@ -438,6 +439,17 @@ std::optional RuntimeController::GetRootIsolateReturnCode() { return root_isolate_return_code_; } +uint64_t RuntimeController::GetRootIsolateGroup() const { + auto isolate = root_isolate_.lock(); + if (isolate) { + auto isolate_scope = tonic::DartIsolateScope(isolate->isolate()); + Dart_IsolateGroup isolate_group = Dart_CurrentIsolateGroup(); + return reinterpret_cast(isolate_group); + } else { + return 0; + } +} + void RuntimeController::LoadDartDeferredLibrary( intptr_t loading_unit_id, std::unique_ptr snapshot_data, diff --git a/runtime/runtime_controller.h b/runtime/runtime_controller.h index 09ff677814e17..4ae96f9512e16 100644 --- a/runtime/runtime_controller.h +++ b/runtime/runtime_controller.h @@ -494,6 +494,14 @@ class RuntimeController : public PlatformConfigurationClient { /// std::optional GetRootIsolateReturnCode(); + //---------------------------------------------------------------------------- + /// @brief Get an identifier that represents the Dart isolate group the + /// root isolate is in. + /// + /// @return The root isolate isolate group identifier, zero if one can't + /// be established. + uint64_t GetRootIsolateGroup() const; + //-------------------------------------------------------------------------- /// @brief Loads the Dart shared library into the Dart VM. When the /// Dart library is loaded successfully, the Dart future diff --git a/shell/common/engine.h b/shell/common/engine.h index a5581611e5c31..29c687693089c 100644 --- a/shell/common/engine.h +++ b/shell/common/engine.h @@ -871,6 +871,13 @@ class Engine final : public RuntimeDelegate, const std::string error_message, bool transient); + //-------------------------------------------------------------------------- + /// @brief Accessor for the RuntimeController. + /// + const RuntimeController* GetRuntimeController() const { + return runtime_controller_.get(); + } + private: Engine::Delegate& delegate_; const Settings settings_; diff --git a/shell/common/rasterizer.cc b/shell/common/rasterizer.cc index 0c671b8534efa..b025dfc464cbf 100644 --- a/shell/common/rasterizer.cc +++ b/shell/common/rasterizer.cc @@ -151,13 +151,13 @@ void Rasterizer::DrawLastLayerTree() { DrawToSurface(*last_layer_tree_); } -void Rasterizer::Draw(fml::RefPtr> pipeline, +RasterStatus Rasterizer::Draw(fml::RefPtr> pipeline, LayerTreeDiscardCallback discardCallback) { TRACE_EVENT0("flutter", "GPURasterizer::Draw"); if (raster_thread_merger_ && !raster_thread_merger_->IsOnRasterizingThread()) { // we yield and let this frame be serviced on the right thread. - return; + return RasterStatus::kYielded; } FML_DCHECK(delegate_.GetTaskRunners() .GetRasterTaskRunner() @@ -211,6 +211,8 @@ void Rasterizer::Draw(fml::RefPtr> pipeline, default: break; } + + return raster_status; } namespace { @@ -342,6 +344,8 @@ RasterStatus Rasterizer::DoDraw( raster_status == RasterStatus::kSkipAndRetry) { resubmitted_layer_tree_ = std::move(layer_tree); return raster_status; + } else if (raster_status == RasterStatus::kDiscarded) { + return raster_status; } if (persistent_cache->IsDumpingSkp() && @@ -418,10 +422,33 @@ RasterStatus Rasterizer::DrawToSurface(flutter::LayerTree& layer_tree) { TRACE_EVENT0("flutter", "Rasterizer::DrawToSurface"); FML_DCHECK(surface_); - // There is no way for the compositor to know how long the layer tree - // construction took. Fortunately, the layer tree does. Grab that time - // for instrumentation. - compositor_context_->ui_time().SetLapTime(layer_tree.build_time()); + RasterStatus raster_status; + if (surface_->AllowsDrawingWhenGpuDisabled()) { + raster_status = DrawToSurfaceUnsafe(frame_timings_recorder, layer_tree); + } else { + delegate_.GetIsGpuDisabledSyncSwitch()->Execute( + fml::SyncSwitch::Handlers() + .SetIfTrue([&] { raster_status = RasterStatus::kDiscarded; }) + .SetIfFalse([&] { + raster_status = + DrawToSurfaceUnsafe(frame_timings_recorder, layer_tree); + })); + } + + return raster_status; +} + +/// Unsafe because it assumes we have access to the GPU which isn't the case +/// when iOS is backgrounded, for example. +/// \see Rasterizer::DrawToSurface +RasterStatus Rasterizer::DrawToSurfaceUnsafe( + FrameTimingsRecorder& frame_timings_recorder, + flutter::LayerTree& layer_tree) { + TRACE_EVENT0("flutter", "Rasterizer::DrawToSurfaceUnsafe"); + FML_DCHECK(surface_); + + compositor_context_->ui_time().SetLapTime( + frame_timings_recorder.GetBuildDuration()); SkCanvas* embedder_root_canvas = nullptr; if (external_view_embedder_) { @@ -480,9 +507,8 @@ RasterStatus Rasterizer::DrawToSurface(flutter::LayerTree& layer_tree) { if (external_view_embedder_ && (!raster_thread_merger_ || raster_thread_merger_->IsMerged())) { FML_DCHECK(!frame->IsSubmitted()); - external_view_embedder_->SubmitFrame( - surface_->GetContext(), std::move(frame), - delegate_.GetIsGpuDisabledSyncSwitch()); + external_view_embedder_->SubmitFrame(surface_->GetContext(), + std::move(frame)); } else { frame->Submit(); } diff --git a/shell/common/rasterizer.h b/shell/common/rasterizer.h index b853e6628e4f8..c5348ec4ec4d7 100644 --- a/shell/common/rasterizer.h +++ b/shell/common/rasterizer.h @@ -241,7 +241,7 @@ class Rasterizer final : public SnapshotDelegate { /// @param[in] discardCallback if specified and returns true, the layer tree /// is discarded instead of being rendered /// - void Draw(fml::RefPtr> pipeline, + RasterStatus Draw(fml::RefPtr> pipeline, LayerTreeDiscardCallback discardCallback = NoDiscard); //---------------------------------------------------------------------------- @@ -482,6 +482,9 @@ class Rasterizer final : public SnapshotDelegate { RasterStatus DrawToSurface(flutter::LayerTree& layer_tree); + RasterStatus DrawToSurfaceUnsafe(FrameTimingsRecorder& frame_timings_recorder, + flutter::LayerTree& layer_tree); + void FireNextFrameCallbackIfPresent(); static bool NoDiscard(const flutter::LayerTree& layer_tree) { return false; } diff --git a/shell/common/rasterizer_unittests.cc b/shell/common/rasterizer_unittests.cc index 3098a19256533..c3fae9ec315f3 100644 --- a/shell/common/rasterizer_unittests.cc +++ b/shell/common/rasterizer_unittests.cc @@ -37,6 +37,7 @@ class MockSurface : public Surface { MOCK_METHOD0(GetExternalViewEmbedder, ExternalViewEmbedder*()); MOCK_METHOD0(MakeRenderContextCurrent, std::unique_ptr()); MOCK_METHOD0(ClearRenderContext, bool()); + MOCK_CONST_METHOD0(AllowsDrawingWhenGpuDisabled, bool()); }; class MockExternalViewEmbedder : public ExternalViewEmbedder { @@ -55,11 +56,9 @@ class MockExternalViewEmbedder : public ExternalViewEmbedder { fml::RefPtr raster_thread_merger)); MOCK_METHOD0(GetCurrentCanvases, std::vector()); MOCK_METHOD1(CompositeEmbeddedView, SkCanvas*(int view_id)); - MOCK_METHOD3( - SubmitFrame, - void(GrDirectContext* context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch)); + MOCK_METHOD2(SubmitFrame, + void(GrDirectContext* context, + std::unique_ptr frame)); MOCK_METHOD2(EndFrame, void(bool should_resubmit_frame, fml::RefPtr raster_thread_merger)); @@ -122,6 +121,7 @@ TEST(RasterizerTest, auto surface_frame = std::make_unique( /*surface=*/nullptr, /*supports_readback=*/true, /*submit_callback=*/[](const SurfaceFrame&, SkCanvas*) { return true; }); + EXPECT_CALL(*surface, AllowsDrawingWhenGpuDisabled()).WillOnce(Return(true)); EXPECT_CALL(*surface, AcquireFrame(SkISize())) .WillOnce(Return(ByMove(std::move(surface_frame)))); @@ -180,6 +180,7 @@ TEST( auto surface_frame = std::make_unique( /*surface=*/nullptr, /*supports_readback=*/true, /*submit_callback=*/[](const SurfaceFrame&, SkCanvas*) { return true; }); + EXPECT_CALL(*surface, AllowsDrawingWhenGpuDisabled()).WillOnce(Return(true)); EXPECT_CALL(*surface, AcquireFrame(SkISize())) .WillOnce(Return(ByMove(std::move(surface_frame)))); @@ -238,6 +239,7 @@ TEST( auto surface_frame = std::make_unique( /*surface=*/nullptr, /*supports_readback=*/true, /*submit_callback=*/[](const SurfaceFrame&, SkCanvas*) { return true; }); + EXPECT_CALL(*surface, AllowsDrawingWhenGpuDisabled()).WillOnce(Return(true)); EXPECT_CALL(*surface, AcquireFrame(SkISize())) .WillOnce(Return(ByMove(std::move(surface_frame)))); EXPECT_CALL(*external_view_embedder, SupportsDynamicThreadMerging) @@ -299,4 +301,200 @@ TEST(RasterizerTest, externalViewEmbedderDoesntEndFrameWhenNoSurfaceIsSet) { }); latch.Wait(); } + +TEST(RasterizerTest, + drawWithGpuEnabledAndSurfaceAllowsDrawingWhenGpuDisabledDoesAcquireFrame) { + std::string test_name = + ::testing::UnitTest::GetInstance()->current_test_info()->name(); + ThreadHost thread_host("io.flutter.test." + test_name + ".", + ThreadHost::Type::Platform | ThreadHost::Type::RASTER | + ThreadHost::Type::IO | ThreadHost::Type::UI); + TaskRunners task_runners("test", thread_host.platform_thread->GetTaskRunner(), + thread_host.raster_thread->GetTaskRunner(), + thread_host.ui_thread->GetTaskRunner(), + thread_host.io_thread->GetTaskRunner()); + MockDelegate delegate; + EXPECT_CALL(delegate, GetTaskRunners()) + .WillRepeatedly(ReturnRef(task_runners)); + EXPECT_CALL(delegate, OnFrameRasterized(_)); + + auto rasterizer = std::make_unique(delegate); + auto surface = std::make_unique(); + auto is_gpu_disabled_sync_switch = + std::make_shared(false); + + auto surface_frame = std::make_unique( + /*surface=*/nullptr, /*supports_readback=*/true, + /*submit_callback=*/[](const SurfaceFrame&, SkCanvas*) { return true; }); + EXPECT_CALL(*surface, AllowsDrawingWhenGpuDisabled()).WillOnce(Return(true)); + ON_CALL(delegate, GetIsGpuDisabledSyncSwitch()) + .WillByDefault(Return(is_gpu_disabled_sync_switch)); + EXPECT_CALL(delegate, GetIsGpuDisabledSyncSwitch()).Times(0); + EXPECT_CALL(*surface, AcquireFrame(SkISize())) + .WillOnce(Return(ByMove(std::move(surface_frame)))); + EXPECT_CALL(*surface, MakeRenderContextCurrent()) + .WillOnce(Return(ByMove(std::make_unique(true)))); + + rasterizer->Setup(std::move(surface)); + fml::AutoResetWaitableEvent latch; + thread_host.raster_thread->GetTaskRunner()->PostTask([&] { + auto pipeline = std::make_shared>(/*depth=*/10); + auto layer_tree = std::make_unique(/*frame_size=*/SkISize(), + /*device_pixel_ratio=*/2.0f); + bool result = pipeline->Produce().Complete(std::move(layer_tree)); + EXPECT_TRUE(result); + auto no_discard = [](LayerTree&) { return false; }; + rasterizer->Draw(CreateFinishedBuildRecorder(), pipeline, no_discard); + latch.Signal(); + }); + latch.Wait(); +} + +TEST( + RasterizerTest, + drawWithGpuDisabledAndSurfaceAllowsDrawingWhenGpuDisabledDoesAcquireFrame) { + std::string test_name = + ::testing::UnitTest::GetInstance()->current_test_info()->name(); + ThreadHost thread_host("io.flutter.test." + test_name + ".", + ThreadHost::Type::Platform | ThreadHost::Type::RASTER | + ThreadHost::Type::IO | ThreadHost::Type::UI); + TaskRunners task_runners("test", thread_host.platform_thread->GetTaskRunner(), + thread_host.raster_thread->GetTaskRunner(), + thread_host.ui_thread->GetTaskRunner(), + thread_host.io_thread->GetTaskRunner()); + MockDelegate delegate; + EXPECT_CALL(delegate, GetTaskRunners()) + .WillRepeatedly(ReturnRef(task_runners)); + EXPECT_CALL(delegate, OnFrameRasterized(_)); + auto rasterizer = std::make_unique(delegate); + auto surface = std::make_unique(); + auto is_gpu_disabled_sync_switch = + std::make_shared(true); + + auto surface_frame = std::make_unique( + /*surface=*/nullptr, /*supports_readback=*/true, + /*submit_callback=*/[](const SurfaceFrame&, SkCanvas*) { return true; }); + EXPECT_CALL(*surface, AllowsDrawingWhenGpuDisabled()).WillOnce(Return(true)); + ON_CALL(delegate, GetIsGpuDisabledSyncSwitch()) + .WillByDefault(Return(is_gpu_disabled_sync_switch)); + EXPECT_CALL(delegate, GetIsGpuDisabledSyncSwitch()).Times(0); + EXPECT_CALL(*surface, AcquireFrame(SkISize())) + .WillOnce(Return(ByMove(std::move(surface_frame)))); + EXPECT_CALL(*surface, MakeRenderContextCurrent()) + .WillOnce(Return(ByMove(std::make_unique(true)))); + + rasterizer->Setup(std::move(surface)); + fml::AutoResetWaitableEvent latch; + thread_host.raster_thread->GetTaskRunner()->PostTask([&] { + auto pipeline = std::make_shared>(/*depth=*/10); + auto layer_tree = std::make_unique(/*frame_size=*/SkISize(), + /*device_pixel_ratio=*/2.0f); + bool result = pipeline->Produce().Complete(std::move(layer_tree)); + EXPECT_TRUE(result); + auto no_discard = [](LayerTree&) { return false; }; + RasterStatus status = + rasterizer->Draw(CreateFinishedBuildRecorder(), pipeline, no_discard); + EXPECT_EQ(status, RasterStatus::kSuccess); + latch.Signal(); + }); + latch.Wait(); +} + +TEST( + RasterizerTest, + drawWithGpuEnabledAndSurfaceDisallowsDrawingWhenGpuDisabledDoesAcquireFrame) { + std::string test_name = + ::testing::UnitTest::GetInstance()->current_test_info()->name(); + ThreadHost thread_host("io.flutter.test." + test_name + ".", + ThreadHost::Type::Platform | ThreadHost::Type::RASTER | + ThreadHost::Type::IO | ThreadHost::Type::UI); + TaskRunners task_runners("test", thread_host.platform_thread->GetTaskRunner(), + thread_host.raster_thread->GetTaskRunner(), + thread_host.ui_thread->GetTaskRunner(), + thread_host.io_thread->GetTaskRunner()); + MockDelegate delegate; + EXPECT_CALL(delegate, GetTaskRunners()) + .WillRepeatedly(ReturnRef(task_runners)); + EXPECT_CALL(delegate, OnFrameRasterized(_)); + auto rasterizer = std::make_unique(delegate); + auto surface = std::make_unique(); + auto is_gpu_disabled_sync_switch = + std::make_shared(false); + + auto surface_frame = std::make_unique( + /*surface=*/nullptr, /*supports_readback=*/true, + /*submit_callback=*/[](const SurfaceFrame&, SkCanvas*) { return true; }); + EXPECT_CALL(*surface, AllowsDrawingWhenGpuDisabled()).WillOnce(Return(false)); + EXPECT_CALL(delegate, GetIsGpuDisabledSyncSwitch()) + .WillOnce(Return(is_gpu_disabled_sync_switch)); + EXPECT_CALL(*surface, AcquireFrame(SkISize())) + .WillOnce(Return(ByMove(std::move(surface_frame)))); + EXPECT_CALL(*surface, MakeRenderContextCurrent()) + .WillOnce(Return(ByMove(std::make_unique(true)))); + + rasterizer->Setup(std::move(surface)); + fml::AutoResetWaitableEvent latch; + thread_host.raster_thread->GetTaskRunner()->PostTask([&] { + auto pipeline = std::make_shared>(/*depth=*/10); + auto layer_tree = std::make_unique(/*frame_size=*/SkISize(), + /*device_pixel_ratio=*/2.0f); + bool result = pipeline->Produce().Complete(std::move(layer_tree)); + EXPECT_TRUE(result); + auto no_discard = [](LayerTree&) { return false; }; + RasterStatus status = + rasterizer->Draw(CreateFinishedBuildRecorder(), pipeline, no_discard); + EXPECT_EQ(status, RasterStatus::kSuccess); + latch.Signal(); + }); + latch.Wait(); +} + +TEST( + RasterizerTest, + drawWithGpuDisabledAndSurfaceDisallowsDrawingWhenGpuDisabledDoesntAcquireFrame) { + std::string test_name = + ::testing::UnitTest::GetInstance()->current_test_info()->name(); + ThreadHost thread_host("io.flutter.test." + test_name + ".", + ThreadHost::Type::Platform | ThreadHost::Type::RASTER | + ThreadHost::Type::IO | ThreadHost::Type::UI); + TaskRunners task_runners("test", thread_host.platform_thread->GetTaskRunner(), + thread_host.raster_thread->GetTaskRunner(), + thread_host.ui_thread->GetTaskRunner(), + thread_host.io_thread->GetTaskRunner()); + MockDelegate delegate; + EXPECT_CALL(delegate, GetTaskRunners()) + .WillRepeatedly(ReturnRef(task_runners)); + EXPECT_CALL(delegate, OnFrameRasterized(_)).Times(0); + auto rasterizer = std::make_unique(delegate); + auto surface = std::make_unique(); + auto is_gpu_disabled_sync_switch = + std::make_shared(true); + + auto surface_frame = std::make_unique( + /*surface=*/nullptr, /*supports_readback=*/true, + /*submit_callback=*/[](const SurfaceFrame&, SkCanvas*) { return true; }); + EXPECT_CALL(*surface, AllowsDrawingWhenGpuDisabled()).WillOnce(Return(false)); + EXPECT_CALL(delegate, GetIsGpuDisabledSyncSwitch()) + .WillOnce(Return(is_gpu_disabled_sync_switch)); + EXPECT_CALL(*surface, AcquireFrame(SkISize())).Times(0); + EXPECT_CALL(*surface, MakeRenderContextCurrent()) + .WillOnce(Return(ByMove(std::make_unique(true)))); + + rasterizer->Setup(std::move(surface)); + fml::AutoResetWaitableEvent latch; + thread_host.raster_thread->GetTaskRunner()->PostTask([&] { + auto pipeline = std::make_shared>(/*depth=*/10); + auto layer_tree = std::make_unique(/*frame_size=*/SkISize(), + /*device_pixel_ratio=*/2.0f); + bool result = pipeline->Produce().Complete(std::move(layer_tree)); + EXPECT_TRUE(result); + auto no_discard = [](LayerTree&) { return false; }; + RasterStatus status = + rasterizer->Draw(CreateFinishedBuildRecorder(), pipeline, no_discard); + EXPECT_EQ(status, RasterStatus::kDiscarded); + latch.Signal(); + }); + latch.Wait(); +} + } // namespace flutter diff --git a/shell/common/shell_test_external_view_embedder.cc b/shell/common/shell_test_external_view_embedder.cc index 80e82102629c7..5a8edc19fb45a 100644 --- a/shell/common/shell_test_external_view_embedder.cc +++ b/shell/common/shell_test_external_view_embedder.cc @@ -63,8 +63,7 @@ SkCanvas* ShellTestExternalViewEmbedder::CompositeEmbeddedView(int view_id) { // |ExternalViewEmbedder| void ShellTestExternalViewEmbedder::SubmitFrame( GrDirectContext* context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch) { + std::unique_ptr frame) { frame->Submit(); if (frame && frame->SkiaSurface()) { last_submitted_frame_size_ = SkISize::Make(frame->SkiaSurface()->width(), diff --git a/shell/common/shell_test_external_view_embedder.h b/shell/common/shell_test_external_view_embedder.h index 1782a035527a2..72c101ed1f4bc 100644 --- a/shell/common/shell_test_external_view_embedder.h +++ b/shell/common/shell_test_external_view_embedder.h @@ -62,10 +62,8 @@ class ShellTestExternalViewEmbedder final : public ExternalViewEmbedder { SkCanvas* CompositeEmbeddedView(int view_id) override; // |ExternalViewEmbedder| - void SubmitFrame( - GrDirectContext* context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch) override; + void SubmitFrame(GrDirectContext* context, + std::unique_ptr frame) override; // |ExternalViewEmbedder| void EndFrame( diff --git a/shell/common/shell_unittests.cc b/shell/common/shell_unittests.cc index aca67acd5c081..74a0b74e633cf 100644 --- a/shell/common/shell_unittests.cc +++ b/shell/common/shell_unittests.cc @@ -2480,11 +2480,27 @@ TEST_F(ShellTest, Spawn) { ASSERT_NE(nullptr, spawn.get()); ASSERT_TRUE(ValidateShell(spawn.get())); - PostSync(spawner->GetTaskRunners().GetUITaskRunner(), [&spawn] { - // Check second shell ran the second entrypoint. - ASSERT_EQ("testCanLaunchSecondaryIsolate", - spawn->GetEngine()->GetLastEntrypoint()); - }); + PostSync(spawner->GetTaskRunners().GetUITaskRunner(), + [&spawn, &spawner] { + // Check second shell ran the second entrypoint. + ASSERT_EQ("testCanLaunchSecondaryIsolate", + spawn->GetEngine()->GetLastEntrypoint()); + + // TODO(74520): Remove conditional once isolate groups are + // supported by JIT. + if (DartVM::IsRunningPrecompiledCode()) { + ASSERT_NE(spawner->GetEngine() + ->GetRuntimeController() + ->GetRootIsolateGroup(), + 0u); + ASSERT_EQ(spawner->GetEngine() + ->GetRuntimeController() + ->GetRootIsolateGroup(), + spawn->GetEngine() + ->GetRuntimeController() + ->GetRootIsolateGroup()); + } + }); PostSync( spawner->GetTaskRunners().GetIOTaskRunner(), [&spawner, &spawn] { diff --git a/shell/common/thread_host.cc b/shell/common/thread_host.cc index fdae202a53cce..2e0d539fa82f0 100644 --- a/shell/common/thread_host.cc +++ b/shell/common/thread_host.cc @@ -35,12 +35,4 @@ ThreadHost::ThreadHost(std::string name_prefix_arg, uint64_t mask) ThreadHost::~ThreadHost() = default; -void ThreadHost::Reset() { - platform_thread.reset(); - ui_thread.reset(); - raster_thread.reset(); - io_thread.reset(); - profiler_thread.reset(); -} - } // namespace flutter diff --git a/shell/common/thread_host.h b/shell/common/thread_host.h index c5db31938563e..d944963282cb9 100644 --- a/shell/common/thread_host.h +++ b/shell/common/thread_host.h @@ -38,8 +38,6 @@ struct ThreadHost { ThreadHost(std::string name_prefix, uint64_t type_mask); ~ThreadHost(); - - void Reset(); }; } // namespace flutter diff --git a/shell/gpu/gpu_surface_gl.cc b/shell/gpu/gpu_surface_gl.cc index a92c9359b7e01..fe8c56b36443a 100644 --- a/shell/gpu/gpu_surface_gl.cc +++ b/shell/gpu/gpu_surface_gl.cc @@ -339,4 +339,9 @@ bool GPUSurfaceGL::ClearRenderContext() { return delegate_->GLContextClearCurrent(); } +// |Surface| +bool GPUSurfaceGL::AllowsDrawingWhenGpuDisabled() const { + return delegate_->AllowsDrawingWhenGpuDisabled(); +} + } // namespace flutter diff --git a/shell/gpu/gpu_surface_gl.h b/shell/gpu/gpu_surface_gl.h index f1be74c495b2d..e4b04d31f9ed5 100644 --- a/shell/gpu/gpu_surface_gl.h +++ b/shell/gpu/gpu_surface_gl.h @@ -50,6 +50,9 @@ class GPUSurfaceGL : public Surface { // |Surface| bool ClearRenderContext() override; + // |Surface| + bool AllowsDrawingWhenGpuDisabled() const override; + private: GPUSurfaceGLDelegate* delegate_; sk_sp context_; diff --git a/shell/gpu/gpu_surface_gl_delegate.cc b/shell/gpu/gpu_surface_gl_delegate.cc index 66f9df261f0ab..70bdfc03ba4bb 100644 --- a/shell/gpu/gpu_surface_gl_delegate.cc +++ b/shell/gpu/gpu_surface_gl_delegate.cc @@ -99,4 +99,8 @@ GPUSurfaceGLDelegate::GetDefaultPlatformGLInterface() { return CreateGLInterface(nullptr); } +bool GPUSurfaceGLDelegate::AllowsDrawingWhenGpuDisabled() const { + return true; +} + } // namespace flutter diff --git a/shell/gpu/gpu_surface_gl_delegate.h b/shell/gpu/gpu_surface_gl_delegate.h index fff96945e00a8..fb13c8212390c 100644 --- a/shell/gpu/gpu_surface_gl_delegate.h +++ b/shell/gpu/gpu_surface_gl_delegate.h @@ -69,6 +69,9 @@ class GPUSurfaceGLDelegate { // instrumentation to specific GL calls can specify custom GL functions // here. virtual GLProcResolver GetGLProcResolver() const; + + // Whether to allow drawing to the surface when the GPU is disabled + virtual bool AllowsDrawingWhenGpuDisabled() const; }; } // namespace flutter diff --git a/shell/gpu/gpu_surface_metal.h b/shell/gpu/gpu_surface_metal.h index 0ef1b8c4a1d3d..78cbae5248e67 100644 --- a/shell/gpu/gpu_surface_metal.h +++ b/shell/gpu/gpu_surface_metal.h @@ -42,6 +42,9 @@ class SK_API_AVAILABLE_CA_METAL_LAYER GPUSurfaceMetal : public Surface { // |Surface| std::unique_ptr MakeRenderContextCurrent() override; + // |Surface| + bool AllowsDrawingWhenGpuDisabled() const override; + std::unique_ptr AcquireFrameFromCAMetalLayer( const SkISize& frame_info); diff --git a/shell/gpu/gpu_surface_metal.mm b/shell/gpu/gpu_surface_metal.mm index 96ec00a325837..061f91fcc3eab 100644 --- a/shell/gpu/gpu_surface_metal.mm +++ b/shell/gpu/gpu_surface_metal.mm @@ -159,6 +159,10 @@ return std::make_unique(true); } +bool GPUSurfaceMetal::AllowsDrawingWhenGpuDisabled() const { + return delegate_->AllowsDrawingWhenGpuDisabled(); +} + void GPUSurfaceMetal::ReleaseUnusedDrawableIfNecessary() { // If the previous surface frame was not submitted before a new one is acquired, the old drawable // needs to be released. An RAII wrapper may not be used because this needs to interoperate with diff --git a/shell/gpu/gpu_surface_metal_delegate.cc b/shell/gpu/gpu_surface_metal_delegate.cc index 427614e1b41d8..7a22d8de88a89 100644 --- a/shell/gpu/gpu_surface_metal_delegate.cc +++ b/shell/gpu/gpu_surface_metal_delegate.cc @@ -16,4 +16,8 @@ MTLRenderTargetType GPUSurfaceMetalDelegate::GetRenderTargetType() { return render_target_type_; } +bool GPUSurfaceMetalDelegate::AllowsDrawingWhenGpuDisabled() const { + return true; +} + } // namespace flutter diff --git a/shell/gpu/gpu_surface_metal_delegate.h b/shell/gpu/gpu_surface_metal_delegate.h index 4185e409e4d17..07af6d8e0f768 100644 --- a/shell/gpu/gpu_surface_metal_delegate.h +++ b/shell/gpu/gpu_surface_metal_delegate.h @@ -89,6 +89,11 @@ class GPUSurfaceMetalDelegate { /// virtual bool PresentTexture(GPUMTLTextureInfo texture) const = 0; + //------------------------------------------------------------------------------ + /// @brief Whether to allow drawing to the surface when the GPU is disabled + /// + virtual bool AllowsDrawingWhenGpuDisabled() const; + MTLRenderTargetType GetRenderTargetType(); private: diff --git a/shell/platform/android/android_shell_holder.cc b/shell/platform/android/android_shell_holder.cc index e83980f4bf4f5..f72e4edf77faf 100644 --- a/shell/platform/android/android_shell_holder.cc +++ b/shell/platform/android/android_shell_holder.cc @@ -157,7 +157,7 @@ AndroidShellHolder::AndroidShellHolder( AndroidShellHolder::~AndroidShellHolder() { shell_.reset(); - thread_host_->Reset(); + thread_host_.reset(); } bool AndroidShellHolder::IsValid() const { diff --git a/shell/platform/android/external_view_embedder/external_view_embedder.cc b/shell/platform/android/external_view_embedder/external_view_embedder.cc index 69370e7ca65bf..9e544d6a2e5f9 100644 --- a/shell/platform/android/external_view_embedder/external_view_embedder.cc +++ b/shell/platform/android/external_view_embedder/external_view_embedder.cc @@ -75,8 +75,7 @@ SkRect AndroidExternalViewEmbedder::GetViewRect(int view_id) const { // |ExternalViewEmbedder| void AndroidExternalViewEmbedder::SubmitFrame( GrDirectContext* context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch) { + std::unique_ptr frame) { TRACE_EVENT0("flutter", "AndroidExternalViewEmbedder::SubmitFrame"); if (!FrameHasPlatformLayers()) { diff --git a/shell/platform/android/external_view_embedder/external_view_embedder.h b/shell/platform/android/external_view_embedder/external_view_embedder.h index 71d7e7c85c775..7398ce490aa7d 100644 --- a/shell/platform/android/external_view_embedder/external_view_embedder.h +++ b/shell/platform/android/external_view_embedder/external_view_embedder.h @@ -46,10 +46,8 @@ class AndroidExternalViewEmbedder final : public ExternalViewEmbedder { std::vector GetCurrentCanvases() override; // |ExternalViewEmbedder| - void SubmitFrame( - GrDirectContext* context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch) override; + void SubmitFrame(GrDirectContext* context, + std::unique_ptr frame) override; // |ExternalViewEmbedder| PostPrerollResult PostPrerollAction( diff --git a/shell/platform/android/external_view_embedder/external_view_embedder_unittests.cc b/shell/platform/android/external_view_embedder/external_view_embedder_unittests.cc index 9bbad774d3f1c..0b07e6c3a9f2e 100644 --- a/shell/platform/android/external_view_embedder/external_view_embedder_unittests.cc +++ b/shell/platform/android/external_view_embedder/external_view_embedder_unittests.cc @@ -334,7 +334,7 @@ TEST(AndroidExternalViewEmbedder, SubmitFrame) { return true; }); - embedder->SubmitFrame(gr_context.get(), std::move(surface_frame), nullptr); + embedder->SubmitFrame(gr_context.get(), std::move(surface_frame)); // Submits frame if no Android view in the current frame. EXPECT_TRUE(did_submit_frame); // Doesn't resubmit frame. @@ -401,7 +401,7 @@ TEST(AndroidExternalViewEmbedder, SubmitFrame) { return true; }); - embedder->SubmitFrame(gr_context.get(), std::move(surface_frame), nullptr); + embedder->SubmitFrame(gr_context.get(), std::move(surface_frame)); // Doesn't submit frame if there aren't Android views in the previous frame. EXPECT_FALSE(did_submit_frame); // Resubmits frame. @@ -465,7 +465,7 @@ TEST(AndroidExternalViewEmbedder, SubmitFrame) { } return true; }); - embedder->SubmitFrame(gr_context.get(), std::move(surface_frame), nullptr); + embedder->SubmitFrame(gr_context.get(), std::move(surface_frame)); // Submits frame if there are Android views in the previous frame. EXPECT_TRUE(did_submit_frame); // Doesn't resubmit frame. @@ -477,6 +477,175 @@ TEST(AndroidExternalViewEmbedder, SubmitFrame) { } } +TEST(AndroidExternalViewEmbedder, SubmitFrame__overlayComposition) { + auto jni_mock = std::make_shared(); + auto android_context = + std::make_shared(AndroidRenderingAPI::kSoftware); + + auto window = fml::MakeRefCounted(nullptr); + auto gr_context = GrDirectContext::MakeMock(nullptr); + auto frame_size = SkISize::Make(1000, 1000); + auto surface_factory = std::make_shared( + [&android_context, gr_context, window, frame_size]() { + auto surface_frame_1 = std::make_unique( + SkSurface::MakeNull(1000, 1000), false, + [](const SurfaceFrame& surface_frame, SkCanvas* canvas) { + return true; + }); + + auto surface_mock = std::make_unique(); + EXPECT_CALL(*surface_mock, AcquireFrame(frame_size)) + .Times(1 /* frames */) + .WillOnce(Return(ByMove(std::move(surface_frame_1)))); + + auto android_surface_mock = + std::make_unique(android_context); + EXPECT_CALL(*android_surface_mock, IsValid()).WillOnce(Return(true)); + + EXPECT_CALL(*android_surface_mock, CreateGPUSurface(gr_context.get())) + .WillOnce(Return(ByMove(std::move(surface_mock)))); + + EXPECT_CALL(*android_surface_mock, SetNativeWindow(window)); + return android_surface_mock; + }); + auto embedder = std::make_unique( + *android_context, jni_mock, surface_factory); + + auto raster_thread_merger = + GetThreadMergerFromPlatformThread(/*merged=*/true); + + EXPECT_CALL(*jni_mock, FlutterViewBeginFrame()); + embedder->BeginFrame(frame_size, nullptr, 1.5, raster_thread_merger); + + { + // Add first Android view. + SkMatrix matrix; + MutatorsStack stack; + stack.PushTransform(SkMatrix::Translate(0, 0)); + + embedder->PrerollCompositeEmbeddedView( + 0, std::make_unique(matrix, SkSize::Make(200, 200), + stack)); + EXPECT_CALL(*jni_mock, FlutterViewOnDisplayPlatformView(0, 0, 0, 200, 200, + 300, 300, stack)); + } + + auto rect_paint = SkPaint(); + rect_paint.setColor(SkColors::kCyan); + rect_paint.setStyle(SkPaint::Style::kFill_Style); + + // This simulates Flutter UI that intersects with the first Android view. + embedder->CompositeEmbeddedView(0)->drawRect( + SkRect::MakeXYWH(25, 25, 80, 150), rect_paint); + + { + // Add second Android view. + SkMatrix matrix; + MutatorsStack stack; + stack.PushTransform(SkMatrix::Translate(0, 100)); + + embedder->PrerollCompositeEmbeddedView( + 1, std::make_unique(matrix, SkSize::Make(100, 100), + stack)); + EXPECT_CALL(*jni_mock, FlutterViewOnDisplayPlatformView(1, 0, 0, 100, 100, + 150, 150, stack)); + } + // This simulates Flutter UI that intersects with the first and second Android + // views. + embedder->CompositeEmbeddedView(1)->drawRect(SkRect::MakeXYWH(25, 25, 80, 50), + rect_paint); + + embedder->CompositeEmbeddedView(1)->drawRect( + SkRect::MakeXYWH(75, 75, 30, 100), rect_paint); + + EXPECT_CALL(*jni_mock, FlutterViewCreateOverlaySurface()) + .WillRepeatedly([&]() { + return std::make_unique( + 1, window); + }); + + EXPECT_CALL(*jni_mock, FlutterViewDisplayOverlaySurface(1, 25, 25, 80, 150)) + .Times(2); + + auto surface_frame = std::make_unique( + SkSurface::MakeNull(1000, 1000), false, + [](const SurfaceFrame& surface_frame, SkCanvas* canvas) mutable { + return true; + }); + + embedder->SubmitFrame(gr_context.get(), std::move(surface_frame)); + + EXPECT_CALL(*jni_mock, FlutterViewEndFrame()); + embedder->EndFrame(/*should_resubmit_frame=*/false, raster_thread_merger); +} + +TEST(AndroidExternalViewEmbedder, SubmitFrame__platformViewWithoutAnyOverlay) { + auto jni_mock = std::make_shared(); + auto android_context = + std::make_shared(AndroidRenderingAPI::kSoftware); + + auto window = fml::MakeRefCounted(nullptr); + auto gr_context = GrDirectContext::MakeMock(nullptr); + auto frame_size = SkISize::Make(1000, 1000); + auto surface_factory = std::make_shared( + [&android_context, gr_context, window, frame_size]() { + auto surface_frame_1 = std::make_unique( + SkSurface::MakeNull(1000, 1000), false, + [](const SurfaceFrame& surface_frame, SkCanvas* canvas) { + return true; + }); + + auto surface_mock = std::make_unique(); + EXPECT_CALL(*surface_mock, AcquireFrame(frame_size)) + .Times(1 /* frames */) + .WillOnce(Return(ByMove(std::move(surface_frame_1)))); + + auto android_surface_mock = + std::make_unique(android_context); + EXPECT_CALL(*android_surface_mock, IsValid()).WillOnce(Return(true)); + + EXPECT_CALL(*android_surface_mock, CreateGPUSurface(gr_context.get())) + .WillOnce(Return(ByMove(std::move(surface_mock)))); + + EXPECT_CALL(*android_surface_mock, SetNativeWindow(window)); + return android_surface_mock; + }); + auto embedder = std::make_unique( + *android_context, jni_mock, surface_factory); + + auto raster_thread_merger = + GetThreadMergerFromPlatformThread(/*merged=*/true); + + EXPECT_CALL(*jni_mock, FlutterViewBeginFrame()); + embedder->BeginFrame(frame_size, nullptr, 1.5, raster_thread_merger); + + { + // Add Android view. + SkMatrix matrix; + MutatorsStack stack; + stack.PushTransform(SkMatrix::Translate(0, 0)); + + embedder->PrerollCompositeEmbeddedView( + 0, std::make_unique(matrix, SkSize::Make(200, 200), + stack)); + EXPECT_CALL(*jni_mock, FlutterViewOnDisplayPlatformView(0, 0, 0, 200, 200, + 300, 300, stack)); + } + + EXPECT_CALL(*jni_mock, FlutterViewCreateOverlaySurface()).Times(0); + + auto surface_frame = std::make_unique( + SkSurface::MakeNull(1000, 1000), false, + [](const SurfaceFrame& surface_frame, SkCanvas* canvas) mutable { + return true; + }); + + embedder->SubmitFrame(gr_context.get(), std::move(surface_frame)); + + EXPECT_CALL(*jni_mock, FlutterViewEndFrame()); + embedder->EndFrame(/*should_resubmit_frame=*/false, raster_thread_merger); +} + TEST(AndroidExternalViewEmbedder, DoesNotCallJNIPlatformThreadOnlyMethods) { auto jni_mock = std::make_shared(); @@ -565,7 +734,7 @@ TEST(AndroidExternalViewEmbedder, DestroyOverlayLayersOnSizeChange) { std::make_unique(SkSurface::MakeNull(1000, 1000), false, [](const SurfaceFrame& surface_frame, SkCanvas* canvas) { return true; }); - embedder->SubmitFrame(gr_context.get(), std::move(surface_frame), nullptr); + embedder->SubmitFrame(gr_context.get(), std::move(surface_frame)); EXPECT_CALL(*jni_mock, FlutterViewEndFrame()); embedder->EndFrame(/*should_resubmit_frame=*/false, raster_thread_merger); @@ -647,7 +816,7 @@ TEST(AndroidExternalViewEmbedder, DoesNotDestroyOverlayLayersOnSizeChange) { std::make_unique(SkSurface::MakeNull(1000, 1000), false, [](const SurfaceFrame& surface_frame, SkCanvas* canvas) { return true; }); - embedder->SubmitFrame(gr_context.get(), std::move(surface_frame), nullptr); + embedder->SubmitFrame(gr_context.get(), std::move(surface_frame)); EXPECT_CALL(*jni_mock, FlutterViewEndFrame()); embedder->EndFrame(/*should_resubmit_frame=*/false, raster_thread_merger); diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java b/shell/platform/android/io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java index 0990604b9ba1b..e3d0235ff621e 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java @@ -400,7 +400,7 @@ private String maybeGetInitialRouteFromIntent(Intent intent) { Uri data = intent.getData(); if (data != null && !data.getPath().isEmpty()) { String pathAndQuery = data.getPath(); - if (!data.getQuery().isEmpty()) { + if (data.getQuery() != null && !data.getQuery().isEmpty()) { pathAndQuery += "?" + data.getQuery(); } return pathAndQuery; 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/android/FlutterActivityAndFragmentDelegateTest.java b/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java index 509b97791cd9c..848ad8505495e 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java @@ -456,6 +456,32 @@ public void itForwardsOnRequestPermissionsResultToFlutterEngine() { .setInitialRoute("/custom/route?query=test"); } + @Test + public void + itSendsInitialRouteFromIntentOnStartIfNoInitialRouteFromActivityAndShouldHandleDeeplinkingNoQueryParameter() { + Intent intent = FlutterActivity.createDefaultIntent(RuntimeEnvironment.application); + intent.setData(Uri.parse("http://myApp/custom/route")); + + ActivityController activityController = + Robolectric.buildActivity(FlutterActivity.class, intent); + FlutterActivity flutterActivity = activityController.get(); + + when(mockHost.getActivity()).thenReturn(flutterActivity); + when(mockHost.getInitialRoute()).thenReturn(null); + when(mockHost.shouldHandleDeeplinking()).thenReturn(true); + // Create the real object that we're testing. + FlutterActivityAndFragmentDelegate delegate = new FlutterActivityAndFragmentDelegate(mockHost); + + // --- Execute the behavior under test --- + // The FlutterEngine is setup in onAttach(). + delegate.onAttach(RuntimeEnvironment.application); + // Emulate app start. + delegate.onStart(); + + // Verify that the navigation channel was given the initial route message. + verify(mockFlutterEngine.getNavigationChannel(), times(1)).setInitialRoute("/custom/route"); + } + @Test public void itSendsdefaultInitialRouteOnStartIfNotDeepLinkingFromIntent() { // Creates an empty intent without launch uri. @@ -501,6 +527,25 @@ public void itSendsPushRouteMessageWhenOnNewIntent() { .pushRoute("/custom/route?query=test"); } + @Test + public void itSendsPushRouteMessageWhenOnNewIntentNoQueryParameter() { + when(mockHost.shouldHandleDeeplinking()).thenReturn(true); + // Create the real object that we're testing. + FlutterActivityAndFragmentDelegate delegate = new FlutterActivityAndFragmentDelegate(mockHost); + + // --- Execute the behavior under test --- + // The FlutterEngine is setup in onAttach(). + delegate.onAttach(RuntimeEnvironment.application); + + Intent mockIntent = mock(Intent.class); + when(mockIntent.getData()).thenReturn(Uri.parse("http://myApp/custom/route")); + // Emulate the host and call the method that we expect to be forwarded. + delegate.onNewIntent(mockIntent); + + // Verify that the navigation channel was given the push route message. + verify(mockFlutterEngine.getNavigationChannel(), times(1)).pushRoute("/custom/route"); + } + @Test public void itForwardsOnNewIntentToFlutterEngine() { // Create the real object that we're testing. 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/darwin/ios/framework/Source/FlutterPlatformViews.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm index f65cb5d5dd791..e3876889beced 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm @@ -462,22 +462,10 @@ ); } -bool FlutterPlatformViewsController::SubmitFrame( - GrDirectContext* gr_context, - std::shared_ptr ios_context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch) { - bool result = false; - gpu_disable_sync_switch->Execute( - fml::SyncSwitch::Handlers().SetIfTrue([&] { result = false; }).SetIfFalse([&] { - result = SubmitFrameGpuSafe(gr_context, ios_context, std::move(frame)); - })); - return result; -} - -bool FlutterPlatformViewsController::SubmitFrameGpuSafe(GrDirectContext* gr_context, - std::shared_ptr ios_context, - std::unique_ptr frame) { + +bool FlutterPlatformViewsController::SubmitFrame(GrDirectContext* gr_context, + std::shared_ptr ios_context, + std::unique_ptr frame) { // Any UIKit related code has to run on main thread. FML_DCHECK([[NSThread currentThread] isMainThread]); if (flutter_view_ == nullptr) { diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm index 8f4b3e7a972c4..b25b8cc9b4eae 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm @@ -906,10 +906,8 @@ - (void)testFlutterPlatformViewControllerSubmitFrameWithoutFlutterViewNotCrashin auto mock_surface = std::make_unique( nullptr, true, [](const flutter::SurfaceFrame& surface_frame, SkCanvas* canvas) { return false; }); - auto is_gpu_disabled = std::make_shared(); - is_gpu_disabled->SetSwitch(false); - XCTAssertFalse(flutterPlatformViewsController->SubmitFrame( - nullptr, nullptr, std::move(mock_surface), is_gpu_disabled)); + XCTAssertFalse( + flutterPlatformViewsController->SubmitFrame(nullptr, nullptr, std::move(mock_surface))); auto embeddedViewParams_2 = std::make_unique(finalMatrix, SkSize::Make(300, 300), stack); @@ -918,10 +916,8 @@ - (void)testFlutterPlatformViewControllerSubmitFrameWithoutFlutterViewNotCrashin auto mock_surface_submit_false = std::make_unique( nullptr, true, [](const flutter::SurfaceFrame& surface_frame, SkCanvas* canvas) { return true; }); - auto gpu_is_disabled = std::make_shared(); - gpu_is_disabled->SetSwitch(false); - XCTAssertTrue(flutterPlatformViewsController->SubmitFrame( - nullptr, nullptr, std::move(mock_surface_submit_false), gpu_is_disabled)); + XCTAssertTrue(flutterPlatformViewsController->SubmitFrame(nullptr, nullptr, + std::move(mock_surface_submit_false))); } - (void) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h index 3debeab09b444..1678bbaa9546d 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h @@ -173,8 +173,7 @@ class FlutterPlatformViewsController { bool SubmitFrame(GrDirectContext* gr_context, std::shared_ptr ios_context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch); + std::unique_ptr frame); void OnMethodCall(FlutterMethodCall* call, FlutterResult& result); @@ -300,10 +299,6 @@ class FlutterPlatformViewsController { // Commit a CATransaction if |BeginCATransaction| has been called during the frame. void CommitCATransactionIfNeeded(); - bool SubmitFrameGpuSafe(GrDirectContext* gr_context, - std::shared_ptr ios_context, - std::unique_ptr frame); - // Resets the state of the frame. void ResetFrameState(); diff --git a/shell/platform/darwin/ios/ios_external_view_embedder.h b/shell/platform/darwin/ios/ios_external_view_embedder.h index 84d30416d27f0..6c023d1b793e1 100644 --- a/shell/platform/darwin/ios/ios_external_view_embedder.h +++ b/shell/platform/darwin/ios/ios_external_view_embedder.h @@ -53,10 +53,8 @@ class IOSExternalViewEmbedder : public ExternalViewEmbedder { SkCanvas* CompositeEmbeddedView(int view_id) override; // |ExternalViewEmbedder| - void SubmitFrame( - GrDirectContext* context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch) override; + void SubmitFrame(GrDirectContext* context, + std::unique_ptr frame) override; // |ExternalViewEmbedder| void EndFrame( diff --git a/shell/platform/darwin/ios/ios_external_view_embedder.mm b/shell/platform/darwin/ios/ios_external_view_embedder.mm index 9ea32d1b97fd5..a4921af254af9 100644 --- a/shell/platform/darwin/ios/ios_external_view_embedder.mm +++ b/shell/platform/darwin/ios/ios_external_view_embedder.mm @@ -72,14 +72,11 @@ } // |ExternalViewEmbedder| -void IOSExternalViewEmbedder::SubmitFrame( - GrDirectContext* context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch) { +void IOSExternalViewEmbedder::SubmitFrame(GrDirectContext* context, + std::unique_ptr frame) { TRACE_EVENT0("flutter", "IOSExternalViewEmbedder::SubmitFrame"); FML_CHECK(platform_views_controller_); - platform_views_controller_->SubmitFrame(std::move(context), ios_context_, std::move(frame), - gpu_disable_sync_switch); + platform_views_controller_->SubmitFrame(std::move(context), ios_context_, std::move(frame)); TRACE_EVENT0("flutter", "IOSExternalViewEmbedder::DidSubmitFrame"); } diff --git a/shell/platform/darwin/ios/ios_surface_gl.h b/shell/platform/darwin/ios/ios_surface_gl.h index 0172e225a6bd8..a541e28e7c21c 100644 --- a/shell/platform/darwin/ios/ios_surface_gl.h +++ b/shell/platform/darwin/ios/ios_surface_gl.h @@ -46,6 +46,9 @@ class IOSSurfaceGL final : public IOSSurface, public GPUSurfaceGLDelegate { // |GPUSurfaceGLDelegate| bool SurfaceSupportsReadback() const override; + // |GPUSurfaceGLDelegate| + bool AllowsDrawingWhenGpuDisabled() const override; + private: std::unique_ptr render_target_; diff --git a/shell/platform/darwin/ios/ios_surface_gl.mm b/shell/platform/darwin/ios/ios_surface_gl.mm index 34983f3a5bb21..e1c5bde675499 100644 --- a/shell/platform/darwin/ios/ios_surface_gl.mm +++ b/shell/platform/darwin/ios/ios_surface_gl.mm @@ -89,4 +89,9 @@ return IsValid() && render_target_->PresentRenderBuffer(); } +// |GPUSurfaceGLDelegate| +bool IOSSurfaceGL::AllowsDrawingWhenGpuDisabled() const { + return false; +} + } // namespace flutter diff --git a/shell/platform/darwin/ios/ios_surface_metal.h b/shell/platform/darwin/ios/ios_surface_metal.h index c2e9b8c50d0da..2424ceafca3e6 100644 --- a/shell/platform/darwin/ios/ios_surface_metal.h +++ b/shell/platform/darwin/ios/ios_surface_metal.h @@ -49,6 +49,9 @@ class SK_API_AVAILABLE_CA_METAL_LAYER IOSSurfaceMetal final : public IOSSurface, // |GPUSurfaceMetalDelegate| bool PresentTexture(GPUMTLTextureInfo texture) const override; + // |GPUSurfaceMetalDelegate| + bool AllowsDrawingWhenGpuDisabled() const override; + FML_DISALLOW_COPY_AND_ASSIGN(IOSSurfaceMetal); }; diff --git a/shell/platform/darwin/ios/ios_surface_metal.mm b/shell/platform/darwin/ios/ios_surface_metal.mm index 64909e0b15b1b..2df3fd0840897 100644 --- a/shell/platform/darwin/ios/ios_surface_metal.mm +++ b/shell/platform/darwin/ios/ios_surface_metal.mm @@ -98,4 +98,9 @@ return false; } +// |GPUSurfaceMetalDelegate| +bool IOSSurfaceMetal::AllowsDrawingWhenGpuDisabled() const { + return false; +} + } // namespace flutter diff --git a/shell/platform/embedder/embedder_external_view_embedder.cc b/shell/platform/embedder/embedder_external_view_embedder.cc index 3e1dd488240d8..a890260887e26 100644 --- a/shell/platform/embedder/embedder_external_view_embedder.cc +++ b/shell/platform/embedder/embedder_external_view_embedder.cc @@ -135,8 +135,7 @@ static FlutterBackingStoreConfig MakeBackingStoreConfig( // |ExternalViewEmbedder| void EmbedderExternalViewEmbedder::SubmitFrame( GrDirectContext* context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch) { + std::unique_ptr frame) { auto [matched_render_targets, pending_keys] = render_target_cache_.GetExistingTargetsInCache(pending_views_); diff --git a/shell/platform/embedder/embedder_external_view_embedder.h b/shell/platform/embedder/embedder_external_view_embedder.h index 9796d92c64ea2..98fe8b2a56639 100644 --- a/shell/platform/embedder/embedder_external_view_embedder.h +++ b/shell/platform/embedder/embedder_external_view_embedder.h @@ -97,10 +97,8 @@ class EmbedderExternalViewEmbedder final : public ExternalViewEmbedder { SkCanvas* CompositeEmbeddedView(int view_id) override; // |ExternalViewEmbedder| - void SubmitFrame( - GrDirectContext* context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch) override; + void SubmitFrame(GrDirectContext* context, + std::unique_ptr frame) override; // |ExternalViewEmbedder| SkCanvas* GetRootCanvas() override; diff --git a/shell/platform/fuchsia/flutter/accessibility_bridge.cc b/shell/platform/fuchsia/flutter/accessibility_bridge.cc index 9a0039f50fa24..0d801fbffb87f 100644 --- a/shell/platform/fuchsia/flutter/accessibility_bridge.cc +++ b/shell/platform/fuchsia/flutter/accessibility_bridge.cc @@ -314,6 +314,7 @@ void AccessibilityBridge::AddSemanticsNodeUpdate( nodes_[flutter_node.id] = { .id = flutter_node.id, .flags = flutter_node.flags, + .is_focusable = IsFocusable(flutter_node), .rect = flutter_node.rect, .transform = flutter_node.transform, .children_in_hit_test_order = flutter_node.childrenInHitTestOrder, @@ -412,6 +413,7 @@ fuchsia::accessibility::semantics::Node AccessibilityBridge::GetRootNodeUpdate( nodes_[root_flutter_semantics_node_.id] = { .id = root_flutter_semantics_node_.id, .flags = root_flutter_semantics_node_.flags, + .is_focusable = IsFocusable(root_flutter_semantics_node_), .rect = root_flutter_semantics_node_.rect, .transform = result, .children_in_hit_test_order = @@ -570,7 +572,36 @@ std::optional AccessibilityBridge::GetHitNode(int32_t node_id, return candidate; } } - return node_id; + + if (node.is_focusable) { + return node_id; + } + + return {}; +} + +bool AccessibilityBridge::IsFocusable( + const flutter::SemanticsNode& node) const { + if (node.HasFlag(flutter::SemanticsFlags::kScopesRoute)) { + return false; + } + + if (node.HasFlag(flutter::SemanticsFlags::kIsFocusable)) { + return true; + } + + // Always consider platform views focusable. + if (node.IsPlatformViewNode()) { + return true; + } + + // Always conider actionable nodes focusable. + if (node.actions != 0) { + return true; + } + + // Consider text nodes focusable. + return !node.label.empty() || !node.value.empty() || !node.hint.empty(); } // |fuchsia::accessibility::semantics::SemanticListener| diff --git a/shell/platform/fuchsia/flutter/accessibility_bridge.h b/shell/platform/fuchsia/flutter/accessibility_bridge.h index 4328c8b46d660..8b0e939047105 100644 --- a/shell/platform/fuchsia/flutter/accessibility_bridge.h +++ b/shell/platform/fuchsia/flutter/accessibility_bridge.h @@ -110,6 +110,7 @@ class AccessibilityBridge struct SemanticsNode { int32_t id; int32_t flags; + bool is_focusable; SkRect rect; SkRect screen_rect; SkM44 transform; @@ -197,6 +198,9 @@ class AccessibilityBridge // Assumes that SemanticsNode::screen_rect is up to date. std::optional GetHitNode(int32_t node_id, float x, float y); + // Returns whether the node is considered focusable. + bool IsFocusable(const flutter::SemanticsNode& node) const; + // Converts a fuchsia::accessibility::semantics::Action to a // flutter::SemanticsAction. // diff --git a/shell/platform/fuchsia/flutter/accessibility_bridge_unittest.cc b/shell/platform/fuchsia/flutter/accessibility_bridge_unittest.cc index c9de9f75cb51f..44d5174190d10 100644 --- a/shell/platform/fuchsia/flutter/accessibility_bridge_unittest.cc +++ b/shell/platform/fuchsia/flutter/accessibility_bridge_unittest.cc @@ -726,23 +726,32 @@ TEST_F(AccessibilityBridgeTest, HitTest) { flutter::SemanticsNode node0; node0.id = 0; node0.rect.setLTRB(0, 0, 100, 100); + node0.flags |= static_cast(flutter::SemanticsFlags::kIsFocusable); flutter::SemanticsNode node1; node1.id = 1; node1.rect.setLTRB(10, 10, 20, 20); + // Setting platform view id ensures this node is considered focusable. + node1.platformViewId = 1u; flutter::SemanticsNode node2; node2.id = 2; node2.rect.setLTRB(25, 10, 45, 20); + // Setting label ensures this node is considered focusable. + node2.label = "label"; flutter::SemanticsNode node3; node3.id = 3; node3.rect.setLTRB(10, 25, 20, 45); + // Setting actions to a nonzero value ensures this node is considered + // focusable. + node3.actions = 1u; flutter::SemanticsNode node4; node4.id = 4; node4.rect.setLTRB(10, 10, 20, 20); node4.transform.setTranslate(20, 20, 0); + node4.flags |= static_cast(flutter::SemanticsFlags::kIsFocusable); node0.childrenInTraversalOrder = {1, 2, 3, 4}; node0.childrenInHitTestOrder = {1, 2, 3, 4}; @@ -782,20 +791,59 @@ TEST_F(AccessibilityBridgeTest, HitTest) { EXPECT_EQ(hit_node_id, 4u); } +TEST_F(AccessibilityBridgeTest, HitTestUnfocusableChild) { + flutter::SemanticsNode node0; + node0.id = 0; + node0.rect.setLTRB(0, 0, 100, 100); + + flutter::SemanticsNode node1; + node1.id = 1; + node1.rect.setLTRB(10, 10, 60, 60); + + flutter::SemanticsNode node2; + node2.id = 2; + node2.rect.setLTRB(50, 50, 100, 100); + node2.flags |= static_cast(flutter::SemanticsFlags::kIsFocusable); + + node0.childrenInTraversalOrder = {1, 2}; + node0.childrenInHitTestOrder = {1, 2}; + + accessibility_bridge_->AddSemanticsNodeUpdate( + { + {0, node0}, + {1, node1}, + {2, node2}, + }, + 1.f); + RunLoopUntilIdle(); + + uint32_t hit_node_id; + auto callback = [&hit_node_id](fuchsia::accessibility::semantics::Hit hit) { + EXPECT_TRUE(hit.has_node_id()); + hit_node_id = hit.node_id(); + }; + + accessibility_bridge_->HitTest({55, 55}, callback); + EXPECT_EQ(hit_node_id, 2u); +} + TEST_F(AccessibilityBridgeTest, HitTestOverlapping) { // Tests that the first node in hit test order wins, even if a later node // would be able to recieve the hit. flutter::SemanticsNode node0; node0.id = 0; node0.rect.setLTRB(0, 0, 100, 100); + node0.flags |= static_cast(flutter::SemanticsFlags::kIsFocusable); flutter::SemanticsNode node1; node1.id = 1; node1.rect.setLTRB(0, 0, 100, 100); + node1.flags |= static_cast(flutter::SemanticsFlags::kIsFocusable); flutter::SemanticsNode node2; node2.id = 2; node2.rect.setLTRB(25, 10, 45, 20); + node2.flags |= static_cast(flutter::SemanticsFlags::kIsFocusable); node0.childrenInTraversalOrder = {1, 2}; node0.childrenInHitTestOrder = {2, 1}; diff --git a/shell/platform/fuchsia/flutter/fuchsia_external_view_embedder.cc b/shell/platform/fuchsia/flutter/fuchsia_external_view_embedder.cc index afebc157dcbd1..bbcbf1fe7ebdb 100644 --- a/shell/platform/fuchsia/flutter/fuchsia_external_view_embedder.cc +++ b/shell/platform/fuchsia/flutter/fuchsia_external_view_embedder.cc @@ -164,8 +164,7 @@ void FuchsiaExternalViewEmbedder::EndFrame( void FuchsiaExternalViewEmbedder::SubmitFrame( GrDirectContext* context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch) { + std::unique_ptr frame) { TRACE_EVENT0("flutter", "FuchsiaExternalViewEmbedder::SubmitFrame"); std::vector> frame_surfaces; std::unordered_map frame_surface_indices; diff --git a/shell/platform/fuchsia/flutter/fuchsia_external_view_embedder.h b/shell/platform/fuchsia/flutter/fuchsia_external_view_embedder.h index af1ccc57e21a7..cc2ba55f9dadf 100644 --- a/shell/platform/fuchsia/flutter/fuchsia_external_view_embedder.h +++ b/shell/platform/fuchsia/flutter/fuchsia_external_view_embedder.h @@ -74,10 +74,8 @@ class FuchsiaExternalViewEmbedder final : public flutter::ExternalViewEmbedder { fml::RefPtr raster_thread_merger) override; // |ExternalViewEmbedder| - void SubmitFrame( - GrDirectContext* context, - std::unique_ptr frame, - const std::shared_ptr& gpu_disable_sync_switch) override; + void SubmitFrame(GrDirectContext* context, + std::unique_ptr frame) override; // |ExternalViewEmbedder| void CancelFrame() override { Reset(); } diff --git a/shell/platform/fuchsia/flutter/platform_view_unittest.cc b/shell/platform/fuchsia/flutter/platform_view_unittest.cc index 628eefc7d9b17..465ed862da6cb 100644 --- a/shell/platform/fuchsia/flutter/platform_view_unittest.cc +++ b/shell/platform/fuchsia/flutter/platform_view_unittest.cc @@ -45,9 +45,7 @@ class MockExternalViewEmbedder : public flutter::ExternalViewEmbedder { double device_pixel_ratio, fml::RefPtr raster_thread_merger) override {} void SubmitFrame(GrDirectContext* context, - std::unique_ptr frame, - const std::shared_ptr& - gpu_disable_sync_switch) override { + std::unique_ptr frame) override { return; } diff --git a/shell/platform/windows/flutter_windows_view.cc b/shell/platform/windows/flutter_windows_view.cc index 2f3b4c282698c..f098803c74d2d 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_target_dims = target_height > 0 && target_width > 0; + bool not_same_size = + (cur_height != target_height) || (cur_width != target_width); + return non_zero_target_dims && not_same_size; +} + FlutterWindowsView::FlutterWindowsView( std::unique_ptr window_binding) { surface_manager_ = std::make_unique(); @@ -80,12 +98,21 @@ 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; + + EGLint surface_width, surface_height; + surface_manager_->GetSurfaceDimensions(&surface_width, &surface_height); + + bool surface_will_update = + SurfaceWillUpdate(surface_width, surface_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/dart/codec_test.dart b/testing/dart/codec_test.dart index 961f54f4ace6d..649aba4562eab 100644 --- a/testing/dart/codec_test.dart +++ b/testing/dart/codec_test.dart @@ -34,6 +34,18 @@ void main() { ); }); + test('getNextFrame fails with invalid data', () async { + Uint8List data = await _getSkiaResource('flutter_logo.jpg').readAsBytes(); + data = Uint8List.view(data.buffer, 0, 4000); + final ui.Codec codec = await ui.instantiateImageCodec(data); + try { + await codec.getNextFrame(); + fail('exception not thrown'); + } catch(e) { + expect(e, exceptionWithMessage('Codec failed')); + } + }); + test('nextFrame', () async { final Uint8List data = await _getSkiaResource('test640x479.gif').readAsBytes(); final ui.Codec codec = await ui.instantiateImageCodec(data); 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); diff --git a/tools/generate_package_config/pubspec.yaml b/tools/generate_package_config/pubspec.yaml index 213dbb9423900..1afb2e21c5e42 100644 --- a/tools/generate_package_config/pubspec.yaml +++ b/tools/generate_package_config/pubspec.yaml @@ -5,4 +5,4 @@ environment: dependencies: yaml: any pub_semver: any - package_config: any + package_config: 1.9.3