layout method

  1. @override
ResolvedTextStory layout({
  1. required TextStory story,
  2. required DiagramRect clipBounds,
  3. required int revisionKey,
  4. List<DiagramTextRegionLayout> regions = const [],
})
override

Implementation

@override
ResolvedTextStory layout({
  required TextStory story,
  required DiagramRect clipBounds,
  required int revisionKey,
  List<DiagramTextRegionLayout> regions = const [],
}) {
  validateParagraphPlacement(
    clipBounds: clipBounds,
    regions: regions,
    blockSpacing: blockSpacing,
    listIndent: listIndent,
  );
  // Lines are built once their region's vertical alignment offset is known.
  final lineBuilders = <(String, ResolvedTextLine Function(double))>[];
  final regionById = {for (final region in regions) region.slotId: region};
  final effectiveClipBounds = regions.isEmpty
      ? clipBounds
      : DiagramRect.union([for (final region in regions) region.bounds]);
  final fallbackRegion = DiagramTextRegionLayout(
    slotId: TextRole.body.name,
    bounds: clipBounds,
  );
  final yByRegion = <String, double>{};
  final contentHeightByRegion = <String, double>{};
  var storyOffset = 0;
  var contentBottom = 0.0;
  var overflowHeight = 0.0;
  final markerSets = <DiagramTextMarkers, List<String?>>{};
  final markers = <String?>[
    for (var index = 0; index < story.blocks.length; index++)
      if (regionById[story.blocks[index].effectiveSlotId]?.text?.markers
          case final provider?)
        _contributedMarker(provider, story, index, markerSets)
      else
        null,
  ];
  for (var blockIndex = 0; blockIndex < story.blocks.length; blockIndex++) {
    final block = story.blocks[blockIndex];
    final text = block.plainText;
    final region = regions.isEmpty
        ? fallbackRegion
        : regionById[block.effectiveSlotId] ??
              (throw StateError(
                'Text block ${block.id} targets unknown slot '
                '${block.effectiveSlotId}.',
              ));
    // Accumulate line heights locally: translating a region must not change
    // its measured height through subtraction of world coordinates.
    var y = yByRegion[region.slotId] ?? 0.0;
    final marker = markers[blockIndex];
    final markerMeasurement = marker == null
        ? null
        : _measureMarker(block, region, marker);
    final markerGap =
        (markerMeasurement?.fontSize ?? region.defaultFontSize) / 2;
    var indent = markerMeasurement == null
        ? 0.0
        : markerMeasurement.intrinsicWidth + markerGap;
    final policy = region.text?.markers;
    if (markerMeasurement != null && policy != null) {
      final reference = policy.widthReference;
      if (reference.isEmpty || reference.contains(_lineBreak)) {
        throw StateError(
          'Marker width reference must be a nonempty single line.',
        );
      }
      final referenceWidth = _measureMarker(
        block.copyWith(attributes: null),
        region,
        reference,
      ).intrinsicWidth;
      indent = policy.indent(
        paragraph: block,
        markerWidth: markerMeasurement.intrinsicWidth,
        referenceWidth: referenceWidth,
        markerFontSize: markerMeasurement.fontSize,
        defaultFontSize: region.defaultFontSize,
        minimumIndent: listIndent,
      );
      if (!indent.isFinite || indent < 0) {
        throw StateError(
          'Marker indentation must be finite and nonnegative.',
        );
      }
    }
    final availableWidth = math.max(1.0, region.bounds.width - indent);
    final styledBlock =
        region.text?.present(block, region.defaultFontSize) ?? block;
    if (!identical(styledBlock, block) &&
        (styledBlock.id != block.id ||
            styledBlock.plainText != text ||
            styledBlock.role != block.role ||
            styledBlock.effectiveSlotId != block.effectiveSlotId)) {
      throw StateError(
        'Paragraph presentation must preserve text, identity, and region.',
      );
    }
    final shapedBlock = markerMeasurement == null
        ? styledBlock
        : styledBlock.copyWith(alignment: ParagraphAlignment.start);
    final measured = measureBlock(shapedBlock, region, availableWidth);
    if (measured.isEmpty) {
      throw StateError(
        'Text block ${block.id} must resolve at least one line.',
      );
    }
    var previousEnd = 0;
    for (final measurement in measured) {
      validateDiagramMeasuredTextLine(
        measurement,
        textLength: text.length,
        previousEnd: previousEnd,
        context: 'Text block ${block.id}',
      );
      previousEnd = measurement.end;
      final slice = (measurement.start, measurement.end);
      final lineText = text.substring(slice.$1, slice.$2);
      final lineLeft =
          region.bounds.left +
          (block.direction == ParagraphDirection.rtl ? 0 : indent);
      final lineStart = storyOffset;
      final lineY = y;
      double placed(double top, double offset) =>
          offset == 0 ? top : top + offset;
      // Marker fallback-font leading must not change the text's row rhythm.
      // Align marker ink to the text baseline without expanding its line box.
      lineBuilders.add((
        region.slotId,
        (offset) => ResolvedTextLine(
          blockId: block.id,
          startOffset: lineStart + slice.$1,
          endOffset: lineStart + slice.$2,
          bounds: DiagramRect.fromLTWH(
            lineLeft,
            placed(region.bounds.top + lineY, offset),
            region.singleLine && region.overflow
                ? math.max(availableWidth, measurement.intrinsicWidth)
                : availableWidth,
            measurement.height,
          ),
          contentBounds: DiagramRect.fromLTWH(
            lineLeft + measurement.left,
            placed(region.bounds.top + lineY, offset),
            measurement.width,
            measurement.height,
          ),
          intrinsicWidth: measurement.intrinsicWidth + indent,
          fontSize: measurement.fontSize,
          baseline: measurement.baseline,
          geometry: measurement.geometry,
          regionBounds: region.bounds,
          overflow: region.overflow,
          text: lineText,
          runs: slice.$1 == slice.$2
              ? [TextRun('')]
              : sliceTextRuns(shapedBlock.runs, slice.$1, slice.$2),
          foreground: region.foreground,
          defaultFontSize: region.defaultFontSize,
          fontFamilies: measurement.fontFamilies,
          marker: slice.$1 == 0 ? marker : null,
          resolvedMarker: slice.$1 == 0 && markerMeasurement != null
              ? ResolvedTextMarker(
                  run: TextRun(
                    marker!,
                    fontFamily:
                        block.runs.firstOrNull?.fontFamily ??
                        markerMeasurement.defaultFontFamily,
                    color: block.runs.firstOrNull?.color,
                    fontSize: markerMeasurement.fontSize,
                  ),
                  baseline: markerMeasurement.baseline,
                  bounds: DiagramRect.fromLTWH(
                    block.direction == ParagraphDirection.rtl
                        ? lineLeft + availableWidth + markerGap
                        : lineLeft -
                              markerGap -
                              markerMeasurement.intrinsicWidth,
                    placed(
                      region.bounds.top +
                          lineY +
                          measurement.baseline -
                          markerMeasurement.baseline,
                      offset,
                    ),
                    markerMeasurement.intrinsicWidth,
                    markerMeasurement.height,
                  ),
                  strokes: switch (region.text?.markers) {
                    final provider? => _markerStrokes(
                      provider,
                      block,
                      markerMeasurement,
                    ),
                    null => null,
                  },
                  interactive:
                      region.text?.markers?.canActivate(block) ?? false,
                  geometry: markerMeasurement.geometry,
                )
              : null,
          role: block.role,
          alignment: shapedBlock.alignment,
          direction: shapedBlock.direction,
        ),
      ));
      y += measurement.height;
    }
    contentHeightByRegion[region.slotId] = y;
    yByRegion[region.slotId] = y + blockSpacing;
    contentBottom = math.max(
      contentBottom,
      (region.bounds.top - effectiveClipBounds.top) + y,
    );
    if (!region.singleLine) {
      overflowHeight = math.max(overflowHeight, y - region.bounds.height);
    }
    storyOffset += text.length + 1;
  }

  final offsets = <String, double>{};
  for (final entry in contentHeightByRegion.entries) {
    final region = regions.isEmpty ? fallbackRegion : regionById[entry.key]!;
    final remaining = region.bounds.height - entry.value;
    final freeHeight = region.singleLine
        ? remaining
        : math.max(0.0, remaining);
    offsets[entry.key] = switch (region.verticalAlignment) {
      DiagramTextVerticalAlignment.top => 0.0,
      DiagramTextVerticalAlignment.center => freeHeight / 2,
      DiagramTextVerticalAlignment.bottom => freeHeight,
    };
  }

  return ResolvedTextStory(
    storyId: story.id,
    revisionKey: revisionKey,
    clipBounds: effectiveClipBounds,
    lines: [
      for (final (slotId, build) in lineBuilders) build(offsets[slotId]!),
    ],
    contentHeight: math.max(0, contentBottom),
    overflowHeight: math.max(0, overflowHeight),
  );
}