layout method
ResolvedTextStory
layout({
- required TextStory story,
- required DiagramRect clipBounds,
- required int revisionKey,
- 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),
);
}