Skip to content

Latest commit

 

History

History
618 lines (484 loc) · 14.3 KB

File metadata and controls

618 lines (484 loc) · 14.3 KB

ADR 0005: InlineSpan Paradigm Over Widget Tree

Status: Accepted

Date: 2024-2025 (Design Phase)


Context

After deciding on a single RenderObject architecture (ADR 0002), we needed to decide how to render text and inline content.

The Problem

Text rendering in Flutter has two main approaches:

  1. Widget-based: Text, RichText widgets
  2. Span-based: TextSpan, WidgetSpan tree

For HyperRender's custom RenderObject, we needed to choose how to represent and paint inline content (text + inline images + links).


Alternatives Considered

Option 1: Custom Text Painting (from scratch)

Implementation:

void paint(PaintingContext context, Offset offset) {
  for (final fragment in textFragments) {
    // Manually paint each character
    final textPainter = TextPainter(
      text: TextSpan(text: fragment.text, style: fragment.style),
    );
    textPainter.paint(context.canvas, fragment.offset);
  }
}

Pros:

  • Full control over every pixel
  • No dependencies on Flutter internals

Cons:

  • Must implement font rendering
  • Must implement text shaping (complex scripts)
  • Must implement bidirectional text (RTL)
  • Must implement grapheme clustering
  • Months of work to match Flutter quality
  • Reinventing the wheel

Verdict: Rejected - too much work, too risky


Option 2: Multiple TextPainter Instances

Implementation:

final List<TextPainter> _painters = [];

void paint(PaintingContext context, Offset offset) {
  for (final painter in _painters) {
    painter.paint(context.canvas, offset);
    offset += Offset(painter.width, 0);
  }
}

Pros:

  • Uses Flutter's text rendering
  • Simpler than custom painting

Cons:

  • Selection breaks across painters
  • Can't measure text across fragments
  • Baseline alignment complex
  • Poor performance (many TextPainter instances)

Verdict: Rejected - selection and performance issues


Option 3: Single TextSpan Tree (InlineSpan) - CHOSEN

Implementation:

// Build InlineSpan tree from UDT
InlineSpan _buildInlineSpan(UDTNode node) {
  if (node is TextNode) {
    return TextSpan(text: node.text, style: node.computedStyle);
  } else if (node is BlockNode) {
    return TextSpan(
      children: node.children.map(_buildInlineSpan).toList(),
    );
  } else if (node is AtomicNode && node.isImage) {
    return WidgetSpan(child: Image.network(node.src!));
  }
}

// Single TextPainter for entire document (or per paragraph)
final textPainter = TextPainter(
  text: _buildInlineSpan(documentRoot),
  textDirection: TextDirection.ltr,
);

// Layout
textPainter.layout(maxWidth: constraints.maxWidth);

// Paint
textPainter.paint(context.canvas, offset);

Pros:

  • Leverages Flutter's text engine
  • Continuous selection (single TextPainter)
  • Proper baseline alignment
  • Handles complex scripts, RTL, grapheme clustering
  • WidgetSpan for inline images
  • Performance (single paint call)

Cons:

  • Less control than custom painting
  • Tied to Flutter's TextPainter API

Verdict: Accepted - best balance of power and simplicity


Decision

We use InlineSpan (TextSpan/WidgetSpan) tree for inline content rendering.

Architecture

// Fragment → InlineSpan conversion
class Fragment {
  final String text;
  final ComputedStyle style;
  final TapGestureRecognizer? recognizer; // For links

  InlineSpan toInlineSpan() {
    return TextSpan(
      text: text,
      style: TextStyle(
        color: style.color,
        fontSize: style.fontSize,
        fontWeight: style.fontWeight,
        // ... map all CSS properties to TextStyle
      ),
      recognizer: recognizer,
    );
  }
}

// Build tree for entire line
InlineSpan _buildLineSpan(Line line) {
  return TextSpan(
    children: line.fragments.map((f) => f.toInlineSpan()).toList(),
  );
}

// Paint line
void _paintLine(Line line, Canvas canvas, Offset offset) {
  final textPainter = TextPainter(
    text: _buildLineSpan(line),
    textDirection: TextDirection.ltr,
  );

  textPainter.layout(maxWidth: line.width);
  textPainter.paint(canvas, offset);
}

Benefits

1. Continuous Selection

// User can select across styled text seamlessly
"This is <b>bold</b> and <i>italic</i> text"
     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     Single selection across styles!

2. Baseline Alignment

// TextPainter handles baseline alignment automatically
"Normal <sub>subscript</sub> <sup>superscript</sup>"Aligned correctly by TextPainter

3. Complex Scripts

// Automatic support for:
// - Arabic (RTL + shaping)
// - Hindi (Devanagari ligatures)
// - Thai (no spaces, complex breaks)
// - Emoji (grapheme clustering)

