diff --git a/lib/web_ui/lib/src/engine/text/measurement.dart b/lib/web_ui/lib/src/engine/text/measurement.dart index 22cb42639318f..174145222f4c8 100644 --- a/lib/web_ui/lib/src/engine/text/measurement.dart +++ b/lib/web_ui/lib/src/engine/text/measurement.dart @@ -23,12 +23,9 @@ bool _newlinePredicate(int char) { prop == LineCharProperty.CR; } -/// Manages [ParagraphRuler] instances and caches them per unique -/// [ParagraphGeometricStyle]. -/// -/// All instances of [ParagraphRuler] should be created through this class. -class RulerManager { - RulerManager({required this.rulerCacheCapacity}) { +/// Hosts ruler DOM elements in a hidden container. +class RulerHost { + RulerHost() { _rulerHost.style ..position = 'fixed' ..visibility = 'hidden' @@ -41,8 +38,6 @@ class RulerManager { registerHotRestartListener(dispose); } - final int rulerCacheCapacity; - /// Hosts a cache of rulers that measure text. /// /// This element exists purely for organizational purposes. Otherwise the @@ -51,6 +46,28 @@ class RulerManager { /// purpose. final html.Element _rulerHost = html.Element.tag('flt-ruler-host'); + /// Releases the resources used by this [RulerHost]. + /// + /// After this is called, this object is no longer usable. + void dispose() { + _rulerHost.remove(); + } + + /// Adds an element used for measuring text as a child of [_rulerHost]. + void addElement(html.HtmlElement element) { + _rulerHost.append(element); + } +} + +/// Manages [ParagraphRuler] instances and caches them per unique +/// [ParagraphGeometricStyle]. +/// +/// All instances of [ParagraphRuler] should be created through this class. +class RulerManager extends RulerHost { + RulerManager({required this.rulerCacheCapacity}): super(); + + final int rulerCacheCapacity; + /// The cache of rulers used to measure text. /// /// Each ruler is keyed by paragraph style. This allows us to setup the @@ -78,13 +95,6 @@ class RulerManager { } } - /// Releases the resources used by this [RulerManager]. - /// - /// After this is called, this object is no longer usable. - void dispose() { - _rulerHost.remove(); - } - // Evicts all rulers from the cache. void _evictAllRulers() { _rulers.forEach((ParagraphGeometricStyle style, ParagraphRuler ruler) { @@ -129,11 +139,6 @@ class RulerManager { } } - /// Adds an element used for measuring text as a child of [_rulerHost]. - void addHostElement(html.DivElement element) { - _rulerHost.append(element); - } - /// Performs a cache lookup to find an existing [ParagraphRuler] for the given /// [style] and if it can't find one in the cache, it would create one. /// @@ -479,7 +484,7 @@ class DomTextMeasurementService extends TextMeasurementService { height = naturalHeight; } else { // Lazily compute [lineHeight] when [maxLines] is not null. - lineHeight = ruler.lineHeightDimensions!.height; + lineHeight = ruler.lineHeight; height = math.min(naturalHeight, maxLines * lineHeight); } @@ -587,8 +592,9 @@ class CanvasTextMeasurementService extends TextMeasurementService { } } + final double alphabeticBaseline = ruler.alphabeticBaseline; final int lineCount = linesCalculator.lines.length; - final double lineHeight = ruler.lineHeightDimensions!.height; + final double lineHeight = ruler.lineHeight; final double naturalHeight = lineCount * lineHeight; final int? maxLines = style.maxLines; final double height = maxLines == null @@ -598,8 +604,8 @@ class CanvasTextMeasurementService extends TextMeasurementService { final MeasurementResult result = MeasurementResult( constraints.width, isSingleLine: lineCount == 1, - alphabeticBaseline: ruler.alphabeticBaseline, - ideographicBaseline: ruler.alphabeticBaseline * _baselineRatioHack, + alphabeticBaseline: alphabeticBaseline, + ideographicBaseline: alphabeticBaseline * _baselineRatioHack, height: height, naturalHeight: naturalHeight, lineHeight: lineHeight, diff --git a/lib/web_ui/lib/src/engine/text/ruler.dart b/lib/web_ui/lib/src/engine/text/ruler.dart index f3a450a011f51..ee4ff2476bc51 100644 --- a/lib/web_ui/lib/src/engine/text/ruler.dart +++ b/lib/web_ui/lib/src/engine/text/ruler.dart @@ -9,7 +9,7 @@ String _buildCssFontString({ required ui.FontStyle? fontStyle, required ui.FontWeight? fontWeight, required double? fontSize, - required String? fontFamily, + required String fontFamily, }) { final StringBuffer result = StringBuffer(); @@ -81,7 +81,7 @@ class ParagraphGeometricStyle { /// /// - Always returns "Ahem" in tests. /// - Provides correct defaults when [fontFamily] doesn't have a value. - String? get effectiveFontFamily { + String get effectiveFontFamily { if (assertionsEnabled) { // In widget tests we use a predictable-size font "Ahem". This makes // widget tests predictable and less flaky. @@ -89,10 +89,11 @@ class ParagraphGeometricStyle { return 'Ahem'; } } - if (fontFamily == null || fontFamily!.isEmpty) { + final String? localFontFamily = fontFamily; + if (localFontFamily == null || localFontFamily.isEmpty) { return DomRenderer.defaultFontFamily; } - return fontFamily; + return localFontFamily; } String? _cssFontString; @@ -109,6 +110,24 @@ class ParagraphGeometricStyle { ); } + TextHeightStyle? _cachedHeightStyle; + + TextHeightStyle get textHeightStyle { + TextHeightStyle? style = _cachedHeightStyle; + if (style == null) { + style = TextHeightStyle( + fontFamily: effectiveFontFamily, + fontSize: fontSize ?? DomRenderer.defaultFontSize, + height: lineHeight, + // TODO(mdebbar): Pass the actual value when font features become supported + // https://github.com/flutter/flutter/issues/64595 + fontFeatures: null, + ); + _cachedHeightStyle = style; + } + return style; + } + @override bool operator ==(Object other) { if (identical(this, other)) { @@ -168,6 +187,40 @@ class ParagraphGeometricStyle { } } +/// Contains all styles that have an effect on the height of text. +/// +/// This is useful as a cache key for [TextHeightRuler]. It's more efficient +/// than using the entire [ParagraphGeometricStyle] as a cache key. +class TextHeightStyle { + TextHeightStyle({ + required this.fontFamily, + required this.fontSize, + required this.height, + required this.fontFeatures, + }); + + final String fontFamily; + final double fontSize; + final double? height; + final List? fontFeatures; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is TextHeightStyle && other.hashCode == hashCode; + } + + @override + late final int hashCode = ui.hashValues( + fontFamily, + fontSize, + height, + ui.hashList(fontFeatures), + ); +} + /// Provides text dimensions found on [_element]. The idea behind this class is /// to allow the [ParagraphRuler] to mutate multiple dom elements and allow /// consumers to lazily read the measurements. @@ -293,6 +346,21 @@ class TextDimensions { _invalidateBoundsCache(); } + void applyHeightStyle(TextHeightStyle textHeightStyle) { + final String fontFamily = textHeightStyle.fontFamily; + final double fontSize = textHeightStyle.fontSize; + final html.CssStyleDeclaration style = _element.style; + style + ..fontSize = '${fontSize.floor()}px' + ..fontFamily = canonicalizeFontFamily(fontFamily); + + final double? height = textHeightStyle.height; + if (height != null) { + style.lineHeight = height.toString(); + } + _invalidateBoundsCache(); + } + /// Appends element and probe to hostElement that is setup for a specific /// TextStyle. void appendToHost(html.HtmlElement hostElement) { @@ -323,6 +391,67 @@ class TextDimensions { } } +/// Performs height measurement for the given [textHeightStyle]. +/// +/// The two results of this ruler's measurement are: +/// +/// 1. [alphabeticBaseline]. +/// 2. [height]. +class TextHeightRuler { + TextHeightRuler(this.textHeightStyle, this.rulerHost); + + final TextHeightStyle textHeightStyle; + final RulerHost rulerHost; + + // Elements used to measure the line-height metric. + late final html.HtmlElement _probe = _createProbe(); + late final html.HtmlElement _host = _createHost(); + final TextDimensions _dimensions = TextDimensions(html.ParagraphElement()); + + /// The alphabetic baseline for this ruler's [textHeightStyle]. + late final double alphabeticBaseline = _probe.getBoundingClientRect().bottom.toDouble(); + + /// The height for this ruler's [textHeightStyle]. + late final double height = _dimensions.height; + + html.HtmlElement _createHost() { + final html.DivElement host = html.DivElement(); + host.style + ..visibility = 'hidden' + ..position = 'absolute' + ..top = '0' + ..left = '0' + ..display = 'flex' + ..flexDirection = 'row' + ..alignItems = 'baseline' + ..margin = '0' + ..border = '0' + ..padding = '0'; + + if (assertionsEnabled) { + host.setAttribute('data-ruler', 'line-height'); + } + + _dimensions.applyHeightStyle(textHeightStyle); + + // Force single-line (even if wider than screen) and preserve whitespaces. + _dimensions._element.style.whiteSpace = 'pre'; + + // To measure line-height, all we need is a whitespace. + _dimensions.updateTextToSpace(); + + _dimensions.appendToHost(host); + rulerHost.addElement(host); + return host; + } + + html.HtmlElement _createProbe() { + final html.HtmlElement probe = html.DivElement(); + _host.append(probe); + return probe; + } +} + /// Performs 4 types of measurements: /// /// 1. Single line: can be prepared by calling [measureAsSingleLine]. @@ -375,25 +504,14 @@ class ParagraphRuler { /// but a [ParagraphRuler] can only belong to one [RulerManager]. final RulerManager rulerManager; - /// Probe to use for measuring alphabetic base line. - final html.HtmlElement _probe = html.DivElement(); - - /// Cached value of alphabetic base line. - double? _cachedAlphabeticBaseline; - ParagraphRuler(this.style, this.rulerManager) { _configureSingleLineHostElements(); - // Since alphabeticbaseline will be same regardless of constraints. - // We can measure it using a probe on the single line dimensions - // host. - _singleLineHost.append(_probe); _configureMinIntrinsicHostElements(); _configureConstrainedHostElements(); } /// The alphabetic baseline of the paragraph being measured. - double get alphabeticBaseline => - (_cachedAlphabeticBaseline ??= _probe.getBoundingClientRect().bottom as double?)!; + double get alphabeticBaseline => _textHeightRuler.alphabeticBaseline; // Elements used to measure single-line metrics. final html.DivElement _singleLineHost = html.DivElement(); @@ -411,18 +529,10 @@ class ParagraphRuler { TextDimensions(html.ParagraphElement()); // Elements used to measure the line-height metric. - html.DivElement? _lineHeightHost; - TextDimensions? _lineHeightDimensions; - TextDimensions? get lineHeightDimensions { - // Lazily create the elements for line-height measurement since they are not - // always needed. - if (_lineHeightDimensions == null) { - _lineHeightHost = html.DivElement(); - _lineHeightDimensions = TextDimensions(html.ParagraphElement()); - _configureLineHeightHostElements(); - _lineHeightHost!.append(_probe); - } - return _lineHeightDimensions; + late final TextHeightRuler _textHeightRuler = + TextHeightRuler(style.textHeightStyle, rulerManager); + double get lineHeight { + return _textHeightRuler.height; } /// The number of times this ruler was used this frame. @@ -475,7 +585,7 @@ class ParagraphRuler { singleLineDimensions._element.style.whiteSpace = 'pre'; singleLineDimensions.appendToHost(_singleLineHost); - rulerManager.addHostElement(_singleLineHost); + rulerManager.addElement(_singleLineHost); } void _configureMinIntrinsicHostElements() { @@ -508,7 +618,7 @@ class ParagraphRuler { ..whiteSpace = 'pre-line'; _minIntrinsicHost.append(minIntrinsicDimensions._element); - rulerManager.addHostElement(_minIntrinsicHost); + rulerManager.addElement(_minIntrinsicHost); } void _configureConstrainedHostElements() { @@ -542,36 +652,7 @@ class ParagraphRuler { } constrainedDimensions.appendToHost(_constrainedHost); - rulerManager.addHostElement(_constrainedHost); - } - - void _configureLineHeightHostElements() { - _lineHeightHost!.style - ..visibility = 'hidden' - ..position = 'absolute' - ..top = '0' - ..left = '0' - ..display = 'flex' - ..flexDirection = 'row' - ..alignItems = 'baseline' - ..margin = '0' - ..border = '0' - ..padding = '0'; - - if (assertionsEnabled) { - _lineHeightHost!.setAttribute('data-ruler', 'line-height'); - } - - lineHeightDimensions!.applyStyle(style); - - // Force single-line (even if wider than screen) and preserve whitespaces. - lineHeightDimensions!._element.style.whiteSpace = 'pre'; - - // To measure line-height, all we need is a whitespace. - lineHeightDimensions!.updateTextToSpace(); - - lineHeightDimensions!.appendToHost(_lineHeightHost!); - rulerManager.addHostElement(_lineHeightHost!); + rulerManager.addElement(_constrainedHost); } /// The paragraph being measured. @@ -803,7 +884,7 @@ class ParagraphRuler { final double maxLinesLimit = style.maxLines == null ? double.infinity - : style.maxLines! * lineHeightDimensions!.height; + : style.maxLines! * lineHeight; html.Rectangle? previousRect; for (html.Rectangle rect in clientRects) { @@ -847,7 +928,7 @@ class ParagraphRuler { _singleLineHost.remove(); _minIntrinsicHost.remove(); _constrainedHost.remove(); - _lineHeightHost?.remove(); + _textHeightRuler._host.remove(); assert(() { _debugIsDisposed = true; return true; diff --git a/lib/web_ui/test/text/measurement_test.dart b/lib/web_ui/test/text/measurement_test.dart index 2b5c92571a116..7fd73f8e0f94e 100644 --- a/lib/web_ui/test/text/measurement_test.dart +++ b/lib/web_ui/test/text/measurement_test.dart @@ -254,6 +254,7 @@ void testMain() async { // Should fit on a single line. expect(result.isSingleLine, true); + expect(result.alphabeticBaseline, 8); expect(result.maxIntrinsicWidth, 50); expect(result.minIntrinsicWidth, 50); expect(result.width, 50); @@ -274,6 +275,7 @@ void testMain() async { // The long text doesn't fit in 70px of width, so it needs to wrap. result = instance.measure(build(ahemStyle, 'foo bar baz'), constraints); expect(result.isSingleLine, false); + expect(result.alphabeticBaseline, 8); expect(result.maxIntrinsicWidth, 110); expect(result.minIntrinsicWidth, 30); expect(result.width, 70); @@ -299,6 +301,7 @@ void testMain() async { // The long text doesn't fit in 50px of width, so it needs to wrap. result = instance.measure(build(ahemStyle, '1234567890'), constraints); expect(result.isSingleLine, false); + expect(result.alphabeticBaseline, 8); expect(result.maxIntrinsicWidth, 100); expect(result.minIntrinsicWidth, 100); expect(result.width, 50); @@ -318,6 +321,7 @@ void testMain() async { result = instance.measure(build(ahemStyle, 'abcdefghijk lm'), constraints); expect(result.isSingleLine, false); + expect(result.alphabeticBaseline, 8); expect(result.maxIntrinsicWidth, 140); expect(result.minIntrinsicWidth, 110); expect(result.width, 50); @@ -340,6 +344,7 @@ void testMain() async { ui.ParagraphConstraints(width: 8); result = instance.measure(build(ahemStyle, 'AA'), narrowConstraints); expect(result.isSingleLine, false); + expect(result.alphabeticBaseline, 8); expect(result.maxIntrinsicWidth, 20); expect(result.minIntrinsicWidth, 20); expect(result.width, 8); @@ -358,6 +363,7 @@ void testMain() async { // Extremely narrow constraints with new line in the middle. result = instance.measure(build(ahemStyle, 'AA\nA'), narrowConstraints); expect(result.isSingleLine, false); + expect(result.alphabeticBaseline, 8); expect(result.maxIntrinsicWidth, 20); expect(result.minIntrinsicWidth, 20); expect(result.width, 8); @@ -377,6 +383,7 @@ void testMain() async { // Extremely narrow constraints with new line in the end. result = instance.measure(build(ahemStyle, 'AAA\n'), narrowConstraints); expect(result.isSingleLine, false); + expect(result.alphabeticBaseline, 8); expect(result.maxIntrinsicWidth, 30); expect(result.minIntrinsicWidth, 30); expect(result.width, 8);