// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter_web_ui/ui.dart' as ui; import 'package:flutter_web_ui/src/engine.dart'; import 'package:flutter_web_test/flutter_web_test.dart'; final ui.ParagraphStyle ahemStyle = ui.ParagraphStyle( fontFamily: 'ahem', fontSize: 10, ); const ui.ParagraphConstraints constraints = ui.ParagraphConstraints(width: 50); const ui.ParagraphConstraints infiniteConstraints = ui.ParagraphConstraints(width: double.infinity); ui.Paragraph build(ui.ParagraphStyle style, String text) { final ui.ParagraphBuilder builder = ui.ParagraphBuilder(style); builder.addText(text); return builder.build(); } typedef MeasurementTestBody = void Function(TextMeasurementService instance); /// Runs the same test twice - once with dom measurement and once with canvas /// measurement. void testMeasurements(String description, MeasurementTestBody body) { test( '$description (dom)', () => body(TextMeasurementService.domInstance), ); test( '$description (canvas)', () => body(TextMeasurementService.canvasInstance), ); } void main() { group('$RulerManager', () { final ui.ParagraphStyle s1 = ui.ParagraphStyle(fontFamily: 'sans-serif'); final ui.ParagraphStyle s2 = ui.ParagraphStyle( fontWeight: ui.FontWeight.bold, ); final ui.ParagraphStyle s3 = ui.ParagraphStyle(fontSize: 22.0); ParagraphGeometricStyle style1, style2, style3; ui.Paragraph style1Text1, style1Text2; // two paragraphs sharing style ui.Paragraph style2Text1, style3Text3; setUp(() { style1Text1 = build(s1, '1'); style1Text2 = build(s1, '2'); style2Text1 = build(s2, '1'); style3Text3 = build(s3, '3'); style1 = style1Text1.webOnlyGetParagraphGeometricStyle(); style2 = style2Text1.webOnlyGetParagraphGeometricStyle(); style3 = style3Text3.webOnlyGetParagraphGeometricStyle(); final ParagraphGeometricStyle style1_2 = style1Text2.webOnlyGetParagraphGeometricStyle(); expect(style1_2, style1); // styles must be equal despite different text }); test('caches rulers', () { final RulerManager rulerManager = RulerManager(rulerCacheCapacity: 2); ParagraphRuler ruler1, ruler2, ruler3; expect(rulerManager.rulerCacheCapacity, 2); expect(rulerManager.rulers.length, 0); // First ruler cached ruler1 = rulerManager.findOrCreateRuler(style1); expect(rulerManager.rulers.length, 1); expect(ruler1.hitCount, 1); // Increase hit count for style 1 ruler1 = rulerManager.findOrCreateRuler(style1); expect(rulerManager.rulers.length, 1); expect(ruler1.hitCount, 2); // Previous ruler reused rulerManager.findOrCreateRuler(style1); expect(rulerManager.rulers.length, 1); expect(ruler1.hitCount, 3); // Second ruler created and cached ruler2 = rulerManager.findOrCreateRuler(style2); expect(rulerManager.rulers.length, 2); expect(ruler1.hitCount, 3); expect(ruler2.hitCount, 1); // Increase hit count for style 2 rulerManager.findOrCreateRuler(style2); rulerManager.findOrCreateRuler(style2); rulerManager.findOrCreateRuler(style2); expect(rulerManager.rulers.length, 2); expect(ruler2.hitCount, 4); // Third ruler cached: it is ok to store more rulers that the cache // capacity because the cache is cleaned-up at the next microtask. ruler3 = rulerManager.findOrCreateRuler(style3); // Final ruler states expect(rulerManager.rulers.length, 3); expect(ruler1.hitCount, 3); expect(ruler2.hitCount, 4); expect(ruler3.hitCount, 1); // The least hit ruler isn't disposed yet. expect(ruler3.debugIsDisposed, isFalse); // Cleaning up the cache should bring its size down to capacity limit. rulerManager.cleanUpRulerCache(); expect(rulerManager.rulers.length, 2); expect(rulerManager.rulers, containsValue(ruler1)); // retained expect(rulerManager.rulers, containsValue(ruler2)); // retained expect(rulerManager.rulers, isNot(containsValue(ruler3))); // evicted expect(ruler1.debugIsDisposed, isFalse); expect(ruler2.debugIsDisposed, isFalse); expect(ruler3.debugIsDisposed, isTrue); ruler1 = rulerManager.rulers[style1]; expect(ruler1.style, style1); expect(ruler1.hitCount, 0); // hit counts are reset ruler2 = rulerManager.rulers[style2]; expect(ruler2.style, style2); expect(ruler2.hitCount, 0); // hit counts are reset }); }); group('$TextMeasurementService', () { setUp(() { TextMeasurementService.initialize(rulerCacheCapacity: 2); }); tearDown(() { TextMeasurementService.clearCache(); }); testMeasurements( 'preserves whitespace when measuring', (TextMeasurementService instance) { ui.Paragraph text; MeasurementResult result; // leading whitespaces text = build(ahemStyle, ' abc'); result = instance.measure(text, infiniteConstraints); expect(result.maxIntrinsicWidth, 60); expect(result.minIntrinsicWidth, 30); expect(result.height, 10); // trailing whitespaces text = build(ahemStyle, 'abc '); result = instance.measure(text, infiniteConstraints); expect(result.maxIntrinsicWidth, 60); expect(result.minIntrinsicWidth, 30); expect(result.height, 10); // mixed whitespaces text = build(ahemStyle, ' ab c '); result = instance.measure(text, infiniteConstraints); expect(result.maxIntrinsicWidth, 100); expect(result.minIntrinsicWidth, 20); expect(result.height, 10); // single whitespace text = build(ahemStyle, ' '); result = instance.measure(text, infiniteConstraints); expect(result.maxIntrinsicWidth, 10); expect(result.minIntrinsicWidth, 0); expect(result.height, 10); // whitespace only text = build(ahemStyle, ' '); result = instance.measure(text, infiniteConstraints); expect(result.maxIntrinsicWidth, 50); expect(result.minIntrinsicWidth, 0); expect(result.height, 10); }, ); testMeasurements( 'uses single-line when text can fit without wrapping', (TextMeasurementService instance) { final MeasurementResult result = instance.measure(build(ahemStyle, '12345'), constraints); // Should fit on a single line. expect(result.isSingleLine, true); expect(result.maxIntrinsicWidth, 50); expect(result.minIntrinsicWidth, 50); expect(result.width, 50); expect(result.height, 10); }, ); testMeasurements( 'uses multi-line for long text', (TextMeasurementService instance) { final MeasurementResult result = instance.measure(build(ahemStyle, '1234567890'), constraints); // The long text doesn't fit in 50px of width, so it needs to wrap. expect(result.isSingleLine, false); expect(result.maxIntrinsicWidth, 100); expect(result.minIntrinsicWidth, 100); expect(result.width, 50); expect(result.height, 20); }, ); testMeasurements( 'uses multi-line for text that contains new-line', (TextMeasurementService instance) { final MeasurementResult result = instance.measure(build(ahemStyle, '12\n34'), constraints); // Text containing newlines should always be drawn in multi-line mode. expect(result.isSingleLine, false); expect(result.maxIntrinsicWidth, 20); expect(result.minIntrinsicWidth, 20); expect(result.width, 50); expect(result.height, 20); }, ); testMeasurements('empty lines', (TextMeasurementService instance) { MeasurementResult result; // Empty lines in the beginning. result = instance.measure(build(ahemStyle, '\n\n1234'), constraints); expect(result.maxIntrinsicWidth, 40); expect(result.minIntrinsicWidth, 40); expect(result.height, 30); // Empty lines in the middle. result = instance.measure(build(ahemStyle, '12\n\n345'), constraints); expect(result.maxIntrinsicWidth, 30); expect(result.minIntrinsicWidth, 30); expect(result.height, 30); // This can only be done correctly in the canvas-based implementation. if (instance is CanvasTextMeasurementService) { // Empty lines in the end. result = instance.measure(build(ahemStyle, '1234\n\n'), constraints); expect(result.maxIntrinsicWidth, 40); expect(result.minIntrinsicWidth, 40); expect(result.height, 30); } }); test('takes letter spacing into account', () { const ui.ParagraphConstraints constraints = ui.ParagraphConstraints(width: 100); final ui.ParagraphBuilder normalBuilder = ui.ParagraphBuilder(ahemStyle); normalBuilder.addText('abc'); final ui.Paragraph normalText = normalBuilder.build(); final ui.ParagraphBuilder spacedBuilder = ui.ParagraphBuilder(ahemStyle); spacedBuilder.pushStyle(ui.TextStyle(letterSpacing: 1.5)); spacedBuilder.addText('abc'); final ui.Paragraph spacedText = spacedBuilder.build(); // Letter spacing is only supported via DOM measurement. final TextMeasurementService instance = TextMeasurementService.forParagraph(spacedText); expect(instance, isInstanceOf()); final MeasurementResult normalResult = instance.measure(normalText, constraints); final MeasurementResult spacedResult = instance.measure(spacedText, constraints); expect( normalResult.maxIntrinsicWidth < spacedResult.maxIntrinsicWidth, isTrue, ); }); test('takes word spacing into account', () { const ui.ParagraphConstraints constraints = ui.ParagraphConstraints(width: 100); final ui.ParagraphBuilder normalBuilder = ui.ParagraphBuilder(ahemStyle); normalBuilder.addText('a b c'); final ui.Paragraph normalText = normalBuilder.build(); final ui.ParagraphBuilder spacedBuilder = ui.ParagraphBuilder(ahemStyle); spacedBuilder.pushStyle(ui.TextStyle(wordSpacing: 1.5)); spacedBuilder.addText('a b c'); final ui.Paragraph spacedText = spacedBuilder.build(); // Word spacing is only supported via DOM measurement. final TextMeasurementService instance = TextMeasurementService.forParagraph(spacedText); expect(instance, isInstanceOf()); final MeasurementResult normalResult = instance.measure(normalText, constraints); final MeasurementResult spacedResult = instance.measure(spacedText, constraints); expect( normalResult.maxIntrinsicWidth < spacedResult.maxIntrinsicWidth, isTrue, ); }); testMeasurements('minIntrinsicWidth', (TextMeasurementService instance) { MeasurementResult result; // Simple case. result = instance.measure(build(ahemStyle, 'abc de fghi'), constraints); expect(result.minIntrinsicWidth, 40); // With new lines. result = instance.measure(build(ahemStyle, 'abcd\nef\nghi'), constraints); expect(result.minIntrinsicWidth, 40); // With trailing whitespace. result = instance.measure(build(ahemStyle, 'abcd efg'), constraints); expect(result.minIntrinsicWidth, 40); // With trailing whitespace and new lines. result = instance.measure(build(ahemStyle, 'abc \ndefg'), constraints); expect(result.minIntrinsicWidth, 40); // Very long text. result = instance.measure(build(ahemStyle, 'AAAAAAAAAAAA'), constraints); expect(result.minIntrinsicWidth, 120); }); testMeasurements('maxIntrinsicWidth', (TextMeasurementService instance) { MeasurementResult result; // Simple case. result = instance.measure(build(ahemStyle, 'abc de fghi'), constraints); expect(result.maxIntrinsicWidth, 110); // With new lines. result = instance.measure(build(ahemStyle, 'abcd\nef\nghi'), constraints); expect(result.maxIntrinsicWidth, 40); // With long whitespace. result = instance.measure(build(ahemStyle, 'abcd efg'), constraints); expect(result.maxIntrinsicWidth, 100); // With trailing whitespace. result = instance.measure(build(ahemStyle, 'abc def '), constraints); expect(result.maxIntrinsicWidth, 100); // With trailing whitespace and new lines. result = instance.measure(build(ahemStyle, 'abc \ndef '), constraints); expect(result.maxIntrinsicWidth, 60); // Very long text. result = instance.measure(build(ahemStyle, 'AAAAAAAAAAAA'), constraints); expect(result.maxIntrinsicWidth, 120); }); testMeasurements( 'respects text overflow', (TextMeasurementService instance) { final ui.ParagraphStyle overflowStyle = ui.ParagraphStyle( fontFamily: 'ahem', fontSize: 10, ellipsis: '...', ); MeasurementResult result; // The text shouldn't be broken into multiple lines, so the height should // be equal to a height of a single line. final ui.Paragraph longText = build( overflowStyle, 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', ); result = instance.measure(longText, constraints); expect(result.minIntrinsicWidth, 480); expect(result.maxIntrinsicWidth, 480); expect(result.height, 10); // The short prefix should make the text break into two lines, but the // second line should remain unbroken. final ui.Paragraph longTextShortPrefix = build( overflowStyle, 'AAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', ); result = instance.measure(longTextShortPrefix, constraints); expect(result.minIntrinsicWidth, 450); expect(result.maxIntrinsicWidth, 450); expect(result.height, 20); // The first line is overflowing so we should stop the measurement there // and there should be no second line (the short suffix shouldn't be rendered). final ui.Paragraph longTextShortSuffix = build( overflowStyle, 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAA', ); result = instance.measure(longTextShortSuffix, constraints); expect(result.minIntrinsicWidth, 450); expect(result.maxIntrinsicWidth, 450); // This can only be done correctly in the canvas-based implementation. if (instance is CanvasTextMeasurementService) { expect(result.height, 10); } }, ); testMeasurements('respects max lines', (TextMeasurementService instance) { final ui.ParagraphStyle maxlinesStyle = ui.ParagraphStyle( fontFamily: 'ahem', fontSize: 10, maxLines: 2, ); MeasurementResult result; // The height should be that of a single line. final ui.Paragraph oneline = build(maxlinesStyle, 'One line'); result = instance.measure(oneline, infiniteConstraints); expect(result.height, 10); // The height should respect max lines and be limited to two lines here. final ui.Paragraph threelines = build(maxlinesStyle, 'First\nSecond\nThird'); result = instance.measure(threelines, infiniteConstraints); expect(result.height, 20); }); test('canvas line breaks', () { // TODO(mdebbar): Add tests and make sure to cover the following edge cases: // 1. First chunk already overflows the width constraint. // 2. maxIntrinsicWidth in the presence of mandatory line breaks. // 3. minIntrinsicWidth in the presence of optional line breaks. // 4. empty lines in the middle. // 5. long text with a short prefix. // 6. long text with a short suffix. // 7. whitespace at end of line (shouldn't count towards minIntWidth, but counts towards max). // 8. long whitespace should still cause line break if not enough space for it. }); }); }