From 4c40cab239b976389783300742ffa92dee83561f Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 6 Jul 2026 08:37:42 +0100
Subject: [PATCH 1/2] refactor(engine): extract TokenBreaking from
TextFlowSupport
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The token-breaking family — breakLongToken, softBreakSegments, splitLongToken
and fitCharacters — was pure string logic reachable only through breakLongToken
from the three wrap loops, yet covered only end-to-end via the paragraph render
tests. Move it verbatim into a package-private TokenBreaking; TextFlowSupport
now calls TokenBreaking.breakLongToken(...) at its three wrap sites. Behaviour is
unchanged (methods lifted as-is) and the wrap file drops ~125 lines.
Add TokenBreakingTest: direct coverage of seam segmentation, greedy packing, the
char-level fallback and the min-one-char guarantee, driven by a deterministic
fixed-width measurement double so the assertions do not depend on font metrics.
---
.../document/layout/TextFlowSupport.java | 132 +-------------
.../document/layout/TokenBreaking.java | 161 ++++++++++++++++++
.../document/layout/TokenBreakingTest.java | 124 ++++++++++++++
3 files changed, 288 insertions(+), 129 deletions(-)
create mode 100644 src/main/java/com/demcha/compose/document/layout/TokenBreaking.java
create mode 100644 src/test/java/com/demcha/compose/document/layout/TokenBreakingTest.java
diff --git a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java
index 583939518..ef9b41802 100644
--- a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java
+++ b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java
@@ -858,7 +858,7 @@ private static List wrapParagraph(List logicalLines,
// Over-wide on a fresh (or already empty) line: break it at soft seams,
// char-splitting as a last resort.
double availableWidth = availableWidthForPrefix(maxWidth, currentPrefix, style, measurement);
- List chunks = breakLongToken(strippedToken, style, availableWidth, measurement);
+ List chunks = TokenBreaking.breakLongToken(strippedToken, style, availableWidth, measurement);
if (chunks.isEmpty()) {
continue;
}
@@ -987,7 +987,7 @@ private static List wrapInlineParagraph(List runs,
// coalesced fill inside the column. Interior fragments under-fill by
// at most that padding — cosmetic, and only on an over-wide chip.
double reserve = chip ? textToken.leadPad() + textToken.trailPad() : 0.0;
- List pieces = breakLongToken(
+ List pieces = TokenBreaking.breakLongToken(
textToken.text(),
textToken.textStyle(),
Math.max(1.0, maxWidth - currentWidth - reserve),
@@ -1117,7 +1117,7 @@ private static List wrapMarkdownParagraph(List logicalLin
}
}
- List chunks = breakLongToken(
+ List chunks = TokenBreaking.breakLongToken(
sanitizedToken.text(),
sanitizedToken.textStyle(),
Math.max(1.0, maxWidth - currentWidth),
@@ -1631,132 +1631,6 @@ private static double lineWidth(List bodies,
return width;
}
- /**
- * Characters after which a long, otherwise-unbreakable token may wrap. These
- * are the joiners inside package coordinates, fully-qualified class names and
- * URLs ({@code org.junit.jupiter:junit-jupiter:5.10.2}, {@code a/b/c}), so an
- * over-wide code token breaks at a readable seam instead of mid-identifier.
- */
- private static final String SOFT_BREAK_AFTER_CHARS = ".:/-";
-
- /**
- * Breaks a single over-wide token into line-pieces that each fit
- * {@code maxWidth}. Prefers breaking at {@link #SOFT_BREAK_AFTER_CHARS soft
- * seams} (after {@code . : / -}) so coordinates and URLs split naturally, and
- * falls back to character-level {@link #splitLongToken splitting} for any lone
- * segment still too wide (e.g. a long dot-less identifier). The returned pieces
- * have the same shape the wrap loops already consume from {@code splitLongToken}
- * — for a token with no soft seams the two are identical.
- */
- private static List breakLongToken(String token,
- TextStyle style,
- double maxWidth,
- TextMeasurementSystem measurement) {
- if (token == null || token.isEmpty()) {
- return List.of();
- }
- List segments = softBreakSegments(token);
- List pieces = new ArrayList<>();
- StringBuilder current = new StringBuilder();
- double currentWidth = 0.0;
- for (String segment : segments) {
- double segmentWidth = measurement.textWidth(style, segment);
- if (current.length() > 0 && currentWidth + segmentWidth > maxWidth + EPS) {
- pieces.add(current.toString());
- current.setLength(0);
- currentWidth = 0.0;
- }
- if (current.length() == 0 && segmentWidth > maxWidth + EPS) {
- // A single seam-delimited segment is itself too wide: char-split it.
- // Every char-chunk but the last is a finished piece; the tail seeds
- // the current piece so a following short segment can still join it.
- List chars = splitLongToken(segment, style, maxWidth, measurement);
- for (int index = 0; index < chars.size() - 1; index++) {
- pieces.add(chars.get(index));
- }
- String tail = chars.get(chars.size() - 1);
- current.append(tail);
- currentWidth = measurement.textWidth(style, tail);
- } else {
- current.append(segment);
- currentWidth += segmentWidth;
- }
- }
- if (current.length() > 0) {
- pieces.add(current.toString());
- }
- return List.copyOf(pieces);
- }
-
- /**
- * Splits a token after each {@link #SOFT_BREAK_AFTER_CHARS soft-break
- * character}, keeping the delimiter with the preceding segment
- * ({@code org.junit.jupiter:} then {@code junit-jupiter:} then {@code 5.10.2}).
- * A token with no such characters yields a single segment (itself).
- */
- private static List softBreakSegments(String token) {
- List segments = new ArrayList<>();
- int start = 0;
- for (int index = 0; index < token.length(); index++) {
- if (SOFT_BREAK_AFTER_CHARS.indexOf(token.charAt(index)) >= 0) {
- segments.add(token.substring(start, index + 1));
- start = index + 1;
- }
- }
- if (start < token.length()) {
- segments.add(token.substring(start));
- }
- return segments;
- }
-
- private static List splitLongToken(String token,
- TextStyle style,
- double maxWidth,
- TextMeasurementSystem measurement) {
- if (token == null || token.isEmpty()) {
- return List.of();
- }
-
- List pieces = new ArrayList<>();
- String remaining = token;
- while (!remaining.isEmpty()) {
- int splitIndex = fitCharacters(remaining, style, maxWidth, measurement);
- if (splitIndex <= 0 || splitIndex >= remaining.length()) {
- pieces.add(remaining);
- break;
- }
- pieces.add(remaining.substring(0, splitIndex));
- remaining = remaining.substring(splitIndex).stripLeading();
- }
- return List.copyOf(pieces);
- }
-
- private static int fitCharacters(String text,
- TextStyle style,
- double maxWidth,
- TextMeasurementSystem measurement) {
- // Largest prefix length whose width fits. The fit predicate
- // width(substring(0,n)) <= maxWidth is monotonic in n (each added char
- // contributes a non-negative glyph advance), so the fitting lengths form
- // a prefix [1..lastFitting] and a binary search finds the SAME boundary
- // as the old linear scan — but in O(log n) width calls instead of
- // measuring every growing prefix (which was O(n) calls and O(n^2)
- // measured characters for a long unbreakable token).
- int lastFitting = 0;
- int low = 1;
- int high = text.length();
- while (low <= high) {
- int mid = (low + high) >>> 1;
- if (measurement.textWidth(style, text.substring(0, mid)) <= maxWidth + EPS) {
- lastFitting = mid;
- low = mid + 1;
- } else {
- high = mid - 1;
- }
- }
- return lastFitting == 0 ? Math.min(1, text.length()) : lastFitting;
- }
-
private static String trimTrailingSpaces(String value) {
int end = value.length();
while (end > 0 && Character.isWhitespace(value.charAt(end - 1))) {
diff --git a/src/main/java/com/demcha/compose/document/layout/TokenBreaking.java b/src/main/java/com/demcha/compose/document/layout/TokenBreaking.java
new file mode 100644
index 000000000..e0ed8f084
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/layout/TokenBreaking.java
@@ -0,0 +1,161 @@
+package com.demcha.compose.document.layout;
+
+import com.demcha.compose.engine.components.content.text.TextStyle;
+import com.demcha.compose.engine.measurement.TextMeasurementSystem;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static com.demcha.compose.document.layout.NodeDefinitionSupport.EPS;
+
+/**
+ * Breaks a single over-wide, otherwise-unbreakable token into line-pieces that
+ * each fit a width budget. Prefers wrapping at readable soft seams
+ * ({@code . : / -}) so package coordinates, fully-qualified class names and URLs
+ * split naturally, and falls back to character-level splitting for a lone
+ * segment that is still too wide.
+ *
+ * Extracted from {@link TextFlowSupport} (which enters only through
+ * {@link #breakLongToken}) so the pure breaking logic is unit-testable in
+ * isolation and does not grow the wrap-loop file. Package-private engine
+ * internal.
+ */
+final class TokenBreaking {
+
+ /**
+ * Characters after which a long, otherwise-unbreakable token may wrap. These
+ * are the joiners inside package coordinates, fully-qualified class names and
+ * URLs ({@code org.junit.jupiter:junit-jupiter:5.10.2}, {@code a/b/c}), so an
+ * over-wide code token breaks at a readable seam instead of mid-identifier.
+ */
+ private static final String SOFT_BREAK_AFTER_CHARS = ".:/-";
+
+ private TokenBreaking() {
+ }
+
+ /**
+ * Breaks a single over-wide token into line-pieces that each fit
+ * {@code maxWidth}. Prefers breaking at {@link #SOFT_BREAK_AFTER_CHARS soft
+ * seams} (after {@code . : / -}) so coordinates and URLs split naturally, and
+ * falls back to character-level {@link #splitLongToken splitting} for any lone
+ * segment still too wide (e.g. a long dot-less identifier). The returned pieces
+ * have the same shape the wrap loops consume — for a token with no soft seams
+ * the result is identical to a bare {@link #splitLongToken}.
+ */
+ static List breakLongToken(String token,
+ TextStyle style,
+ double maxWidth,
+ TextMeasurementSystem measurement) {
+ if (token == null || token.isEmpty()) {
+ return List.of();
+ }
+ List segments = softBreakSegments(token);
+ List pieces = new ArrayList<>();
+ StringBuilder current = new StringBuilder();
+ double currentWidth = 0.0;
+ for (String segment : segments) {
+ double segmentWidth = measurement.textWidth(style, segment);
+ if (current.length() > 0 && currentWidth + segmentWidth > maxWidth + EPS) {
+ pieces.add(current.toString());
+ current.setLength(0);
+ currentWidth = 0.0;
+ }
+ if (current.length() == 0 && segmentWidth > maxWidth + EPS) {
+ // A single seam-delimited segment is itself too wide: char-split it.
+ // Every char-chunk but the last is a finished piece; the tail seeds
+ // the current piece so a following short segment can still join it.
+ List chars = splitLongToken(segment, style, maxWidth, measurement);
+ for (int index = 0; index < chars.size() - 1; index++) {
+ pieces.add(chars.get(index));
+ }
+ String tail = chars.get(chars.size() - 1);
+ current.append(tail);
+ currentWidth = measurement.textWidth(style, tail);
+ } else {
+ current.append(segment);
+ currentWidth += segmentWidth;
+ }
+ }
+ if (current.length() > 0) {
+ pieces.add(current.toString());
+ }
+ return List.copyOf(pieces);
+ }
+
+ /**
+ * Splits a token after each {@link #SOFT_BREAK_AFTER_CHARS soft-break
+ * character}, keeping the delimiter with the preceding segment
+ * ({@code org.junit.jupiter:} then {@code junit-jupiter:} then {@code 5.10.2}).
+ * A token with no such characters yields a single segment (itself).
+ */
+ static List softBreakSegments(String token) {
+ List segments = new ArrayList<>();
+ int start = 0;
+ for (int index = 0; index < token.length(); index++) {
+ if (SOFT_BREAK_AFTER_CHARS.indexOf(token.charAt(index)) >= 0) {
+ segments.add(token.substring(start, index + 1));
+ start = index + 1;
+ }
+ }
+ if (start < token.length()) {
+ segments.add(token.substring(start));
+ }
+ return segments;
+ }
+
+ /**
+ * Character-level fallback: splits a token into the longest prefixes that each
+ * fit {@code maxWidth}, guaranteeing at least one character per piece.
+ */
+ static List splitLongToken(String token,
+ TextStyle style,
+ double maxWidth,
+ TextMeasurementSystem measurement) {
+ if (token == null || token.isEmpty()) {
+ return List.of();
+ }
+
+ List pieces = new ArrayList<>();
+ String remaining = token;
+ while (!remaining.isEmpty()) {
+ int splitIndex = fitCharacters(remaining, style, maxWidth, measurement);
+ if (splitIndex <= 0 || splitIndex >= remaining.length()) {
+ pieces.add(remaining);
+ break;
+ }
+ pieces.add(remaining.substring(0, splitIndex));
+ remaining = remaining.substring(splitIndex).stripLeading();
+ }
+ return List.copyOf(pieces);
+ }
+
+ /**
+ * Largest prefix length of {@code text} whose width fits {@code maxWidth},
+ * or 1 (the guaranteed minimum) when not even the first character fits.
+ */
+ static int fitCharacters(String text,
+ TextStyle style,
+ double maxWidth,
+ TextMeasurementSystem measurement) {
+ // Largest prefix length whose width fits. The fit predicate
+ // width(substring(0,n)) <= maxWidth is monotonic in n (each added char
+ // contributes a non-negative glyph advance), so the fitting lengths form
+ // a prefix [1..lastFitting] and a binary search finds the SAME boundary
+ // as the old linear scan — but in O(log n) width calls instead of
+ // measuring every growing prefix (which was O(n) calls and O(n^2)
+ // measured characters for a long unbreakable token).
+ int lastFitting = 0;
+ int low = 1;
+ int high = text.length();
+ while (low <= high) {
+ int mid = (low + high) >>> 1;
+ if (measurement.textWidth(style, text.substring(0, mid)) <= maxWidth + EPS) {
+ lastFitting = mid;
+ low = mid + 1;
+ } else {
+ high = mid - 1;
+ }
+ }
+ return lastFitting == 0 ? Math.min(1, text.length()) : lastFitting;
+ }
+}
diff --git a/src/test/java/com/demcha/compose/document/layout/TokenBreakingTest.java b/src/test/java/com/demcha/compose/document/layout/TokenBreakingTest.java
new file mode 100644
index 000000000..e38754865
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/layout/TokenBreakingTest.java
@@ -0,0 +1,124 @@
+package com.demcha.compose.document.layout;
+
+import com.demcha.compose.engine.components.content.text.TextStyle;
+import com.demcha.compose.engine.components.geometry.ContentSize;
+import com.demcha.compose.engine.measurement.TextMeasurementSystem;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Direct unit coverage for {@link TokenBreaking} — soft-seam segmentation, greedy
+ * packing, and the character-level fallback — driven by a deterministic
+ * fixed-width measurement so the assertions do not depend on real font metrics.
+ */
+class TokenBreakingTest {
+
+ private static final TextStyle STYLE = TextStyle.DEFAULT_STYLE;
+ /** Each character is exactly 1.0 wide, so a token's width equals its length. */
+ private static final TextMeasurementSystem UNIT = new FixedWidthMeasurement(1.0);
+
+ // ── softBreakSegments (pure, no measurement) ────────────────────────────
+
+ @Test
+ void softBreakSegmentsSplitsAfterEachSeamKeepingTheDelimiter() {
+ assertThat(TokenBreaking.softBreakSegments("org.junit.jupiter:junit-jupiter:5.10.2"))
+ .containsExactly("org.", "junit.", "jupiter:", "junit-", "jupiter:", "5.", "10.", "2");
+ }
+
+ @Test
+ void softBreakSegmentsWithNoSeamReturnsTheWholeToken() {
+ assertThat(TokenBreaking.softBreakSegments("abcdefgh")).containsExactly("abcdefgh");
+ }
+
+ @Test
+ void softBreakSegmentsHandlesLeadingAndTrailingDelimiters() {
+ assertThat(TokenBreaking.softBreakSegments("/a/b")).containsExactly("/", "a/", "b");
+ assertThat(TokenBreaking.softBreakSegments("...")).containsExactly(".", ".", ".");
+ // The concatenation of the segments always reconstructs the input.
+ assertThat(String.join("", TokenBreaking.softBreakSegments("a.b:c/d-e"))).isEqualTo("a.b:c/d-e");
+ }
+
+ // ── fitCharacters ───────────────────────────────────────────────────────
+
+ @Test
+ void fitCharactersReturnsTheLongestFittingPrefix() {
+ assertThat(TokenBreaking.fitCharacters("abcdefgh", STYLE, 3.0, UNIT)).isEqualTo(3);
+ }
+
+ @Test
+ void fitCharactersReturnsAtLeastOneCharBelowGlyphWidth() {
+ assertThat(TokenBreaking.fitCharacters("abc", STYLE, 0.4, UNIT)).isEqualTo(1);
+ }
+
+ // ── splitLongToken (char-level) ─────────────────────────────────────────
+
+ @Test
+ void splitLongTokenBreaksIntoFittingChunks() {
+ assertThat(TokenBreaking.splitLongToken("abcdefgh", STYLE, 3.0, UNIT))
+ .containsExactly("abc", "def", "gh");
+ }
+
+ // ── breakLongToken (seam-aware, char-split fallback) ────────────────────
+
+ @Test
+ void breakLongTokenWithoutSeamsMatchesSplitLongToken() {
+ List viaBreak = TokenBreaking.breakLongToken("abcdefgh", STYLE, 3.0, UNIT);
+ List viaSplit = TokenBreaking.splitLongToken("abcdefgh", STYLE, 3.0, UNIT);
+ assertThat(viaBreak).isEqualTo(viaSplit).containsExactly("abc", "def", "gh");
+ }
+
+ @Test
+ void breakLongTokenPacksSegmentsAtSeams() {
+ // "org." + "junit." fill width 10 exactly; "jupiter" starts a new piece.
+ assertThat(TokenBreaking.breakLongToken("org.junit.jupiter", STYLE, 10.0, UNIT))
+ .containsExactly("org.junit.", "jupiter");
+ }
+
+ @Test
+ void breakLongTokenCharSplitsAnOverWideSegment() {
+ // "verylongsegment." (16) exceeds width 5, so it char-splits; the tail seeds
+ // the piece that the following short "x" segment then joins.
+ assertThat(TokenBreaking.breakLongToken("verylongsegment.x", STYLE, 5.0, UNIT))
+ .containsExactly("veryl", "ongse", "gment", ".x");
+ }
+
+ @Test
+ void breakLongTokenEveryPieceFitsAndReconstructsTheInput() {
+ for (String token : List.of(
+ "org.junit.jupiter:junit-jupiter:5.10.2",
+ "https://repo1.maven.org/maven2/",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) {
+ for (double width : new double[] {1.0, 4.0, 7.0, 12.0}) {
+ List pieces = TokenBreaking.breakLongToken(token, STYLE, width, UNIT);
+ assertThat(String.join("", pieces))
+ .as("pieces reconstruct the token at width %s", width).isEqualTo(token);
+ assertThat(pieces).allSatisfy(piece ->
+ assertThat((double) piece.length())
+ .as("each piece fits width %s", width)
+ .isLessThanOrEqualTo(width + 1e-6));
+ }
+ }
+ }
+
+ @Test
+ void breakLongTokenEmptyOrNullReturnsEmpty() {
+ assertThat(TokenBreaking.breakLongToken("", STYLE, 5.0, UNIT)).isEmpty();
+ assertThat(TokenBreaking.breakLongToken(null, STYLE, 5.0, UNIT)).isEmpty();
+ }
+
+ /** Deterministic measurement: width == character count * unit; metrics are inert. */
+ private record FixedWidthMeasurement(double unit) implements TextMeasurementSystem {
+ @Override
+ public ContentSize measure(TextStyle style, String text) {
+ return new ContentSize(text.length() * unit, unit);
+ }
+
+ @Override
+ public LineMetrics lineMetrics(TextStyle style) {
+ return new LineMetrics(unit, 0.0, 0.0);
+ }
+ }
+}
From d335e3a5a336af501fd8f04ab007f0abb43e4329 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 6 Jul 2026 08:53:28 +0100
Subject: [PATCH 2/2] refactor(engine): harden the TokenBreaking helper surface
Guard softBreakSegments against null/empty (splitLongToken already did, and both
are now package-visible directly-callable entry points), add the @author tag to
match the sibling layout helpers, and cover the null/empty and fits-whole edges
directly in TokenBreakingTest.
---
.../compose/document/layout/TokenBreaking.java | 9 +++++++--
.../compose/document/layout/TokenBreakingTest.java | 12 ++++++++++++
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/src/main/java/com/demcha/compose/document/layout/TokenBreaking.java b/src/main/java/com/demcha/compose/document/layout/TokenBreaking.java
index e0ed8f084..136cb0a65 100644
--- a/src/main/java/com/demcha/compose/document/layout/TokenBreaking.java
+++ b/src/main/java/com/demcha/compose/document/layout/TokenBreaking.java
@@ -17,8 +17,10 @@
*
* Extracted from {@link TextFlowSupport} (which enters only through
* {@link #breakLongToken}) so the pure breaking logic is unit-testable in
- * isolation and does not grow the wrap-loop file. Package-private engine
- * internal.
+ * isolation and does not grow the wrap-loop file. Package-private intentionally
+ * — engine surface, not public API.
+ *
+ * @author Artem Demchyshyn
*/
final class TokenBreaking {
@@ -89,6 +91,9 @@ static List breakLongToken(String token,
* A token with no such characters yields a single segment (itself).
*/
static List softBreakSegments(String token) {
+ if (token == null || token.isEmpty()) {
+ return List.of();
+ }
List segments = new ArrayList<>();
int start = 0;
for (int index = 0; index < token.length(); index++) {
diff --git a/src/test/java/com/demcha/compose/document/layout/TokenBreakingTest.java b/src/test/java/com/demcha/compose/document/layout/TokenBreakingTest.java
index e38754865..e7001f7d1 100644
--- a/src/test/java/com/demcha/compose/document/layout/TokenBreakingTest.java
+++ b/src/test/java/com/demcha/compose/document/layout/TokenBreakingTest.java
@@ -41,6 +41,12 @@ void softBreakSegmentsHandlesLeadingAndTrailingDelimiters() {
assertThat(String.join("", TokenBreaking.softBreakSegments("a.b:c/d-e"))).isEqualTo("a.b:c/d-e");
}
+ @Test
+ void softBreakSegmentsOnNullOrEmptyIsEmpty() {
+ assertThat(TokenBreaking.softBreakSegments(null)).isEmpty();
+ assertThat(TokenBreaking.softBreakSegments("")).isEmpty();
+ }
+
// ── fitCharacters ───────────────────────────────────────────────────────
@Test
@@ -70,6 +76,12 @@ void breakLongTokenWithoutSeamsMatchesSplitLongToken() {
assertThat(viaBreak).isEqualTo(viaSplit).containsExactly("abc", "def", "gh");
}
+ @Test
+ void breakLongTokenReturnsTheWholeTokenWhenItAlreadyFits() {
+ // A no-seam token that fits the budget is returned as a single piece.
+ assertThat(TokenBreaking.breakLongToken("abc", STYLE, 10.0, UNIT)).containsExactly("abc");
+ }
+
@Test
void breakLongTokenPacksSegmentsAtSeams() {
// "org." + "junit." fill width 10 exactly; "jupiter" starts a new piece.