TextLayoutEngine.java
package swingtree.style;
import org.jspecify.annotations.Nullable;
import sprouts.Pair;
import sprouts.Tuple;
import swingtree.UI;
import swingtree.layout.Bounds;
import javax.swing.JComponent;
import java.awt.*;
import java.awt.font.FontRenderContext;
import java.awt.font.LineBreakMeasurer;
import java.awt.font.TextAttribute;
import java.awt.font.TextLayout;
import java.awt.geom.*;
import java.text.AttributedCharacterIterator;
import java.text.AttributedString;
import java.text.BreakIterator;
import java.util.*;
import java.util.List;
import java.util.WeakHashMap;
import java.util.concurrent.atomic.AtomicReference;
final class TextLayoutEngine {
private TextLayoutEngine() {}
/**
* LRU cache capped at {@value #_CACHE_MAX_SIZE} entries.
* Uses an access-order {@link LinkedHashMap} so the least-recently-used entry is
* evicted once the cap is reached. The map is wrapped in
* {@link Collections#synchronizedMap} so concurrent callers on different Swing
* repaint threads do not corrupt it.
*/
private static final int _CACHE_MAX_SIZE = 128;
/**
* Weak-reference cache of {@link ParagraphLayoutsData} entries, keyed by interned
* {@link Pooled}{@literal <}{@link Paragraph}{@literal >} instances.
* <p>
* Each entry bundles the paragraph's {@link AttributedString}, its reusable
* {@link LineBreakMeasurer}, the single no-wrap {@link TextLayout} (once computed),
* and the map of previously computed {@link LayoutLine} lists for different layout
* contexts — see {@link ParagraphLayoutsData} for details.
* <p>
* Using a {@link WeakHashMap} allows entries to be reclaimed once no other code holds
* a strong reference to the {@link Pooled} key — paragraphs that have scrolled off or
* been replaced are automatically evicted without a fixed size cap.
* <p>
* <b>Borrow semantics:</b> an entry is {@link Map#remove removed} from this cache
* before use and {@link Map#put returned} afterwards. This ensures at most one
* thread mutates a given entry at any time; a concurrent caller that hits the same
* key simply constructs a fresh entry on cache-miss.
*/
private static final Map<Pooled<Paragraph>, ParagraphLayoutsData> _PARAGRAPH_DATA_CACHE = Collections.synchronizedMap(new WeakHashMap<>());
/**
* A large but geometrically safe line-width cap used when no explicit
* {@code boundsWidth} is available and as the "do not wrap" width passed
* to {@link LineBreakMeasurer#nextLayout} when {@code wrapLines} is false.
* <p>
* {@link Short#MAX_VALUE} (32 767 px) comfortably exceeds any real display width,
* yet stays well within the range of {@code float} arithmetic so that
* {@link java.awt.geom.Rectangle2D.Float} and {@link java.awt.geom.Area}
* operations on it do not overflow or lose precision.
*/
private static final float _UNBOUNDED_LINE_WIDTH = Short.MAX_VALUE;
@SuppressWarnings("serial")
private static final Map<TextLayoutKey, Pair<Float, List<LayoutLine>>> _LAYOUT_CACHE =
Collections.synchronizedMap(
new LinkedHashMap<TextLayoutKey, Pair<Float, List<LayoutLine>>>(
_CACHE_MAX_SIZE + 1, 0.75f, true /* access-order */
) {
@Override
protected boolean removeEldestEntry(
Map.Entry<TextLayoutKey, Pair<Float, List<LayoutLine>>> eldest
) {
return size() > _CACHE_MAX_SIZE;
}
}
);
static Shape childShapeForArea( Component child, UI.ComponentBoundary area ) {
if ( area == UI.ComponentBoundary.OUTER_TO_EXTERIOR || !(child instanceof JComponent) )
return child.getBounds();
final JComponent asJComponent = (JComponent) child;
final ComponentConf previousComponentConf = ComponentExtension.from(asJComponent).getConf();
final Pair<BoxModelConf, ComponentConf> boxAndCompConf = StyleEngine._calculateBoxModelAndComponentConfs(
Bounds.of(asJComponent.getX(), asJComponent.getY(), asJComponent.getWidth(), asJComponent.getHeight()),
previousComponentConf.style(),
StyleInstaller._formerBorderMarginCorrection(asJComponent),
previousComponentConf
);
Shape shapeArea = null;
ComponentAreas areas = ComponentAreas.of(new Pooled<>(boxAndCompConf.first()).intern());
switch ( area ) {
case EXTERIOR_TO_BORDER: shapeArea = areas.get(UI.ComponentArea.BODY); break;
case BORDER_TO_INTERIOR: shapeArea = areas.get(UI.ComponentArea.INTERIOR); break;
case INTERIOR_TO_CONTENT: shapeArea = areas.getContentArea(); break;
case CENTER_TO_CONTENT:
// Basically a point. We give it a small area to avoid issues with zero-area shapes:
shapeArea = new Rectangle(0, 0, 1, 1);
break;
}
if ( shapeArea == null )
return asJComponent.getBounds(); // fallback to the whole component bounds if we failed to calculate the area for some reason
// Now we need to translate to the parent coordinate space:
int xOffset = asJComponent.getX(); // -> these are offsets in the parent component coordinate system!
int yOffset = asJComponent.getY();
return new TextLayoutEngine.TranslatedShape(shapeArea, xOffset, yOffset);
}
/**
* Builds the list of {@link TextLayout} objects for the given styled text and measures
* the total rendered height of all lines (Phase 1 + Phase 2 of the text rendering pipeline).
* When {@code obstacles} is non-empty, each line layout will be built with awareness
* of the obstacle shapes and may contain multiple TextLayout segments to skip over obstacles.
* <b>But hitting an obstacle does not just lead to a simple line break, within a line,
* the text will try to skip over the obstacle shape until it can continue.</b>
*
* @param font The base font to use for unstyled segments.
* @param frc The {@link FontRenderContext} used by the measurer.
* @param paragraphs The styled text to lay out.
* @param boundsWidth The available width for the text. This property is only relevant when line wrapping is active.
* @param boundsX, The x-offset of the text in the component space. It is important for intersecting obstacles!
* @param boundsY, The y-offset of the text in the component space. It is important for intersecting obstacles!
* @param wrapLines Whether long lines should be wrapped at word boundaries.
* @param boxModelConf The box-model configuration forwarded to per-segment font derivation.
* @param obstacles Shapes (in component coordinates) the text must not be rendered on top of.
* The text will skip over these shapes and continue rendering on the other side,
* if possible or break the line if no more horizontal space is left.
* Pass an empty {@link Tuple} when no obstacles are present. Ignored when the {@code placement}
* does not support obstacles (see below).
* @param placement The text placement mode, which determines whether obstacle avoidance is active.
* Only TOP-based placements support obstacle avoidance, so when CENTER or BOTTOM placement is used,
* obstacles are ignored even if provided.
* @return A {@link Pair} whose {@link Pair#first()} is the total pixel height of all lines
* and whose {@link Pair#second()} is the ordered list of {@link LayoutLine} entries,
* each carrying its layout and the obstacle-free horizontal region it was placed into.
*/
static Pair<Float, List<LayoutLine>> _buildTextLayoutsAndPreferredHeight(
final Font font,
final FontRenderContext frc,
final Tuple<Pooled<Paragraph>> paragraphs,
final float boundsWidth,
final float boundsX,
final float boundsY,
final boolean wrapLines,
final BoxModelConf boxModelConf,
final Tuple<Shape> obstacles,
final UI.Placement placement
) {
final Tuple<Shape> compatibleObstacles = _supportsObstacles(placement) ? obstacles : obstacles.clear();
final TextLayoutKey key = new TextLayoutKey(font, paragraphs, boundsWidth, boundsX, boundsY, wrapLines, boxModelConf, compatibleObstacles);
final Pair<Float, List<LayoutLine>> cached = _LAYOUT_CACHE.get(key);
if ( cached != null )
return cached;
/*
------------------------------------------------
Phase 1 : Build LayoutLines from paragraphs
------------------------------------------------
*/
final List<LayoutLine> lines = _buildLayoutLines(paragraphs, frc, font, boundsX, boundsY, boundsWidth, wrapLines, compatibleObstacles, boxModelConf);
/*
------------------------------------------------
Phase 2 : Measure total text height
------------------------------------------------
*/
float totalHeight = 0;
for ( LayoutLine line : lines ) {
if ( line.layout == null )
totalHeight += font.getSize2D();
else
totalHeight += line.layout.getAscent() + line.layout.getDescent() + line.layout.getLeading();
}
final Pair<Float, List<LayoutLine>> result = Pair.of(totalHeight, Collections.unmodifiableList(lines));
_LAYOUT_CACHE.put(key, result);
return result;
}
private static boolean _supportsObstacles(UI.Placement placement) {
// Obstacle avoidance relies on TOP-based vertical placement to track the current line's y-level in component coordinates.
// When using CENTER or BOTTOM placement, the text block's y-level shifts as lines are added, so obstacle intersection
// becomes inaccurate and may produce jumbled layouts. For simplicity, we disable obstacles entirely in these modes.
return placement == UI.Placement.TOP_LEFT || placement == UI.Placement.TOP || placement == UI.Placement.TOP_RIGHT;
}
/**
* Phase 1 of the text rendering pipeline: converts a list of pre-split paragraphs into
* an ordered list of {@link LayoutLine}s, respecting word-wrap and obstacle constraints.
* <p>
* Each non-blank paragraph produces one or more {@link LayoutLine}s depending on available
* width and obstacles. A paragraph whose {@link Paragraph#isBlankLine} flag is {@code true}
* is emitted as a {@link LayoutLine} with a {@code null} layout. Any trailing blank-line
* entry is stripped before returning.
* <p>
* Results are cached at two levels for maximum reuse:
* <ol>
* <li>The {@link AttributedString} and {@link LineBreakMeasurer} for each paragraph are
* kept in {@link #_PARAGRAPH_DATA_CACHE}, avoiding expensive font-metric setup on
* every repaint.</li>
* <li>The complete {@link LayoutLine} list for each paragraph is cached inside
* {@link ParagraphLayoutsData#wrappedLayouts} (wrap/obstacle path) or
* {@link ParagraphLayoutsData#singleLayout} (no-wrap, no-obstacle path), so
* that a repaint with identical obstacle geometry and bounds skips the
* {@link LineBreakMeasurer#nextLayout} calls entirely.</li>
* </ol>
*
* @param paragraphs Interned paragraphs produced by {@link TextConf#simplified()};
* entries whose {@link Paragraph#isBlankLine} flag is {@code true} represent blank lines.
* @param frc The {@link FontRenderContext} used by the measurer.
* @param font Base font — used for the estimated line height and blank-line placeholders.
* @param boundsX Left edge of the text bounds in component coordinates.
* @param boundsY Top edge of the text bounds in component coordinates (starting y for obstacle queries).
* @param boundsWidth Available width ({@code < 0} means unbounded).
* @param wrapLines Whether to wrap text at word boundaries when it exceeds the available width.
* @param obstacles Shapes (in component coordinates) the text must not overlap.
* @return Ordered list of {@link LayoutLine}s ready for Phase 2 height measurement and rendering.
*/
private static List<LayoutLine> _buildLayoutLines(
final Tuple<Pooled<Paragraph>> paragraphs,
final FontRenderContext frc,
final Font font,
final float boundsX,
final float boundsY,
final float boundsWidth,
final boolean wrapLines,
final Tuple<Shape> obstacles,
final BoxModelConf boxModelConf
) {
// currentY tracks the top of the next line in component coordinates (TOP-placement
// assumption). Used solely for obstacle intersection — a good approximation for all
// placement modes because obstacles are typically large enough that the small vertical
// offset introduced by CENTER/BOTTOM placement does not change which obstacle a line hits.
final List<LayoutLine> lines = new ArrayList<>();
float currentY = boundsY;
final float estLineHeight = font.getSize2D();
for ( final Pooled<Paragraph> pooled : paragraphs ) {
final Paragraph paragraph = pooled.get();
if ( paragraph.isBlankLine ) {
lines.add(new LayoutLine(null, boundsX, boundsWidth)); // blank line
currentY += estLineHeight;
continue;
}
// Borrow (or create) the ParagraphLayoutsData for this paragraph.
// The remove → use → put borrow pattern ensures thread safety: a concurrent
// caller that hits the same key simply constructs a fresh entry on miss.
ParagraphLayoutsData data = _PARAGRAPH_DATA_CACHE.remove(pooled);
if ( data == null || !data.isCompatibleWith(font) ) {
final AttributedString attrStr = _paragraphToAttributedString(paragraph.styledStrings, font, boxModelConf);
data = new ParagraphLayoutsData(font, attrStr);
}
if ( (wrapLines && boundsWidth >= 0) || !obstacles.isEmpty() ) {
// LineBreakMeasurer path: word-wrapping and/or splitting around obstacles.
// When boundsWidth is undefined, fall back to a large practical limit so
// that the measurer can still advance and obstacles can still be queried.
final float effectiveWidth = boundsWidth >= 0 ? boundsWidth : _UNBOUNDED_LINE_WIDTH;
// Check whether we already have a cached result for this paragraph at this
// layout context. The key captures obstacles + paragraphY, which together
// determine the free intervals at every line within the paragraph — not just
// the first. Using only the first line's intervals would give stale cache
// hits when an obstacle moves into a later line but misses the first.
final LineLayoutKey layoutKey = new LineLayoutKey(obstacles, Math.round(currentY), wrapLines, Math.round(effectiveWidth));
final List<LayoutLine> cachedLines = data.wrappedLayouts.get(layoutKey);
if ( cachedLines != null ) {
lines.addAll(cachedLines);
for ( LayoutLine ll : cachedLines )
currentY += ll.layout == null ? estLineHeight
: ll.layout.getAscent() + ll.layout.getDescent() + ll.layout.getLeading();
} else {
// Build from scratch and populate the per-paragraph cache.
final AttributedCharacterIterator it = data.attrStr.getIterator();
if ( data.measurer != null )
data.measurer.setPosition(it.getBeginIndex()); // reset to start of paragraph
else
data.measurer = new LineBreakMeasurer(it, BreakIterator.getLineInstance(), frc);
final List<LayoutLine> paraLines = new ArrayList<>();
final int end = it.getEndIndex();
while ( data.measurer.getPosition() < end ) {
final List<Band> intervals = _freeIntervalsAt(currentY, estLineHeight, boundsX, effectiveWidth, obstacles);
TextLayout firstLayout = null;
float firstX = boundsX;
float firstW = effectiveWidth;
List<LayoutLine.Segment> extras = Collections.emptyList();
if ( intervals.isEmpty() ) {
// All space is blocked — advance the measurer so the loop terminates.
// When not wrapping, consume all remaining text in one shot.
firstLayout = data.measurer.nextLayout(wrapLines ? effectiveWidth : _UNBOUNDED_LINE_WIDTH);
} else {
final int lastIdx = intervals.size() - 1;
for ( int i = 0; i <= lastIdx; i++ ) {
if ( data.measurer.getPosition() >= end ) break;
final Band band = intervals.get(i);
final float x = band.start, w = band.size;
// When not wrapping, give the last band an unlimited width so all
// remaining text is consumed here instead of wrapping to a new line.
final float nextWidth = (wrapLines || i < lastIdx) ? w : _UNBOUNDED_LINE_WIDTH;
final TextLayout layout = data.measurer.nextLayout(nextWidth);
if ( firstLayout == null ) {
firstLayout = layout;
firstX = x;
firstW = w;
} else {
if ( extras.isEmpty() ) extras = new ArrayList<>();
extras.add(new LayoutLine.Segment(layout, x, w));
}
}
}
if ( firstLayout != null ) {
paraLines.add(new LayoutLine(firstLayout, firstX, firstW, extras));
currentY += firstLayout.getAscent() + firstLayout.getDescent() + firstLayout.getLeading();
} else {
currentY += estLineHeight; // all intervals had zero width — skip band
}
}
final List<LayoutLine> immutableParaLines = Collections.unmodifiableList(paraLines);
data.wrappedLayouts.put(layoutKey, immutableParaLines);
lines.addAll(immutableParaLines);
}
} else {
// No obstacles and no wrapping — a single TextLayout suffices.
// Cache it so subsequent repaints (e.g. after a bounds change that does not
// affect wrapping) skip the TextLayout construction entirely.
if ( data.singleLayout == null ) {
final AttributedCharacterIterator it = data.attrStr.getIterator();
data.singleLayout = new TextLayout(it, frc);
}
lines.add(new LayoutLine(data.singleLayout, boundsX, boundsWidth));
currentY += data.singleLayout.getAscent() + data.singleLayout.getDescent() + data.singleLayout.getLeading();
}
// Return the entry to the cache so the next layout pass can reuse it.
_PARAGRAPH_DATA_CACHE.put(pooled, data);
}
// Strip a trailing blank-line marker that may have been emitted for a paragraph
// separator appearing at the very end of the text.
if ( !lines.isEmpty() && lines.get(lines.size() - 1).layout == null )
lines.remove(lines.size() - 1);
return lines;
}
/**
* Returns all contiguous obstacle-free horizontal intervals als {@link Band}s within
* {@code [boundsX, boundsX+boundsWidth]} for a line whose vertical band spans
* {@code [y, y+lineHeight]} in component coordinates, sorted left to right.
* <p>
* Each obstacle's contribution is derived from its <em>surface geometry</em> via
* {@link Area} intersection (not just the bounding box), so non-rectangular shapes
* such as circles or ellipses are handled correctly — only the actual chord at the
* current y-level is subtracted, not the full bounding-box width.<br>
* <b>
* "Holes" inside shapes are not treated as free space — the line is blocked across the full width of
* the shape, even if the obstacle has a hole at the current y-level. This is a conservative but
* performance-friendly approach that avoids the complexity of handling multiple disjoint
* free intervals per obstacle.
* </b>
* <p>
* Returning all intervals (rather than just the widest one) allows the caller to
* fill text into every free gap on a line, so text appears on both sides of an obstacle.
* If all horizontal space is consumed, an empty list is returned.
*
* @param y Top of the line in component coordinates.
* @param lineHeight Estimated height of one line (typically {@code font.getSize2D()}).
* @param boundsX Left edge of the text bounds in component coordinates.
* @param boundsWidth Full width of the text bounds.
* @param obstacles Shapes to avoid, in component coordinates.
* @return List of {@code new Band(xStart, width)} entries for every obstacle-free interval,
* ordered left to right. Each width is guaranteed to be {@code > 0}.
*/
private static List<Band> _freeIntervalsAt(
final float y,
final float lineHeight,
final float boundsX,
final float boundsWidth,
final Tuple<Shape> obstacles
) {
if ( obstacles.isEmpty() )
return Collections.singletonList(new Band( boundsX, boundsWidth ));
// Internal representation: {start, end} pairs (converted to {start, width} on exit)
List<Range> free = new ArrayList<>();
free.add(new Range( boundsX, boundsX + boundsWidth ));
final Area lineStrip = new Area(new Rectangle2D.Float(boundsX, y, boundsWidth, lineHeight));
for ( Shape obstacle : obstacles ) {
if ( obstacle instanceof TranslatedShape ) {
// Unwrap to get the actual translated geometry:
obstacle = ((TranslatedShape) obstacle).getTranslatedShape();
}
// Fast pre-check with the bounding box before the more expensive Area intersection
final Rectangle2D ob = obstacle.getBounds2D();
if ( ob.getMaxY() <= y || ob.getMinY() >= y + lineHeight )
continue;
// Intersect the exact obstacle geometry with the line strip
final Area intersection = new Area(obstacle);
intersection.intersect(lineStrip);
if ( intersection.isEmpty() )
continue;
free = narrowDownRangesToInclude(intersection, free);
}
if ( free.isEmpty() )
return Collections.emptyList();
// Convert {start, end} → {start, width}, filtering out zero-width gaps
final List<Band> result = new ArrayList<>(free.size());
for ( Range r : free ) {
final float w = r.end - r.start;
if ( w > 0f ) result.add(new Band( r.start, w ));
}
return result;
}
private static List<Range> narrowDownRangesToInclude( Area intersection, List<Range> free ) {
final Rectangle2D ib = intersection.getBounds2D();
final float oLeft = (float) ib.getMinX();
final float oRight = (float) ib.getMaxX();
// Subtract [oLeft, oRight] from every free interval (1-D interval difference)
final List<Range> remaining = new ArrayList<>(free.size() + 1);
for ( Range r : free ) {
final float a = r.start, b = r.end;
if ( oRight <= a || oLeft >= b ) {
remaining.add(r); // no overlap
} else {
if ( oLeft > a ) remaining.add(new Range( a, Math.min(b, oLeft) )); // left fragment
if ( oRight < b ) remaining.add(new Range( Math.max(a, oRight), b )); // right fragment
}
}
return remaining;
}
private static AttributedString _paragraphToAttributedString(
final Tuple<StyledString> paragraph,
final Font font,
final BoxModelConf boxModelConf // Needed for some font configs which have paints that depend on the box model (gradient offsets, etc.)
) {
final StringBuilder sb = new StringBuilder();
for ( StyledString s : paragraph )
sb.append(s.string());
final AttributedString attrStr = new AttributedString(sb.toString());
int index = 0;
for ( StyledString styledString : paragraph ) {
int styledStringLength = styledString.string().length();
if ( styledStringLength <= 0 )
continue; // Skip zero-length segments to avoid AttributedString IllegalArgumentException for empty ranges
int endIndex = index + styledStringLength;
if ( styledString.fontConf().isPresent() ) {
java.awt.Font localFont = styledString.fontConf().get().createDerivedFrom(font, boxModelConf).orElse(font);
attrStr.addAttribute(TextAttribute.FONT, localFont, index, endIndex);
attrStr.addAttributes(localFont.getAttributes(), index, endIndex);
} else {
attrStr.addAttribute(TextAttribute.FONT, font, index, endIndex);
attrStr.addAttributes(font.getAttributes(), index, endIndex);
}
index += styledStringLength;
}
return attrStr;
}
/**
* Holds a single laid-out text line together with the obstacle-free horizontal region
* it was fitted into. Both {@code regionX} and {@code regionWidth} are in component
* coordinates and are used by the renderer to position the line, including alignment
* (LEFT / CENTER / RIGHT) within the available region.
* <p>
* When obstacles split a visual line into more than one free interval, every interval
* beyond the first is captured as an extra {@link Segment} in {@code extraSegments}.
* All segments share the same baseline (y is not advanced between them).
* <p>
* A {@code null} {@code layout} represents a blank / empty line — the region fields
* still carry the full text-bounds width so that the blank contributes the correct height.
*/
static final class LayoutLine {
/** A secondary text fragment placed in one of the additional free intervals on this line. */
static final class Segment {
final TextLayout layout;
final float regionX;
final float regionWidth;
Segment(TextLayout layout, float regionX, float regionWidth) {
this.layout = layout;
this.regionX = regionX;
this.regionWidth = regionWidth;
}
}
final @Nullable TextLayout layout;
final float regionX;
final float regionWidth;
/** Additional same-baseline fragments produced when obstacles split the line. */
final List<Segment> extraSegments;
LayoutLine(@Nullable TextLayout layout, float regionX, float regionWidth) {
this(layout, regionX, regionWidth, Collections.emptyList());
}
LayoutLine(@Nullable TextLayout layout, float regionX, float regionWidth, List<Segment> extraSegments) {
this.layout = layout;
this.regionX = regionX;
this.regionWidth = regionWidth;
this.extraSegments = extraSegments;
}
}
private static final class Range {
final float start, end;
Range(float start, float end) {
this.start = start;
this.end = end;
}
}
private static final class Band {
final float start, size;
Band(float start, float size) {
this.start = start;
this.size = size;
}
@Override
public boolean equals( Object o ) {
if ( this == o ) return true;
if ( !(o instanceof Band) ) return false;
final Band other = (Band) o;
return Float.floatToIntBits(start) == Float.floatToIntBits(other.start) &&
Float.floatToIntBits(size) == Float.floatToIntBits(other.size);
}
@Override
public int hashCode() {
return 31 * Float.floatToIntBits(start) + Float.floatToIntBits(size);
}
}
/**
* Immutable cache key for {@link #_buildTextLayoutsAndPreferredHeight}.
* Contains every parameter of that method <em>except</em> the {@link FontRenderContext},
* which is assumed to be consistent across calls in the same rendering environment.
* <p>
* The {@code hashCode} is pre-computed on construction so repeated cache lookups are cheap.
* Floats are compared via {@link Float#floatToIntBits} to avoid {@code NaN} edge cases.
* {@link Shape} instances inside {@code obstacles} rely on their own {@code equals}/{@code hashCode}
* — most AWT shapes use identity, which is fine since {@link TextConf} is immutable and
* reuses the same {@link Tuple} reference across repeated style evaluations.
*/
private static final class TextLayoutKey {
private final Font font;
private final Tuple<Pooled<Paragraph>> paragraphs;
private final float boundsWidth;
private final float boundsX;
private final float boundsY;
private final boolean wrapLines;
private final BoxModelConf boxModelConf;
private final Tuple<Shape> obstacles;
private final int _hash;
TextLayoutKey(
Font font,
Tuple<Pooled<Paragraph>> paragraphs,
float boundsWidth,
float boundsX,
float boundsY,
boolean wrapLines,
BoxModelConf boxModelConf,
Tuple<Shape> obstacles
) {
this.font = font;
this.paragraphs = paragraphs;
this.boundsWidth = boundsWidth;
this.boundsX = boundsX;
this.boundsY = boundsY;
this.wrapLines = wrapLines;
this.boxModelConf = boxModelConf;
this.obstacles = obstacles;
this._hash = Objects.hash(
font, paragraphs,
Float.floatToIntBits(boundsWidth),
Float.floatToIntBits(boundsX),
Float.floatToIntBits(boundsY),
wrapLines, boxModelConf, obstacles
);
}
@Override
public boolean equals( Object o ) {
if ( this == o ) return true;
if ( !(o instanceof TextLayoutKey) ) return false;
final TextLayoutKey other = (TextLayoutKey) o;
return Float.floatToIntBits(boundsWidth) == Float.floatToIntBits(other.boundsWidth) &&
Float.floatToIntBits(boundsX) == Float.floatToIntBits(other.boundsX) &&
Float.floatToIntBits(boundsY) == Float.floatToIntBits(other.boundsY) &&
wrapLines == other.wrapLines &&
font .equals(other.font) &&
paragraphs .equals(other.paragraphs) &&
boxModelConf.equals(other.boxModelConf) &&
obstacles .equals(other.obstacles);
}
@Override public int hashCode() { return _hash; }
}
/**
* Secondary cache key used inside {@link ParagraphLayoutsData#wrappedLayouts}.
* <p>
* Together with the paragraph content and font configuration stored in the enclosing
* {@link ParagraphLayoutsData} entry, these four fields fully capture the layout
* context needed to reproduce the list of {@link LayoutLine}s produced for the
* wrap/obstacle path of a single paragraph:
* <ul>
* <li>{@code obstacles} — the shapes the text must flow around; equality uses each
* shape's own {@code equals} (most AWT shapes compare by value), so any
* positional change produces a new key and a cache miss.</li>
* <li>{@code paragraphY} — the y-coordinate at which this paragraph starts in
* component space; together with {@code obstacles} it determines the free
* intervals at every line within the paragraph.</li>
* <li>{@code wrapLines} — whether word-wrapping is active.</li>
* <li>{@code effectiveWidth} — the usable horizontal extent passed to
* {@link LineBreakMeasurer#nextLayout}.</li>
* </ul>
* Using {@code obstacles + paragraphY} rather than a snapshot of the first line's
* free intervals is essential for correctness: an obstacle that misses the first line
* but covers later lines would otherwise produce a stale cache hit after it moves.
* <p>
* {@code hashCode} is pre-computed; floats use {@link Float#floatToIntBits} for
* well-defined {@code NaN} handling.
*/
private static final class LineLayoutKey {
final Tuple<Shape> obstacles;
final float paragraphY;
final boolean wrapLines;
final float effectiveWidth;
final int _hash;
LineLayoutKey( Tuple<Shape> obstacles, float paragraphY, boolean wrapLines, float effectiveWidth ) {
this.obstacles = obstacles;
this.paragraphY = paragraphY;
this.wrapLines = wrapLines;
this.effectiveWidth = effectiveWidth;
this._hash = Objects.hash(obstacles, Float.floatToIntBits(paragraphY), wrapLines, Float.floatToIntBits(effectiveWidth));
}
@Override
public boolean equals( Object o ) {
if ( this == o ) return true;
if ( !(o instanceof LineLayoutKey) ) return false;
final LineLayoutKey other = (LineLayoutKey) o;
return wrapLines == other.wrapLines &&
Float.floatToIntBits(paragraphY) == Float.floatToIntBits(other.paragraphY) &&
Float.floatToIntBits(effectiveWidth) == Float.floatToIntBits(other.effectiveWidth) &&
obstacles.equals(other.obstacles);
}
@Override public int hashCode() { return _hash; }
}
/**
* Per-paragraph cache entry stored in {@link #_PARAGRAPH_DATA_CACHE}.
* <p>
* Holds everything that can be reused across multiple layout passes for the same
* paragraph text and font configuration:
* <ul>
* <li>{@link #attrStr} — the {@link AttributedString} built once from the styled
* strings and their font attributes; avoids repeating the O(n) attribute-setup
* work on every repaint.</li>
* <li>{@link #measurer} — reusable {@link LineBreakMeasurer} ({@code null} while
* borrowed by a concurrent caller or not yet created).</li>
* <li>{@link #singleLayout} — cached {@link TextLayout} for the no-wrap,
* no-obstacle path. {@code null} until first computed.</li>
* <li>{@link #wrappedLayouts} — cached {@link LayoutLine} lists for the
* wrap/obstacle path, keyed by {@link LineLayoutKey}.</li>
* </ul>
* {@link #font} are stored so that a stale entry built
* for a different styling context is detected and discarded on retrieval.
* <b>A {@link BoxModelConf} is NOT needed, because although it is used by a font for gradient
* and noise paint configuration, this will never affect the layout!</b>
*/
private static final class ParagraphLayoutsData {
final Font font;
final AttributedString attrStr;
/** {@code null} while borrowed by a caller or not yet created. */
@Nullable LineBreakMeasurer measurer;
/** Non-null once the no-wrap, no-obstacle path has run at least once. */
@Nullable TextLayout singleLayout;
/** Cached full-paragraph {@link LayoutLine} lists for the wrap/obstacle path. */
final Map<LineLayoutKey, List<LayoutLine>> wrappedLayouts = new LinkedHashMap<LineLayoutKey, List<LayoutLine>>(
32 + 1, 0.75f, true /* access-order */
) {
@Override protected boolean removeEldestEntry(Map.Entry<LineLayoutKey, List<LayoutLine>> eldest) {
return size() > 32;
}
};
ParagraphLayoutsData( Font font, AttributedString attrStr ) {
this.font = font;
this.attrStr = attrStr;
}
boolean isCompatibleWith( Font font ) {
return this.font.equals(font);
}
}
/**
* This wrapper class exists to make caching more effective by using the source shape
* and the offsets as the basis for equality and hash code, rather than the translated shape's geometry.
* <b>
* This is because {@code AffineTransform.getTranslateInstance(xOffset, yOffset)} produces shapes
* with non-deterministic value semantics for equals() and hashCode().
* </b>
*/
private static final class TranslatedShape implements Shape {
private final int _xOffset;
private final int _yOffset;
private final Shape _sourceShape;
private final Shape _translatedShape;
private final AtomicReference<@Nullable Integer> _hashCache = new AtomicReference<>(null);
public TranslatedShape(Shape shapeArea, int xOffset, int yOffset) {
java.awt.geom.AffineTransform translate = java.awt.geom.AffineTransform.getTranslateInstance(xOffset, yOffset);
_xOffset = xOffset;
_yOffset = yOffset;
_sourceShape = shapeArea;
_translatedShape = translate.createTransformedShape(shapeArea);
}
public Shape getTranslatedShape() { return _translatedShape; }
@Override
public Rectangle getBounds() { return _translatedShape.getBounds(); }
@Override
public Rectangle2D getBounds2D() { return _translatedShape.getBounds2D(); }
@Override
public boolean contains(double x, double y) { return _translatedShape.contains(x, y); }
@Override
public boolean contains(Point2D p) { return _translatedShape.contains(p); }
@Override
public boolean intersects(double x, double y, double w, double h) {
return _translatedShape.intersects(x, y, w, h);
}
@Override
public boolean intersects(Rectangle2D r) {
return _translatedShape.intersects(r);
}
@Override
public boolean contains(double x, double y, double w, double h) {
return _translatedShape.contains(x, y, w, h);
}
@Override
public boolean contains(Rectangle2D r) {
return _translatedShape.contains(r);
}
@Override
public PathIterator getPathIterator(AffineTransform at) {
return _translatedShape.getPathIterator(at);
}
@Override
public PathIterator getPathIterator(AffineTransform at, double flatness) {
return _translatedShape.getPathIterator(at, flatness);
}
@Override
public int hashCode() {
Integer cachedHash = _hashCache.get();
if ( cachedHash != null ) {
return cachedHash;
} else {
int computedHash = Objects.hash(_xOffset, _yOffset, _sourceShape);
_hashCache.compareAndSet(null, computedHash);
return computedHash;
}
}
@Override
public boolean equals(@Nullable Object obj) {
if ( this == obj ) return true;
if ( obj == null || getClass() != obj.getClass() ) return false;
final TranslatedShape other = (TranslatedShape) obj;
return _xOffset == other._xOffset &&
_yOffset == other._yOffset &&
Objects.equals(_sourceShape, other._sourceShape);
}
@Override
public String toString() {
return "TranslatedShape[source=" + _sourceShape + ", xOffset=" + _xOffset + ", yOffset=" + _yOffset + "]";
}
}
}