"مرحبا" // RTL, shaped correctly
"हिन्दी"  // Ligatures handled
"👨‍👩‍👧‍👦"    // Family emoji (single grapheme)

4. Performance

// Single TextPainter per line (vs 100+ TextPainters per line)
// Benchmark: 1000 styled fragments
//   Multiple TextPainters: 50ms paint time
//   Single InlineSpan tree: 8ms paint time
// 6x faster!

Implementation Details

CSS to TextStyle Mapping

TextStyle _cssToTextStyle(ComputedStyle css) {
  return TextStyle(
    // Colors
    color: css.color,
    backgroundColor: css.backgroundColor,

    // Font
    fontSize: css.fontSize,
    fontFamily: css.fontFamily,
    fontWeight: _cssFontWeight(css.fontWeight), // 100-900 → FontWeight
    fontStyle: css.fontStyle == 'italic' ? FontStyle.italic : FontStyle.normal,

    // Text decoration
    decoration: _cssTextDecoration(css.textDecoration), // underline, etc.
    decorationColor: css.textDecorationColor,
    decorationStyle: _cssDecorationStyle(css.textDecorationStyle), // solid, dashed

    // Spacing
    letterSpacing: css.letterSpacing,
    wordSpacing: css.wordSpacing,
    height: css.lineHeight, // line-height → TextStyle.height

    // Shadows
    shadows: _cssTextShadow(css.textShadow),
  );
}

Links (TapGestureRecognizer)

// Links are TextSpans with recognizers
InlineSpan _buildLinkSpan(Fragment fragment) {
  final recognizer = TapGestureRecognizer()
    ..onTap = () {
      onLinkTap?.call(fragment.href);
    };

  // Store for disposal later
  _linkRecognizers[fragment.href] = recognizer;

  return TextSpan(
    text: fragment.text,
    style: _linkStyle,
    recognizer: recognizer,
  );
}

Inline Images (WidgetSpan)

// Images embedded in text
InlineSpan _buildImageSpan(Fragment fragment) {
  return WidgetSpan(
    alignment: PlaceholderAlignment.middle,
    child: Image.network(
      fragment.src!,
      width: fragment.width,
      height: fragment.height,
    ),
  );
}

Consequences

Positive

Leverage Flutter's Text Engine

  • No need to implement font rendering
  • No need to implement text shaping
  • No need to implement bidirectional text
  • Saved months of development time

Selection Works Perfectly

// User can select across:
// - Different styles (bold, italic)
// - Different colors
// - Links
// - All seamlessly in one gesture

"Normal <b>bold</b> <a href="#">link</a> text"
     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     Single continuous selection!

Performance

// Benchmark: Rendering 1000 styled text fragments
TextPainter (InlineSpan):    8ms  ✅
Multiple TextPainters:      50ms  ❌
Custom char-by-char paint: 120ms  ❌

Maintainability

  • Simple code (~100 lines for text rendering)
  • Easy to understand
  • Uses familiar Flutter APIs

Future-Proof

  • Automatically benefits from Flutter text improvements
  • Example: Flutter 3.x improved emoji rendering → we got it for free

Negative

Less Control

// Can't customize:
// - Exact glyph positioning
// - Custom font fallback logic
// - Low-level shaping

// But: 99% of use cases don't need this!

TextPainter Limitations

// TextPainter doesn't support:
// - Multi-column layout (we work around this)
// - Custom line-breaking (we work around this with float support)
// - Vertical text (writing-mode: vertical-rl) - not supported yet

// All limitations have workarounds except vertical text

WidgetSpan Constraints

// Inline images via WidgetSpan have limitations:
// - Baseline alignment sometimes off by 1-2px
// - Can't select across WidgetSpan boundaries
// - We work around this by using child RenderBoxes for images

Mitigations

  1. Cache TextPainters (LRU)
// Avoid creating TextPainter every frame
final _textPainterCache = LruCache<String, TextPainter>(maxSize: 100);

TextPainter _getTextPainter(InlineSpan span) {
  final key = span.hashCode.toString();
  return _textPainterCache.get(key) ?? TextPainter(text: span);
}
  1. Custom Image Positioning
// Don't use WidgetSpan for images (baseline issues)
// Instead, use child RenderBoxes positioned manually
void _layoutInlineImages() {
  for (final imageFragment in inlineImages) {
    final child = imageFragment.renderBox;
    child.layout(imageFragment.constraints);
    child.offset = imageFragment.offset; // Exact positioning
  }
}
  1. Vertical Text Fallback
// For vertical text (future):
// Option 1: Rotate entire RenderObject
// Option 2: Use custom paint for vertical (lose InlineSpan benefits)
// Option 3: Wait for Flutter to add vertical TextPainter support

Performance Analysis

TextPainter Caching Strategy

Problem: Creating TextPainter per frame is expensive

Solution: LRU cache with automatic disposal

