Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 165 additions & 4 deletions src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.text.BreakIterator;
import java.text.CharacterIterator;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
Expand All @@ -12,6 +13,7 @@
import com.hfstudio.guidenh.guide.color.ConstantColor;
import com.hfstudio.guidenh.guide.document.DefaultStyles;
import com.hfstudio.guidenh.guide.document.LytRect;
import com.hfstudio.guidenh.guide.document.block.LytBlock;
import com.hfstudio.guidenh.guide.document.block.LytImage;
import com.hfstudio.guidenh.guide.document.block.LytItemImage;
import com.hfstudio.guidenh.guide.document.flow.InlineBlockAlignment;
Expand Down Expand Up @@ -141,6 +143,11 @@ private void appendInlineBlock(LytFlowInlineBlock inlineBlock) {

// Is there enough space to have this element here?
var outerWidth = size.width() + marginLeft + marginRight;
boolean floating = inlineBlock.getAlignment() == InlineBlockAlignment.FLOAT_LEFT
|| inlineBlock.getAlignment() == InlineBlockAlignment.FLOAT_RIGHT;
if (!floating && outerWidth > remainingLineWidth && isInlineImage(block) && !openLineEndsWithBreakOpportunity) {
moveGluedTailToNextLine(false);
}
ensureSpaceIsAvailable(outerWidth);

var el = new LineBlock(block);
Expand Down Expand Up @@ -413,6 +420,10 @@ private boolean iterateRuns(CharSequence text, ResolvedTextStyle style, char las
continue;
}

if (!canBreakAtStart && wordBreakOffset != 0) {
// Text glued to an inline image (e.g. punctuation after an item link) takes it along
moveGluedTailToNextLine(true);
}
ensureTextSpaceIsAvailable(advance);

// If no wider lane exists (for example, a genuinely narrow table cell), keep
Expand All @@ -422,6 +433,15 @@ private boolean iterateRuns(CharSequence text, ResolvedTextStyle style, char las
visibleGlyphSeen = visibleGlyphSeen || advance > 0f;
continue;
} else {
// The word touches an inline image at the end of the line (e.g. an item link's icon):
// wrap them together instead of splitting the word.
if (moveGluedTailToNextLine(true) && curLineWidth + advance <= remainingLineWidth) {
curLineWidth += advance;
appendCodePointToLineBuffer(codePoint, advance);
visibleGlyphSeen = visibleGlyphSeen || advance > 0f;
continue;
}

// We exceeded the line length, but did not find a break opportunity
// this causes a forced break mid-word
consumer.visitRun(lineBuffer, curLineWidth, true);
Expand Down Expand Up @@ -700,12 +720,153 @@ private int getInlineImageYOffset(LineElement element) {
}

private boolean isInlineImage(LineElement element) {
if (!(element instanceof LineBlock lineBlock)) {
return element instanceof LineBlock lineBlock && !lineBlock.floating && isInlineImage(lineBlock.getBlock());
}

private static boolean isInlineImage(LytBlock block) {
return block instanceof LytImage
|| block instanceof LytItemImage itemImage && itemImage.isInline() && itemImage.isShowingIcon();
}

/**
* Moves the end of the open line that offers no break opportunity onto a new line: the inline images at its end
* plus the word directly touching them. Keeps an item link's icon on the same line as its label.
*
* @param requireTrailingImage whether the open line has to end on an inline image; otherwise only the last word is
* moved, as it touches an image that is about to be appended
*/
private boolean moveGluedTailToNextLine(boolean requireTrailingImage) {
if (openLineElement == null) {
return false;
}

var elements = new ArrayList<LineElement>();
// Offset of each element from where the previous one ended (inline block margins)
var offsets = new ArrayList<Integer>();
int origin = 0;
for (var el = openLineElement; el != null; el = el.next) {
elements.add(el);
offsets.add(el.bounds.x() - origin);
origin += el.bounds.width();
}

int start = elements.size();
while (start > 0 && isInlineImage(elements.get(start - 1))) {
start--;
}
if (requireTrailingImage && start == elements.size()) {
return false;
}
return lineBlock.getBlock() instanceof LytImage
|| lineBlock.getBlock() instanceof LytItemImage itemImage && itemImage.isInline()
&& itemImage.isShowingIcon();

if (start > 0 && elements.get(start - 1) instanceof LineTextRun run) {
int wordStart = findGluedWordStart(run);
if (wordStart > 0) {
var head = run.text.substring(0, wordStart);
var headRun = copyTextRun(run, head, run.bounds.x());
var tailRun = copyTextRun(run, collectFormattingCodes(head) + run.text.substring(wordStart), 0);
elements.set(start - 1, headRun);
elements.add(start, tailRun);
offsets.add(start, 0);
} else if (wordStart == 0 && start >= 2 && endsWithBreakOpportunity(elements.get(start - 2))) {
start--;
}
}
if (start == 0 || start == elements.size()) {
// Either the whole line is one unbreakable unit or there is nothing to move
return false;
}

openLineElement = null;
openLineTail = null;
openLineMinTop = Integer.MAX_VALUE;
openLineMaxBottom = 0;
openLineMaxRight = 0;
for (int i = 0; i < start; i++) {
var el = elements.get(i);
el.next = null;
reappendToOpenLine(el);
}
endLine();

int x = 0;
for (int i = start; i < elements.size(); i++) {
var el = elements.get(i);
el.next = null;
el.bounds = el.bounds.withX(x + offsets.get(i));
reappendToOpenLine(el);
x += el.bounds.width();
}
innerX = x;
remainingLineWidth -= x;
openLineEndsWithBreakOpportunity = false;
return true;
}

private void reappendToOpenLine(LineElement el) {
appendToOpenLine(el);
if (el instanceof LineBlock && el.flowContent != null) {
var style = el.flowContent.resolveStyle();
includeVerticalBounds(-context.getAscent(style), context.getDescent(style));
}
}

/**
* @return where the word at the end of the run starts, or -1 if the run ends on a break opportunity
*/
private int findGluedWordStart(LineTextRun run) {
if (run.text.isEmpty() || run.style.inlineCode() || Character.isWhitespace(findLastVisibleChar(run.text))) {
return -1;
}
var breakIterator = LINE_BREAK_ITERATOR.get();
breakIterator.setText(run.text);
return Math.max(0, breakIterator.preceding(run.text.length()));
}

private boolean endsWithBreakOpportunity(LineElement element) {
if (element instanceof LineTextRun run) {
return Character.isWhitespace(findLastVisibleChar(run.text));
}
return !isInlineImage(element);
}

private LineTextRun copyTextRun(LineTextRun run, String text, int x) {
var copy = new LineTextRun(text, run.style, run.revealStyle, run.hoverStyle);
copy.flowContent = run.flowContent;
copy.bounds = new LytRect(x, run.bounds.y(), measureText(text, run.style), run.bounds.height());
return copy;
}

private int measureText(CharSequence text, ResolvedTextStyle style) {
float width = 0f;
boolean visibleGlyphSeen = false;
for (int index = 0; index < text.length(); index++) {
if (GuideFontCompat.isFormattingCodeStart(text, index)) {
index++;
continue;
}
int codePoint = Character.codePointAt(text, index);
float advance = context.getRenderedAdvance(codePoint, style, visibleGlyphSeen);
width += Math.max(0f, advance);
index += Character.charCount(codePoint) - 1;
if (advance > 0f) {
visibleGlyphSeen = true;
}
}
return Math.round(width);
}

/**
* @return the formatting codes in the text, so text split off after it keeps rendering the same
*/
private static String collectFormattingCodes(CharSequence text) {
var codes = new StringBuilder();
for (int index = 0; index < text.length(); index++) {
if (GuideFontCompat.isFormattingCodeStart(text, index)) {
codes.append(text, index, index + 2);
index++;
}
}
return codes.toString();
}

public void end() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package com.hfstudio.guidenh.guide.layout.flow;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.ArrayList;
import java.util.List;

import org.junit.jupiter.api.Test;

import com.hfstudio.guidenh.guide.document.block.LytImage;
import com.hfstudio.guidenh.guide.document.flow.LytFlowContent;
import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock;
import com.hfstudio.guidenh.guide.document.flow.LytFlowText;
import com.hfstudio.guidenh.guide.layout.FontMetrics;
import com.hfstudio.guidenh.guide.layout.LayoutContext;
import com.hfstudio.guidenh.guide.style.ResolvedTextStyle;
import com.hfstudio.guidenh.guide.style.TextAlignment;

class LineBuilderInlineImageWrapTest {

private static final String ICON = "[i]";

private static final FontMetrics MONOSPACE = new FontMetrics() {

@Override
public float getAdvance(int codePoint, ResolvedTextStyle style) {
return 6;
}

@Override
public int getLineHeight(ResolvedTextStyle style) {
return 9;
}
};

@Test
void iconWrapsWithTheLabelAfterIt() {
assertEquals(List.of("process ", ICON + "Cleanroom"), layout(60, "process ", ICON, "Cleanroom"));
}

@Test
void iconWrapsWithTheLabelBeforeIt() {
assertEquals(List.of("ab ", "Cleanroom" + ICON), layout(72, "ab ", "Cleanroom", ICON));
assertEquals(List.of("ab ", "Cleanroom" + ICON), layout(72, "ab Cleanroom", ICON));
}

@Test
void punctuationAfterAnIconPullsTheLabelAlong() {
assertEquals(List.of("a ", "Clean" + ICON + ","), layout(48, "a ", "Clean", ICON, ","));
}

@Test
void labelStillBreaksBetweenItsWords() {
assertEquals(List.of("a " + ICON + "Circuit", "Assembler"), layout(60, "a ", ICON, "Circuit Assembler"));
}

@Test
void whitespaceBeforeTheIconStillAllowsAWrap() {
assertEquals(List.of("process ", ICON + "x"), layout(48, "process ", ICON, "x"));
}

@Test
void tooLongLabelIsSplitAfterTheIconOnItsOwnLine() {
assertEquals(List.of("ab ", ICON + "Cleanroo", "mCl"), layout(54, "ab ", ICON, "CleanroomCl"));
}

private static List<String> layout(int width, String... parts) {
var lines = new ArrayList<Line>();
var builder = new LineBuilder(
new LayoutContext(MONOSPACE),
0,
0,
width,
lines,
new ArrayList<>(),
TextAlignment.LEFT);
for (String part : parts) {
builder.accept(ICON.equals(part) ? icon() : LytFlowText.of(part));
}
builder.end();

var result = new ArrayList<String>();
for (var line : lines) {
var text = new StringBuilder();
int x = line.firstElement != null ? line.firstElement.bounds.x() : 0;
for (var el = line.firstElement; el != null; el = el.next) {
assertEquals(x, el.bounds.x(), "elements on a line must be laid out back to back");
x = el.bounds.right();
if (el instanceof LineTextRun run) {
text.append(run.text);
} else {
text.append(ICON);
}
}
result.add(text.toString());
}
return result;
}

private static LytFlowContent icon() {
var image = new LytImage();
image.setDisplaySize(6, 6);
return LytFlowInlineBlock.of(image);
}
}