From bfdc3a291ba08ee0df07a80f0709c8470ed8a739 Mon Sep 17 00:00:00 2001 From: Pasha Stetsenko Date: Wed, 10 Aug 2022 01:13:27 -0700 Subject: [PATCH] feat: Structured text and text styles (#1830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces the notions of structured text, and text styles, to support rendering of rich text bodies. Specifically, we recognize that sometimes in games one needs to render pieces of text that are larger than a single word or even a single paragraph. These pieces may include: books, quest descriptions, mission objectives, tutorials, in-game help system, dialogues, etc. Rendering such a piece of text is non-trivial, however. In order to tackle this problem, I break into the following parts: Text structure, represented as a tree of Nodes. The nodes describe the logical structure of the text, for example the document may contain a header, and then several paragraphs, and a list, where the list contains some list items, some of which having possibly several paragraphs, etc. This structure is similar to how in HTML the text is marked up with HTML tags. Text styles are struct-like classes that contain properties describing how the text is to be styled: font size, font renderer, borders, backgrounds, margins, padding, etc. This representation is also tree-like, so that for example text inside paragraphs can have different style than text within headers, and paragraphs within lists can have different margins. A text style is similar to a stylesheet in HTML. Text elements are the result of applying the document style to a document node: they are the "prepared" and laid out pieces, ready to be rendered. Elements are a bit like mini-components, or perhaps text "particles" in a particle system. --- examples/lib/stories/rendering/rendering.dart | 7 + .../stories/rendering/rich_text_example.dart | 72 ++++++++ .../lib/src/components/text_component.dart | 2 +- .../flame/lib/src/text/common/text_line.dart | 2 +- packages/flame/lib/src/text/common/utils.dart | 12 ++ .../lib/src/text/elements/block_element.dart | 12 ++ .../flame/lib/src/text/elements/element.dart | 22 +++ .../lib/src/text/elements/group_element.dart | 20 +++ .../lib/src/text/elements/rect_element.dart | 21 +++ .../lib/src/text/elements/rrect_element.dart | 25 +++ .../lib/src/text/elements/text_element.dart | 12 ++ .../src/text/formatters/text_formatter.dart | 2 +- .../text/inline/sprite_font_text_element.dart | 2 +- .../lib/src/text/inline/text_element.dart | 22 --- .../inline/text_painter_text_element.dart | 5 +- packages/flame/lib/src/text/nodes.dart | 41 +++++ .../flame/lib/src/text/nodes/block_node.dart | 55 ++++++ .../lib/src/text/nodes/document_node.dart | 60 +++++++ .../flame/lib/src/text/nodes/header_node.dart | 9 + .../lib/src/text/nodes/paragraph_node.dart | 59 +++++++ .../lib/src/text/styles/background_style.dart | 57 ++++++ .../lib/src/text/styles/block_style.dart | 30 ++++ .../lib/src/text/styles/document_style.dart | 167 ++++++++++++++++++ .../flame/lib/src/text/styles/overflow.dart | 18 ++ packages/flame/lib/src/text/styles/style.dart | 40 +++++ packages/flame/lib/text.dart | 8 + 26 files changed, 754 insertions(+), 28 deletions(-) create mode 100644 examples/lib/stories/rendering/rich_text_example.dart create mode 100644 packages/flame/lib/src/text/common/utils.dart create mode 100644 packages/flame/lib/src/text/elements/block_element.dart create mode 100644 packages/flame/lib/src/text/elements/element.dart create mode 100644 packages/flame/lib/src/text/elements/group_element.dart create mode 100644 packages/flame/lib/src/text/elements/rect_element.dart create mode 100644 packages/flame/lib/src/text/elements/rrect_element.dart create mode 100644 packages/flame/lib/src/text/elements/text_element.dart delete mode 100644 packages/flame/lib/src/text/inline/text_element.dart create mode 100644 packages/flame/lib/src/text/nodes.dart create mode 100644 packages/flame/lib/src/text/nodes/block_node.dart create mode 100644 packages/flame/lib/src/text/nodes/document_node.dart create mode 100644 packages/flame/lib/src/text/nodes/header_node.dart create mode 100644 packages/flame/lib/src/text/nodes/paragraph_node.dart create mode 100644 packages/flame/lib/src/text/styles/background_style.dart create mode 100644 packages/flame/lib/src/text/styles/block_style.dart create mode 100644 packages/flame/lib/src/text/styles/document_style.dart create mode 100644 packages/flame/lib/src/text/styles/overflow.dart create mode 100644 packages/flame/lib/src/text/styles/style.dart diff --git a/examples/lib/stories/rendering/rendering.dart b/examples/lib/stories/rendering/rendering.dart index 7120c1eefa8..62c6c3064fb 100644 --- a/examples/lib/stories/rendering/rendering.dart +++ b/examples/lib/stories/rendering/rendering.dart @@ -6,6 +6,7 @@ import 'package:examples/stories/rendering/layers_example.dart'; import 'package:examples/stories/rendering/nine_tile_box_example.dart'; import 'package:examples/stories/rendering/particles_example.dart'; import 'package:examples/stories/rendering/particles_interactive_example.dart'; +import 'package:examples/stories/rendering/rich_text_example.dart'; import 'package:examples/stories/rendering/text_example.dart'; import 'package:flame/game.dart'; import 'package:flutter/material.dart'; @@ -59,5 +60,11 @@ void addRenderingStories(Dashbook dashbook) { ), codeLink: baseLink('rendering/particles_interactive_example.dart'), info: ParticlesInteractiveExample.description, + ) + ..add( + 'Rich Text', + (_) => GameWidget(game: RichTextExample()), + codeLink: baseLink('rendering/rich_text_example.dart'), + info: RichTextExample.description, ); } diff --git a/examples/lib/stories/rendering/rich_text_example.dart b/examples/lib/stories/rendering/rich_text_example.dart new file mode 100644 index 00000000000..12fa21f0b67 --- /dev/null +++ b/examples/lib/stories/rendering/rich_text_example.dart @@ -0,0 +1,72 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame/text.dart'; +import 'package:flutter/painting.dart'; + +class RichTextExample extends FlameGame { + static String description = ''; + + @override + Color backgroundColor() => const Color(0xFF888888); + + @override + Future onLoad() async { + add(MyTextComponent()..position = Vector2.all(100)); + } +} + +class MyTextComponent extends PositionComponent { + late final Element element; + + @override + Future onLoad() async { + final style = DocumentStyle( + width: 400, + height: 200, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), + background: BackgroundStyle( + color: const Color(0xFFFFFFEE), + borderColor: const Color(0xFF000000), + borderWidth: 2.0, + ), + paragraphStyle: BlockStyle( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 6), + background: BackgroundStyle( + color: const Color(0xFFFFF0CB), + borderColor: const Color(0xFFAAAAAA), + ), + ), + ); + final document = DocumentNode([ + ParagraphNode.simple( + 'Anything could be true. The so-called laws of nature were nonsense.', + ), + ParagraphNode.simple( + 'The law of gravity was nonsense. "If I wished," O\'Brien had said, ' + '"I could float off this floor like a soap bubble." Winston worked it ' + 'out. "If he thinks he floats off the floor, and I simultaneously ' + 'think I can see him do it, then the thing happens."', + ), + ParagraphNode.simple( + 'Suddenly, like a lump of submerged wreckage breaking the surface of ' + 'water, the thought burst into his mind: "It doesn\'t really happen. ' + 'We imagine it. It is hallucination."', + ), + ParagraphNode.simple( + 'He pushed the thought under instantly. The fallacy was obvious. It ' + 'presupposed that somewhere or other, outside oneself, there was a ' + '"real" world where "real" things happened. But how could there be ' + 'such a world? What knowledge have we of anything, save through our ' + 'own minds? All happenings are in the mind. Whatever happens in all ' + 'minds, truly happens.', + ), + ]); + element = document.format(style); + } + + @override + void render(Canvas canvas) { + element.render(canvas); + } +} diff --git a/packages/flame/lib/src/components/text_component.dart b/packages/flame/lib/src/components/text_component.dart index 7dc0209dd83..6ace1da6c18 100644 --- a/packages/flame/lib/src/components/text_component.dart +++ b/packages/flame/lib/src/components/text_component.dart @@ -1,8 +1,8 @@ import 'dart:ui'; import 'package:flame/components.dart'; +import 'package:flame/src/text/elements/text_element.dart'; import 'package:flame/src/text/formatter_text_renderer.dart'; -import 'package:flame/src/text/inline/text_element.dart'; import 'package:flame/src/text/text_renderer.dart'; import 'package:flutter/painting.dart'; import 'package:meta/meta.dart'; diff --git a/packages/flame/lib/src/text/common/text_line.dart b/packages/flame/lib/src/text/common/text_line.dart index 632df774b68..45856b27b0e 100644 --- a/packages/flame/lib/src/text/common/text_line.dart +++ b/packages/flame/lib/src/text/common/text_line.dart @@ -1,5 +1,5 @@ import 'package:flame/src/text/common/line_metrics.dart'; -import 'package:flame/src/text/inline/text_element.dart'; +import 'package:flame/src/text/elements/text_element.dart'; /// [TextLine] is an abstract class describing a single line (or a fragment of /// a line) of a laid-out text. diff --git a/packages/flame/lib/src/text/common/utils.dart b/packages/flame/lib/src/text/common/utils.dart new file mode 100644 index 00000000000..a869f77c9d7 --- /dev/null +++ b/packages/flame/lib/src/text/common/utils.dart @@ -0,0 +1,12 @@ +import 'dart:math'; + +import 'package:meta/meta.dart'; + +@internal +double collapseMargin(double margin1, double margin2) { + if (margin1 >= 0) { + return (margin2 < 0) ? margin1 + margin2 : max(margin1, margin2); + } else { + return (margin2 < 0) ? min(margin1, margin2) : margin1 + margin2; + } +} diff --git a/packages/flame/lib/src/text/elements/block_element.dart b/packages/flame/lib/src/text/elements/block_element.dart new file mode 100644 index 00000000000..dccd955b552 --- /dev/null +++ b/packages/flame/lib/src/text/elements/block_element.dart @@ -0,0 +1,12 @@ +import 'package:flame/src/text/elements/element.dart'; + +/// [BlockElement] is the base class for [Element]s with rectangular shape and +/// "block" placement rules. +/// +/// Within HTML, this corresponds to elements with `display: block` property, +/// such as `
` or `
`. +abstract class BlockElement extends Element { + BlockElement(this.width, this.height); + double width; + double height; +} diff --git a/packages/flame/lib/src/text/elements/element.dart b/packages/flame/lib/src/text/elements/element.dart new file mode 100644 index 00000000000..d4114a595d2 --- /dev/null +++ b/packages/flame/lib/src/text/elements/element.dart @@ -0,0 +1,22 @@ +import 'dart:ui'; + +/// An [Element] is a basic building block of a rich-text document. +/// +/// Elements are concrete and "physical": they are objects that are ready to be +/// rendered on a canvas. This property distinguishes them from Nodes (which are +/// structured pieces of text), and from Styles (which are descriptors for how +/// arbitrary pieces of text ought to be rendered). +/// +/// Elements are at the final stage of the text rendering pipeline, they are +/// created during the layout step. +abstract class Element { + void translate(double dx, double dy); + + /// Renders the element on the [canvas], at coordinates determined during the + /// layout. + /// + /// In order to render the element at a different location, consider either + /// calling the [translate] method, or applying a translation transform to the + /// canvas beforehand. + void render(Canvas canvas); +} diff --git a/packages/flame/lib/src/text/elements/group_element.dart b/packages/flame/lib/src/text/elements/group_element.dart new file mode 100644 index 00000000000..340d96a8bbd --- /dev/null +++ b/packages/flame/lib/src/text/elements/group_element.dart @@ -0,0 +1,20 @@ +import 'dart:ui'; + +import 'package:flame/src/text/elements/block_element.dart'; +import 'package:flame/src/text/elements/element.dart'; + +class GroupElement extends BlockElement { + GroupElement(super.width, super.height, this.children); + + final List children; + + @override + void translate(double dx, double dy) { + children.forEach((child) => child.translate(dx, dy)); + } + + @override + void render(Canvas canvas) { + children.forEach((child) => child.render(canvas)); + } +} diff --git a/packages/flame/lib/src/text/elements/rect_element.dart b/packages/flame/lib/src/text/elements/rect_element.dart new file mode 100644 index 00000000000..9eadc303a02 --- /dev/null +++ b/packages/flame/lib/src/text/elements/rect_element.dart @@ -0,0 +1,21 @@ +import 'dart:ui'; + +import 'package:flame/src/text/elements/element.dart'; + +class RectElement extends Element { + RectElement(double width, double height, this._paint) + : _rect = Rect.fromLTWH(0, 0, width, height); + + Rect _rect; + final Paint _paint; + + @override + void translate(double dx, double dy) { + _rect = _rect.translate(dx, dy); + } + + @override + void render(Canvas canvas) { + canvas.drawRect(_rect, _paint); + } +} diff --git a/packages/flame/lib/src/text/elements/rrect_element.dart b/packages/flame/lib/src/text/elements/rrect_element.dart new file mode 100644 index 00000000000..aaed7c94571 --- /dev/null +++ b/packages/flame/lib/src/text/elements/rrect_element.dart @@ -0,0 +1,25 @@ +import 'dart:ui'; + +import 'package:flame/src/text/elements/element.dart'; + +class RRectElement extends Element { + RRectElement( + double width, + double height, + double radius, + this._paint, + ) : _rrect = RRect.fromLTRBR(0, 0, width, height, Radius.circular(radius)); + + RRect _rrect; + final Paint _paint; + + @override + void translate(double dx, double dy) { + _rrect = _rrect.shift(Offset(dx, dy)); + } + + @override + void render(Canvas canvas) { + canvas.drawRRect(_rrect, _paint); + } +} diff --git a/packages/flame/lib/src/text/elements/text_element.dart b/packages/flame/lib/src/text/elements/text_element.dart new file mode 100644 index 00000000000..7e45cdd4dbe --- /dev/null +++ b/packages/flame/lib/src/text/elements/text_element.dart @@ -0,0 +1,12 @@ +import 'package:flame/src/text/common/text_line.dart'; +import 'package:flame/src/text/elements/element.dart'; + +/// [TextElement] is the base class describing a span of text that has *inline* +/// placement rules. +/// +/// Concrete implementations of this class must know how to perform own layout +/// (i.e. determine the exact placement and size of each internal piece), and +/// then render on a canvas afterwards. +abstract class TextElement extends Element { + TextLine get lastLine; +} diff --git a/packages/flame/lib/src/text/formatters/text_formatter.dart b/packages/flame/lib/src/text/formatters/text_formatter.dart index 46b30f89064..2fffd90b921 100644 --- a/packages/flame/lib/src/text/formatters/text_formatter.dart +++ b/packages/flame/lib/src/text/formatters/text_formatter.dart @@ -1,4 +1,4 @@ -import 'package:flame/src/text/inline/text_element.dart'; +import 'package:flame/src/text/elements/text_element.dart'; /// [TextFormatter] is an abstract interface for a class that can convert an /// arbitrary string of text into a renderable [TextElement]. diff --git a/packages/flame/lib/src/text/inline/sprite_font_text_element.dart b/packages/flame/lib/src/text/inline/sprite_font_text_element.dart index 9eb0c7862bd..5c9d8ef1c37 100644 --- a/packages/flame/lib/src/text/inline/sprite_font_text_element.dart +++ b/packages/flame/lib/src/text/inline/sprite_font_text_element.dart @@ -3,7 +3,7 @@ import 'dart:ui'; import 'package:flame/src/text/common/line_metrics.dart'; import 'package:flame/src/text/common/text_line.dart'; -import 'package:flame/src/text/inline/text_element.dart'; +import 'package:flame/src/text/elements/text_element.dart'; class SpriteFontTextElement extends TextElement implements TextLine { SpriteFontTextElement({ diff --git a/packages/flame/lib/src/text/inline/text_element.dart b/packages/flame/lib/src/text/inline/text_element.dart deleted file mode 100644 index 3aa551ea12a..00000000000 --- a/packages/flame/lib/src/text/inline/text_element.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:ui' hide LineMetrics; - -import 'package:flame/src/text/common/text_line.dart'; - -/// [TextElement] is the base class describing a span of text that has *inline* -/// placement rules. -/// -/// Concrete implementations of this class must know how to perform own layout -/// (i.e. determine the exact placement and size of each internal piece), and -/// then render on a canvas afterwards. -abstract class TextElement { - TextLine get lastLine; - - /// Renders the text on the [canvas], at positions determined during the - /// layout. - /// - /// This method should only be invoked after the text was laid out. - /// - /// In order to render the text at a different location, consider applying a - /// translation transform to the canvas. - void render(Canvas canvas); -} diff --git a/packages/flame/lib/src/text/inline/text_painter_text_element.dart b/packages/flame/lib/src/text/inline/text_painter_text_element.dart index 4d0b62aa2aa..9aee5bdc4b6 100644 --- a/packages/flame/lib/src/text/inline/text_painter_text_element.dart +++ b/packages/flame/lib/src/text/inline/text_painter_text_element.dart @@ -2,10 +2,11 @@ import 'dart:ui'; import 'package:flame/src/text/common/line_metrics.dart'; import 'package:flame/src/text/common/text_line.dart'; -import 'package:flame/src/text/inline/text_element.dart'; +import 'package:flame/src/text/elements/element.dart'; +import 'package:flame/src/text/elements/text_element.dart'; import 'package:flutter/rendering.dart' show TextBaseline, TextPainter; -class TextPainterTextElement extends TextElement implements TextLine { +class TextPainterTextElement extends TextElement implements TextLine, Element { TextPainterTextElement(this._textPainter) : _box = LineMetrics( ascent: _textPainter diff --git a/packages/flame/lib/src/text/nodes.dart b/packages/flame/lib/src/text/nodes.dart new file mode 100644 index 00000000000..71e4f934111 --- /dev/null +++ b/packages/flame/lib/src/text/nodes.dart @@ -0,0 +1,41 @@ +import 'package:flame/src/text/nodes/block_node.dart'; + +class GroupBlockNode extends BlockNode { + GroupBlockNode(this.children); + + final List children; +} + +class BlockquoteNode extends GroupBlockNode { + BlockquoteNode(super.children); +} + +abstract class TextNode {} + +class PlainTextNode extends TextNode { + PlainTextNode(this.text); + + final String text; +} + +class GroupTextNode extends TextNode { + GroupTextNode(this.children); + + final List children; +} + +class BoldTextNode extends GroupTextNode { + BoldTextNode(super.children); +} + +class ItalicTextNode extends GroupTextNode { + ItalicTextNode(super.children); +} + +class StrikethroughTextNode extends GroupTextNode { + StrikethroughTextNode(super.children); +} + +class HighlightedTextNode extends GroupTextNode { + HighlightedTextNode(super.children); +} diff --git a/packages/flame/lib/src/text/nodes/block_node.dart b/packages/flame/lib/src/text/nodes/block_node.dart new file mode 100644 index 00000000000..8d16b9e6dce --- /dev/null +++ b/packages/flame/lib/src/text/nodes/block_node.dart @@ -0,0 +1,55 @@ +import 'package:flame/src/text/elements/element.dart'; +import 'package:flame/src/text/elements/group_element.dart'; +import 'package:flame/src/text/elements/rect_element.dart'; +import 'package:flame/src/text/elements/rrect_element.dart'; +import 'package:flame/src/text/styles/background_style.dart'; + +/// An abstract base class for all entities with "block" placement rules. +abstract class BlockNode { + Element? makeBackground(BackgroundStyle? style, double width, double height) { + if (style == null) { + return null; + } + final out = []; + final backgroundPaint = style.backgroundPaint; + final borderPaint = style.borderPaint; + final borders = style.borderWidths; + final radius = style.borderRadius; + + if (backgroundPaint != null) { + if (radius == 0) { + out.add(RectElement(width, height, backgroundPaint)); + } else { + out.add(RRectElement(width, height, radius, backgroundPaint)); + } + } + if (borderPaint != null) { + if (radius == 0) { + out.add( + RectElement( + width - borders.horizontal / 2, + height - borders.vertical / 2, + borderPaint, + )..translate(borders.left / 2, borders.top / 2), + ); + } else { + out.add( + RRectElement( + width - borders.horizontal / 2, + height - borders.vertical / 2, + radius, + borderPaint, + )..translate(borders.left / 2, borders.top / 2), + ); + } + } + if (out.isEmpty) { + return null; + } + if (out.length == 1) { + return out.first; + } else { + return GroupElement(width, height, out); + } + } +} diff --git a/packages/flame/lib/src/text/nodes/document_node.dart b/packages/flame/lib/src/text/nodes/document_node.dart new file mode 100644 index 00000000000..53b6fc9067b --- /dev/null +++ b/packages/flame/lib/src/text/nodes/document_node.dart @@ -0,0 +1,60 @@ +import 'dart:math'; + +import 'package:flame/src/text/common/utils.dart'; +import 'package:flame/src/text/elements/element.dart'; +import 'package:flame/src/text/elements/group_element.dart'; +import 'package:flame/src/text/nodes.dart'; +import 'package:flame/src/text/nodes/paragraph_node.dart'; +import 'package:flame/src/text/styles/document_style.dart'; +import 'package:flutter/painting.dart'; + +class DocumentNode extends GroupBlockNode { + DocumentNode(super.children); + + /// Applies [style] to this document, producing an object that can be rendered + /// on a canvas. Parameters [width] and [height] serve as the fallback values + /// if they were not specified in the style itself. However, they are ignored + /// if `style.width` and `style.height` are provided. + Element format(DocumentStyle style, {double? width, double? height}) { + assert( + style.width != null || width != null, + 'Width must be either provided explicitly or set in the stylesheet', + ); + final out = []; + final border = style.backgroundStyle?.borderWidths ?? EdgeInsets.zero; + final padding = style.padding; + + final pageWidth = style.width ?? width!; + final contentWidth = pageWidth - padding.horizontal - border.horizontal; + final horizontalOffset = padding.left + border.left; + var verticalOffset = border.top; + var currentMargin = padding.top; + for (final node in children) { + final blockStyle = style.styleFor(node); + verticalOffset += collapseMargin(currentMargin, blockStyle.margin.top); + final nodeElement = (node as ParagraphNode).format( + blockStyle, + parentWidth: contentWidth, + ); + nodeElement.translate(horizontalOffset, verticalOffset); + out.add(nodeElement); + currentMargin = blockStyle.margin.bottom; + verticalOffset += nodeElement.height; + } + final pageHeight = max( + height ?? 0, + verticalOffset + + collapseMargin(currentMargin, padding.bottom) + + border.bottom, + ); + final background = makeBackground( + style.backgroundStyle, + pageWidth, + pageHeight, + ); + if (background != null) { + out.insert(0, background); + } + return GroupElement(pageWidth, pageHeight, out); + } +} diff --git a/packages/flame/lib/src/text/nodes/header_node.dart b/packages/flame/lib/src/text/nodes/header_node.dart new file mode 100644 index 00000000000..eea46f12a23 --- /dev/null +++ b/packages/flame/lib/src/text/nodes/header_node.dart @@ -0,0 +1,9 @@ +import 'package:flame/src/text/nodes.dart'; +import 'package:flame/src/text/nodes/block_node.dart'; + +class HeaderNode extends BlockNode { + HeaderNode(this.child, {required this.level}); + + final GroupTextNode child; + final int level; +} diff --git a/packages/flame/lib/src/text/nodes/paragraph_node.dart b/packages/flame/lib/src/text/nodes/paragraph_node.dart new file mode 100644 index 00000000000..9a369fe10d1 --- /dev/null +++ b/packages/flame/lib/src/text/nodes/paragraph_node.dart @@ -0,0 +1,59 @@ +import 'package:flame/src/text/elements/block_element.dart'; +import 'package:flame/src/text/elements/group_element.dart'; +import 'package:flame/src/text/formatters/text_painter_text_formatter.dart'; +import 'package:flame/src/text/inline/text_painter_text_element.dart'; +import 'package:flame/src/text/nodes.dart'; +import 'package:flame/src/text/nodes/block_node.dart'; +import 'package:flame/src/text/styles/block_style.dart'; +import 'package:flutter/painting.dart' as painting; + +class ParagraphNode extends BlockNode { + ParagraphNode.simple(String text) + : child = GroupTextNode([PlainTextNode(text)]); + + final GroupTextNode child; + + BlockElement format(BlockStyle style, {required double parentWidth}) { + final text = (child.children.first as PlainTextNode).text; + final formatter = TextPainterTextFormatter( + style: const painting.TextStyle(fontSize: 16), + ); + final contentWidth = parentWidth - style.padding.horizontal; + final horizontalOffset = style.padding.left; + + final words = text.split(' '); + final lines = []; + var verticalOffset = style.padding.top; + var i0 = 0; + var i1 = 1; + var startNewLine = true; + while (i1 <= words.length) { + final lineText = words.sublist(i0, i1).join(' '); + final formattedLine = formatter.format(lineText); + if (formattedLine.metrics.width <= contentWidth || i1 - i0 == 1) { + formattedLine.translate( + horizontalOffset, + verticalOffset + formattedLine.metrics.ascent, + ); + if (startNewLine) { + lines.add(formattedLine); + startNewLine = false; + } else { + lines[lines.length - 1] = formattedLine; + } + i1++; + } else { + i0 = i1 - 1; + startNewLine = true; + verticalOffset += lines.last.metrics.height; + } + } + if (!startNewLine) { + verticalOffset += lines.last.metrics.height; + } + verticalOffset += style.padding.bottom; + final bg = makeBackground(style.background, parentWidth, verticalOffset); + final elements = bg == null ? lines : [bg, ...lines]; + return GroupElement(parentWidth, verticalOffset, elements); + } +} diff --git a/packages/flame/lib/src/text/styles/background_style.dart b/packages/flame/lib/src/text/styles/background_style.dart new file mode 100644 index 00000000000..85d91165d70 --- /dev/null +++ b/packages/flame/lib/src/text/styles/background_style.dart @@ -0,0 +1,57 @@ +import 'package:flame/src/text/styles/style.dart'; +import 'package:flutter/rendering.dart'; + +class BackgroundStyle extends Style { + BackgroundStyle({ + Color? color, + Paint? paint, + Color? borderColor, + double? borderRadius, + double? borderWidth, + }) : assert( + paint == null || color == null, + 'Parameters `paint` and `color` are exclusive', + ), + borderWidths = EdgeInsets.all(borderWidth ?? 0), + borderRadius = borderRadius ?? 0 { + if (paint != null) { + backgroundPaint = paint; + } else if (color != null) { + backgroundPaint = Paint()..color = color; + } else { + backgroundPaint = null; + } + if (borderColor != null) { + borderPaint = Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth ?? 0; + } else { + borderPaint = null; + } + } + + late final Paint? backgroundPaint; + late final Paint? borderPaint; + final double borderRadius; + final EdgeInsets borderWidths; + + @override + BackgroundStyle clone() => copyWith(); + + BackgroundStyle copyWith({ + Color? color, + Paint? paint, + Color? borderColor, + double? borderRadius, + double? borderWidth, + }) { + return BackgroundStyle( + color: color ?? (paint == null ? backgroundPaint?.color : null), + paint: paint ?? backgroundPaint, + borderColor: borderColor ?? borderPaint?.color, + borderRadius: borderRadius ?? this.borderRadius, + borderWidth: borderWidth ?? borderWidths.left, + ); + } +} diff --git a/packages/flame/lib/src/text/styles/block_style.dart b/packages/flame/lib/src/text/styles/block_style.dart new file mode 100644 index 00000000000..1d73de23387 --- /dev/null +++ b/packages/flame/lib/src/text/styles/block_style.dart @@ -0,0 +1,30 @@ +import 'package:flame/src/text/styles/background_style.dart'; +import 'package:flame/src/text/styles/style.dart'; +import 'package:flutter/painting.dart'; + +class BlockStyle extends Style { + BlockStyle({ + this.margin = EdgeInsets.zero, + this.padding = EdgeInsets.zero, + this.background, + }); + + EdgeInsets margin; + EdgeInsets padding; + BackgroundStyle? background; + + @override + BlockStyle clone() => copyWith(); + + BlockStyle copyWith({ + EdgeInsets? margin, + EdgeInsets? padding, + BackgroundStyle? background, + }) { + return BlockStyle( + margin: margin ?? this.margin, + padding: padding ?? this.padding, + background: background ?? this.background, + ); + } +} diff --git a/packages/flame/lib/src/text/styles/document_style.dart b/packages/flame/lib/src/text/styles/document_style.dart new file mode 100644 index 00000000000..c97c34b6898 --- /dev/null +++ b/packages/flame/lib/src/text/styles/document_style.dart @@ -0,0 +1,167 @@ +import 'package:flame/src/text/nodes/block_node.dart'; +import 'package:flame/src/text/nodes/header_node.dart'; +import 'package:flame/src/text/nodes/paragraph_node.dart'; +import 'package:flame/src/text/styles/background_style.dart'; +import 'package:flame/src/text/styles/block_style.dart'; +import 'package:flame/src/text/styles/overflow.dart'; +import 'package:flame/src/text/styles/style.dart'; +import 'package:flutter/painting.dart' show EdgeInsets; +import 'package:meta/meta.dart'; + +/// [DocumentStyle] is a user-facing description of how to render an entire +/// body of text; it roughly corresponds to a stylesheet in HTML. +/// +/// This class represents a top-level style sheet, comprised of properties that +/// describe the document as a whole (as opposed to lower-level styles that +/// represent more granular elements such as paragraphs, headers, etc). +/// +/// All styles that collectively describe how to render text are organized into +/// a tree, with [DocumentStyle] at the root. +class DocumentStyle extends Style { + DocumentStyle({ + this.width, + this.height, + EdgeInsets? padding, + BackgroundStyle? background, + BlockStyle? paragraphStyle, + BlockStyle? header1Style, + BlockStyle? header2Style, + BlockStyle? header3Style, + BlockStyle? header4Style, + BlockStyle? header5Style, + BlockStyle? header6Style, + }) : padding = padding ?? EdgeInsets.zero { + backgroundStyle = acquire(background); + this.paragraphStyle = acquire(paragraphStyle ?? defaultParagraphStyle)!; + this.header1Style = acquire(header1Style ?? defaultHeader1Style)!; + this.header2Style = acquire(header2Style ?? defaultHeader2Style)!; + this.header3Style = acquire(header3Style ?? defaultHeader3Style)!; + this.header4Style = acquire(header4Style ?? defaultHeader4Style)!; + this.header5Style = acquire(header5Style ?? defaultHeader5Style)!; + this.header6Style = acquire(header6Style ?? defaultHeader6Style)!; + } + + /// Outer width of the document page. + /// + /// This width is the distance between the left edge of the left border, and + /// the right edge of the right border. Thus, it corresponds to the + /// "border-box" box sizing model in HTML. + /// + /// If this property is `null`, then the page width must be provided when + /// formatting a document. + final double? width; + + /// Outer height of the document page. + /// + /// If the [overflow] property is [Overflow.expand], then the height parameter + /// is treated as a minimum height, in other cases the height is obeyed + /// strictly. + /// + /// If this property is `null`, then the page height must be provided when + /// formatting a document (except for the overflow-expand mode, when the + /// value of [height] defaults to 0). + final double? height; + + /// Behavior of the document when the amount of content that needs to be laid + /// out exceeds the provided [height]. See the [Overflow] enum description for + /// more details. + final Overflow overflow = Overflow.expand; + + /// The distance from the outer edges of the page to the inner content box of + /// the document. + /// + /// Note that the padding is computed from the outer edge of the page, unlike + /// HTML where it is computed from the inner edge of the border box. + /// + /// If the document's horizontal padding exceeds its width, an exception will + /// be thrown. + final EdgeInsets padding; + + /// If present, describes what kind of background and borders to draw for the + /// document page(s). + late final BackgroundStyle? backgroundStyle; + + /// Style for paragraph nodes in the document. + late final BlockStyle paragraphStyle; + + /// Style for level-1 headers. + late final BlockStyle header1Style; + + /// Style for level-2 headers. + late final BlockStyle header2Style; + + /// Style for level-3 headers. + late final BlockStyle header3Style; + + /// Style for level-4 headers. + late final BlockStyle header4Style; + + /// Style for level-5 headers. + late final BlockStyle header5Style; + + /// Style for level-6 headers. + late final BlockStyle header6Style; + + static BlockStyle defaultParagraphStyle = BlockStyle(); + static BlockStyle defaultHeader1Style = BlockStyle(); + static BlockStyle defaultHeader2Style = BlockStyle(); + static BlockStyle defaultHeader3Style = BlockStyle(); + static BlockStyle defaultHeader4Style = BlockStyle(); + static BlockStyle defaultHeader5Style = BlockStyle(); + static BlockStyle defaultHeader6Style = BlockStyle(); + + @override + DocumentStyle clone() => copyWith(); + + DocumentStyle copyWith({ + double? width, + double? height, + EdgeInsets? padding, + BackgroundStyle? background, + BlockStyle? paragraphStyle, + BlockStyle? header1Style, + BlockStyle? header2Style, + BlockStyle? header3Style, + BlockStyle? header4Style, + BlockStyle? header5Style, + BlockStyle? header6Style, + }) { + return DocumentStyle( + width: width ?? this.width, + height: height ?? this.height, + padding: padding ?? this.padding, + background: background ?? backgroundStyle, + paragraphStyle: paragraphStyle ?? this.paragraphStyle, + header1Style: header1Style ?? this.header1Style, + header2Style: header2Style ?? this.header2Style, + header3Style: header3Style ?? this.header3Style, + header4Style: header4Style ?? this.header4Style, + header5Style: header5Style ?? this.header5Style, + header6Style: header6Style ?? this.header6Style, + ); + } + + @internal + BlockStyle styleFor(BlockNode node) { + if (node is ParagraphNode) { + return paragraphStyle; + } + if (node is HeaderNode) { + switch (node.level) { + case 1: + return header1Style; + case 2: + return header2Style; + case 3: + return header3Style; + case 4: + return header4Style; + case 5: + return header5Style; + default: + return header6Style; + } + } + return BlockStyle(); + } +} diff --git a/packages/flame/lib/src/text/styles/overflow.dart b/packages/flame/lib/src/text/styles/overflow.dart new file mode 100644 index 00000000000..4528efd9205 --- /dev/null +++ b/packages/flame/lib/src/text/styles/overflow.dart @@ -0,0 +1,18 @@ +enum Overflow { + /// Any content that doesn't fit into the document box will be clipped. + hidden, + + /// If there is any content that doesn't fit into the document box, it will + /// be removed, and an "ellipsis" symbol added at the end to indicate that + /// some content was truncated. + ellipsis, + + /// The height of the document box will be automatically extended to + /// accommodate any content that wouldn't fit otherwise. Under this mode the + /// `height` property is treated as "min-height". + expand, + + /// Any content that doesn't fit into the document box will be moved onto the + /// next one or more pages. + paginate, +} diff --git a/packages/flame/lib/src/text/styles/style.dart b/packages/flame/lib/src/text/styles/style.dart new file mode 100644 index 00000000000..94d33fa4116 --- /dev/null +++ b/packages/flame/lib/src/text/styles/style.dart @@ -0,0 +1,40 @@ +import 'package:flame/src/text/styles/document_style.dart'; +import 'package:meta/meta.dart'; + +/// A [Style] is a base class for several classes that collectively describe +/// the desired visual appearance of a "rich-text" document. +/// +/// The style classes mostly are collections of properties that describe how a +/// potential document should be formatted. However, they have little logic +/// beyond that. The style classes are then passed to document `Node`s so that +/// the content of a document can be formatted. +/// +/// Various [Style] classes are organized into a tree, with [DocumentStyle] at +/// the root. +/// +/// The tree of [Style]s is roughly equivalent to a CSS stylesheet. +abstract class Style { + /// The owner of the current style. + /// + /// Usually, styles are organized into a tree, and this property allows + /// traversing up this tree. This property can be null when the style hasn't + /// been put into a tree yet, or when it is the root of the tree. + Style? get parent => _parent; + Style? _parent; + + /// Creates and returns a copy of the current object. + Style clone(); + + /// Marks [style] as being owned by the current object and returns it. + /// However, if the [style] is already owned by some other object, then clones + /// the [style], marks the copy as being owned, and returns it. + @protected + S? acquire(S? style) { + if (style == null) { + return null; + } + final useStyle = style._parent == null ? style : style.clone() as S; + useStyle._parent = this; + return useStyle; + } +} diff --git a/packages/flame/lib/text.dart b/packages/flame/lib/text.dart index 2af56f3d74e..9dfc02688eb 100644 --- a/packages/flame/lib/text.dart +++ b/packages/flame/lib/text.dart @@ -1,4 +1,12 @@ export 'src/text/common/glyph_data.dart' show GlyphData; +export 'src/text/elements/element.dart' show Element; +export 'src/text/nodes/block_node.dart' show BlockNode; +export 'src/text/nodes/document_node.dart' show DocumentNode; +export 'src/text/nodes/header_node.dart' show HeaderNode; +export 'src/text/nodes/paragraph_node.dart' show ParagraphNode; export 'src/text/sprite_font_renderer.dart' show SpriteFontRenderer; +export 'src/text/styles/background_style.dart' show BackgroundStyle; +export 'src/text/styles/block_style.dart' show BlockStyle; +export 'src/text/styles/document_style.dart' show DocumentStyle; export 'src/text/text_paint.dart' show TextPaint; export 'src/text/text_renderer.dart' show TextRenderer;