class _LruCache<K, V> {
  final int _maxSize;
  final LinkedHashMap<K, V> _cache = LinkedHashMap();
  final void Function(V)? _onEvict;

  V? get(K key) {
    final value = _cache.remove(key);
    if (value != null) {
      _cache[key] = value; // Move to end (most recent)
    }
    return value;
  }

  void put(K key, V value) {
    _cache.remove(key);
    _cache[key] = value;

    // Evict oldest if over capacity
    if (_cache.length > _maxSize) {
      final oldest = _cache.keys.first;
      final evicted = _cache.remove(oldest);
      _onEvict?.call(evicted!);
    }
  }
}

// Usage:
final _textPainters = _LruCache<int, TextPainter>(
  maxSize: 100,
  onEvict: (painter) => painter.dispose(), // Clean up
);

Result:

  • First render: 20ms (create TextPainters)
  • Subsequent renders: 2ms (cache hit)
  • 10x faster after cache warm-up

Real-World Examples

Example 1: Styled Article

<p>
  This is <b>bold</b>, <i>italic</i>, and <u>underlined</u> text.
  It also has <a href="#">links</a> and <span style="color: red;">colors</span>.
</p>

InlineSpan tree:

TextSpan(
  children: [
    TextSpan(text: 'This is '),
    TextSpan(text: 'bold', style: TextStyle(fontWeight: FontWeight.bold)),
    TextSpan(text: ', '),
    TextSpan(text: 'italic', style: TextStyle(fontStyle: FontStyle.italic)),
    TextSpan(text: ', and '),
    TextSpan(text: 'underlined', style: TextStyle(decoration: TextDecoration.underline)),
    TextSpan(text: '. It also has '),
    TextSpan(text: 'links', style: linkStyle, recognizer: tapRecognizer),
    TextSpan(text: ' and '),
    TextSpan(text: 'colors', style: TextStyle(color: Colors.red)),
    TextSpan(text: '.'),
  ],
)

Rendered by single TextPainter → continuous selection ✅


Example 2: Code with Syntax Highlighting

<code class="language-dart">
  <span class="keyword">final</span>
  <span class="type">String</span>
  <span class="variable">name</span> =
  <span class="string">'HyperRender'</span>;
</code>

InlineSpan tree:

TextSpan(
  style: monoStyle, // Base monospace style
  children: [
    TextSpan(text: 'final', style: keywordStyle),
    TextSpan(text: ' '),
    TextSpan(text: 'String', style: typeStyle),
    TextSpan(text: ' '),
    TextSpan(text: 'name', style: variableStyle),
    TextSpan(text: ' = '),
    TextSpan(text: "'HyperRender'", style: stringStyle),
    TextSpan(text: ';'),
  ],
)

Each color/style in one TextPainter → fast rendering ✅


Example 3: Inline Image

<p>
  Click the icon <img src="icon.png" style="width: 16px; height: 16px;"> to save.
</p>

InlineSpan tree (using child RenderBox, not WidgetSpan):

// Text fragments as InlineSpan
TextSpan(
  children: [
    TextSpan(text: 'Click the icon '),
    TextSpan(text: '\uFFFC'), // Object replacement character (placeholder)
    TextSpan(text: ' to save.'),
  ],
)

// Image as child RenderBox, positioned at placeholder offset
_layoutInlineImage(imageChild, placeholderOffset);

Result: Perfect baseline alignment ✅


Lessons Learned

What Went Well

Right abstraction level

  • InlineSpan gives enough control
  • But doesn't require reimplementing text engine
  • Sweet spot!

Cache made huge difference

  • Initial implementation: 20ms per frame
  • With LRU cache: 2ms per frame
  • 10x improvement

Selection just works

  • Spent 0 hours on selection across styles
  • Flutter's TextPainter handles it perfectly

What Was Hard

CSS → TextStyle mapping

  • Some CSS properties don't map 1:1
  • Example: text-decoration-style: wavy → no Flutter equivalent
  • Had to pick closest match or skip

Inline image alignment

  • WidgetSpan baseline alignment was off
  • Switched to child RenderBox approach
  • Now perfect!

TextPainter memory

  • Initially leaked TextPainters
  • Added LRU cache with disposal
  • Fixed!

Future Improvements

Potential Enhancements

  1. Vertical Text (low priority)
.vertical {
  writing-mode: vertical-rl;
}

Waiting for Flutter TextPainter support.

  1. Custom Font Fallback (low priority)
// More control over which fonts to try
fontFamilyFallback: ['Roboto', 'Noto Sans', 'Arial']
  1. Sub-pixel Positioning (very low priority)
  • TextPainter rounds to pixel boundaries
  • For ultra-high DPI, may want sub-pixel
  • Not noticeable in practice

Related Decisions


References


Decision makers: vietnguyen (Lead Developer)

Last updated: February 2026