JavaFx - TextFlow alternative using Canvas


For a long time, it seems to me that creating desktop applications is much more exciting than creating web applications. I see two reasons for this. First one, I am a fan of privacy and I do not like using cloud solutions. Secondly, my professional life is around web applications, and after hours of work I like to do something else. Last week, I spontaneously decided to create my own alternative to the JavaFx TextFlow.

The first working versions took me about 6–10 hours, and then I added a few more, changing the concept a bit after the problems I encountered. I know that the further I go, the more problems I will find, so the more time I spend on it. But this way, the solution will become so complicated that it will be difficult for me to share it. Maybe this what you see will encourage you to experiment on your own, or maybe even to create your own editor.

However, the goal of this project was not to create my own editor. I have done this many times before, using different technologies with varying results – each approach taught me something new. The solution described here is also based on those experiences, but you will probably find many things here that could be done better.

This is a video of the final result:

Defining expectations

When starting the project, I set myself the goal of creating a “presentation layer”, a layout for a model that could be used to present a rich text model - that is, text with different font sizes, interwoven with other JavaFX components, or even tables. This last part is not available in regular TextFlow, although you can always include another TextFlow for each cell, positioned appropriately, inside TextFlow. I haven’t gone that far (yet), but as a minimum for such functionality, I added the ability to embed something like blocks and inline blocks in the text, which should allow building tables in the text in the future. I wanted the block elements to behave similarly to those in web browsers.

Architecture

That’s quite a big word for such a small piece of code, but you still need to plan the organization of the code and the responsibilities of individual elements. Here, the solution can be divided into three main parts:

  • Segments – individual elements of the model that can be displayed on the screen.
  • Layout – a mechanism that allows to calculate where a given element should be located on the screen before it is drawn there.
  • Render – a mechanism for drawing the Layout on the screen.

The most complicated layer here is Layout, which has to calculate what should be drawn and where. This is where you need to specify how the text will wrap between lines, where will be the boundaries of block elements and how much space on the screen will be occupied by JavaFX components (which I also wanted to be able to add to such a layout).

All these layers will be collected in one component - TextCanvas, which will allow them to be displayed.

Segments

Segments are objects that allow you to connect TextCanvas with the outside world. To display an element on the screen, you need to create a Segment of the appropriate type for it and add it to the TextCanvas component, which will be able to display it. A certain complication for segments is the handling of block elements, where child segments may appear within a segment and creating a tree-like structure. The complication is that elements are not necessarily always added directly to the main TextCanvas, but sometimes also to another segment. This also affects the calculation of the position of components and then their drawing on the screen. Additionally, with JavaFx component support, such a component can also force a recalculation of the entire view if the size of that component changes (e.g., due to user interaction). But I’m starting to get ahead of facts. Here is a Segment:

public interface Segment {
    SegmentStyle style();
}

As you’ve noticed, the interface doesn’t reveal much. All we can see is that it will have some kind of style:

public class SegmentStyle {
    public final Font font;
    public final Paint fill;

    public SegmentStyle(Font font, Paint fill) {
        this.font = font;
        this.fill = fill;
    }
}

The style also uses a minimum of data - I added it as an example, but if you need to add styles for borders or text underlining, now you know where to start extending the code.

Below are definitions of supported segments:

public class TextSegment implements Segment {
    final String text;
    final SegmentStyle style;

    public TextSegment(String t, SegmentStyle style) {
        this.text = t;
        this.style = style;
    }

    public String text() {
        return text;
    }

    @Override
    public SegmentStyle style() {
        return style;
    }
}

Text segment - contains only text and styles.

public class NodeSegment implements Segment {
    public final Region node;
    private final SegmentStyle style;

    public NodeSegment(Region node, SegmentStyle style) {
        this.node = node;
        this.style = style;
    }

    @Override
    public SegmentStyle style() {
        return style;
    }
}	

JavaFX node segment - the Region is used in the implementation because it is an element that has a managed size that we want to listen to.

public abstract class BaseBlockSegment implements Segment {

    protected SegmentStyle style;
    final List<Segment> childSegments = new ArrayList<>();

    public BaseBlockSegment(SegmentStyle style) {
        this.style = style;
    }

    public void addSegment(Segment segment) {
        childSegments.add(segment);
    }

    public List<Segment> childSegments() {
        return Collections.unmodifiableList(childSegments);
    }

    @Override
    public SegmentStyle style() {
        return style;
    }
}

Above is the base implementation of the Block Segment, which specific block types will inherit from.

public class BlockSegment extends BaseBlockSegment {

    public BlockSegment(SegmentStyle style) {
        super(style);
    }

    public BlockSegment(SegmentStyle style, Segment... segments) {
        this(style);

        for (Segment segment : segments) {
            addSegment(segment);
        }
    }
}

A simple block that uses the entire width to display its content.

public class InlineBlockSegment extends BaseBlockSegment {

    public InlineBlockSegment(SegmentStyle style) {
        super(style);
    }

    public InlineBlockSegment(SegmentStyle style, Segment... segments) {
        this(style);

        for (Segment segment : segments) {
            addSegment(segment);
        }
    }
}

InlineBlock, which behaves like any other element in the middle of the text from the outside, but acts like a block element on the inside.

You may be asking yourself right now – why all these classes, since they are very similar? Why didn’t I use some kind of type attribute here? My answer will probably be rather weak, but initially I assumed that the segments would have different behaviors. After cleaning up the code and dividing it into layers, I was left with empty classes. However, I didn’t give up on them because I still feel optimistic that in the future, some behaviors may appear within them that will distinguish them from each other. However, again, with my current knowledge and starting the project from scratch again, it is very possible that I would start with a single class and dedicated attribute with a type instead of creating separate classes.

Segments layout

This is a key element of the entire mechanism, on which I spent the most time. The final rendered effect looks quite good, but I assume that there may still be minor errors in the mechanism. Before I start arranging the elements, I need a tool that will allow me to measure them. The tool should measure JavaFX elements and text. Let’s say, it will look like this:

public interface NodeMeasurer {

    double[] measure(Node n);

    TextMetrics measureText(String text, Font font);

    record TextMetrics(double width, double ascentHeight, double descentHeight) {
    }
}

Again, there is a certain inconsistency here… Measuring a node returns a double[] array, while measuring text returns a TextMetrics object. In my defense, I will say that initially both returned a double[] array, where I intuitively understand that the first element will be x and the second y (width and height). However, while working on the text, I quickly came to the conclusion that we need three dimensions there and returning it in an array may be confusing. The width attribute is ok, but height… we need to measure it above the writing line level (ascentHeight), and below it (descentHeight). Distinguishing between these two heights will be particularly useful when rendering InlineBlock segments (or text with different font size), where the text, despite being in a block, should be at the height of the text outside the block.

Below is an example implementation of this measure tool.

public class FxNodeMeasurer implements NodeMeasurer {
    private final Group root = new Group();
    private final Scene scene = new Scene(root);
    private final Text text = new Text();

    public FxNodeMeasurer() {
        scene.setRoot(root);
    }

    @Override
    public double[] measure(Node n) {
        boolean onScene = n.getScene() != null;

        if (!onScene) {
            root.getChildren().add(n);
        }

        n.applyCss();
        n.autosize();
        
        double pw = n.prefWidth(-1);
        if (pw <= 0) {
            pw = n.getLayoutBounds().getWidth();
        }
        double ph = n.prefHeight(-1);
        if (ph <= 0) {
            ph = n.getLayoutBounds().getHeight();
        }

        if (!onScene) {
            root.getChildren().remove(n);
        }
        return new double[]{pw, ph};
    }

    @Override
    public TextMetrics measureText(String text, Font font) {
        this.text.setText(text);
        this.text.setFont(font);
        Bounds layoutBounds = this.text.getLayoutBounds();
        double ascent = this.text.getBaselineOffset(); // part above baseline
        return new TextMetrics(
                layoutBounds.getWidth(),
                ascent,
                layoutBounds.getHeight() - ascent
        );
    }
}

Why did I separate the interface from the implementation in this case? I assumed that I would be able to change the rendering mechanism (e.g., use Skija instead of the usual Canvas), so I didn’t want there to be a lot of rework in places where such measurements would have to be made.

Here, you can see permanently stored attributes of the Scene and Text types. We don’t want to create them every time for each measurement. My final implementation, as presented here, additionally differs in the LRU cache implemented on the basis of LinkedHashMap used when measuring text. I omitted this part this time because even if you don’t know how to implement it yourself, you can easily find it, and it is unnecessary code in terms of presenting the mechanism of my TextCanvas.

I also need two more classes representing, the computed line (LayoutLine) and a single token in that line, keeping a reference to the correct segment from the model (SegmentReference).

public class SegmentReference {
    private final Segment segment;
    private final short offset;
    private short limit;
    private double x;
    private double width;
    private double ascentHeight;

    private List<LayoutLine> innerLines;

    public SegmentReference(Segment segment, short offset, short limit, double x, double width, double ascentHeight) {
        this.segment = segment;
        this.offset = offset;
        this.limit = limit;
        this.x = x;
        this.width = width;
        this.ascentHeight = ascentHeight;
    }

    public void updateX(double x) {
        this.x = x;
    }

    public void updateInnerLines(List<LayoutLine> innerLines) {
        this.innerLines = innerLines;
    }

    public void updateLimit(int limit, double width) {
        this.limit = (short) limit;
        this.width = width;
    }

    public Segment segment() {
        return segment;
    }

    public short limit() {
        return limit;
    }

    public short offset() {
        return offset;
    }

    public double x() {
        return x;
    }

    public double width() {
        return width;
    }

    public double ascentHeight() {
        return ascentHeight;
    }

    public List<LayoutLine> innerLines() {
        return innerLines;
    }

    public void updatedLineHeight(double ascentHeight) {
        double paddingTop = ascentHeight - this.ascentHeight;
        if (paddingTop != 0 && innerLines != null && !innerLines.isEmpty()) {
            innerLines.forEach(l -> l.commitHeight(l.ascent + paddingTop, l.descent));
        }
    }
}
public class LayoutLine {
    public double y;
    public double ascent;
    public double descent;
    public final List<SegmentReference> segments = new ArrayList<>();

    public LayoutLine(double y, double ascent, double descent) {
        this.y = y;
        this.ascent = ascent;
        this.descent = descent;
    }

    public void commitHeight(double ascentHeight, double descentHeight) {
        this.ascent = ascentHeight;
        this.descent = descentHeight;
        this.segments.forEach(s -> s.updatedLineHeight(ascentHeight));
    }
}

Yes…, I see completely different conventions used for data access in both files. To justify this - initially both (against common recommendations) looked similar to the approach with LayoutLine, but at some point for SegmentReference I needed more precise tracking of when which attribute is set and how (thus better data encapsulation). Now a few words about the data stored in them.
SegmentReference - stores the segment, limit and offset for text tokens. We don’t want to copy this text between objects - it’s a waste to keep it permanently in memory. I also remember the offset and limit as short for the same purpose (I optimistically assume that the length of a line of text will not exceed the range of short – in a pessimistic case, you can try to detect an out-of-range situation and split the given text fragment into two tokens). Additionally, we have the innerLines attribute to store the list of lines for block segments, and x and width, which tell us where to draw a given element on the line and the width of that element (optional, but it can be useful if someone wants to draw a frame around a segment and wants to know where it should end). In the case of LayoutLine, we have a list of segments, the y coordinate for drawing the line, and the ascent and descent heights for a given line (mentioned earlier).

Now, we have all the components need to calculate and remember where the elements should be placed on the screen. So let’s move on to the computing mechanism itself in LayoutEngine. This is a class that I will not discuss in detail here, but I’m attaching the complete sources at the bottom, so you can read it for yourself. However, I will focus on its “shape.” I won’t hide the fact that this is the part I spent the most time on to get the expected results, and the code is a little “special” in some places, but it does the job.

public class LayoutEngine {

    private static final Pattern SPLIT_PATTERN = Pattern.compile("(?<=\\s)");

    private final NodeMeasurer nodeMeasurer;
    private final Deque<LayoutLine> lines = new ArrayDeque<>();
    private LayoutLine currentLine;
    private int wordStartSegment;
    private double beforeWordStartAscent;
    private double beforeWordStartDescent;
    private boolean isEndOfWord = true;

    private double lastX;
    private double lastY;
    private double lineHeightAscent;
    private double lineHeightDescent;
    public final double maxWidth; // Waring: This is maxWidth, not maxX. (MaxX = startX + maxWidth)
    public double maxDiscoveredX;
    public final double startX;

    public LayoutEngine(double startX, double startY, double maxWidth, NodeMeasurer nodeMeasurer) {
        // ....
    }
    
    public void appendSegment(Segment segment) {
        // ...
    }
    
    private NodeMeasurer.TextMetrics measure(String text, SegmentStyle style) {
        return nodeMeasurer.measureText(text, style.font);
    }
    
    public List<LayoutLine> getLines() {
        if (currentLine != null && !currentLine.segments.isEmpty()) {
            commitLine(lineHeightAscent, lineHeightDescent);
        }
        return new ArrayList<>(lines);
    }
    
    private void commitLine(double commitAscent, double commitDescent) {
        lastX = startX;
        lastY += lineHeightAscent + lineHeightDescent;
        currentLine.commitHeight(commitAscent, commitDescent);
        if (!currentLine.segments.isEmpty()) {
            lines.add(currentLine);
        }
        lineHeightAscent = 0;
        lineHeightDescent = 0;
        currentLine = new LayoutLine(lastY, lineHeightAscent, lineHeightDescent);
        isEndOfWord = true;
        wordStartSegment = 0;
        beforeWordStartDescent = 0;
        beforeWordStartAscent = 0;
        maxDiscoveredX = Math.max(maxDiscoveredX, lastX);
    }
    
    private void jumpToX(double newXValue) {
        lastX = newXValue;
    }

    private void increaseX(double deltaX) {
        lastX += deltaX;
        maxDiscoveredX = Math.max(maxDiscoveredX, lastX);
    }

    private static int lastWhitespaceIndex(String s) {
        if (s == null || s.isEmpty()) return -1;
        for (int i = s.length() - 1; i >= 0; i--) {
            if (Character.isWhitespace(s.charAt(i))) return i;
        }
        return -1;
    }
}

Apart from those attributes at the beginning, which will help us transform segments into lines ready for rendering, it doesn’t even look bad.

Let me start from the top:

  • SPLIT_PATTERN - pattern that allows us to split text into words, but in such a way that the dividing elements (e.g., spaces) remain inside the word and are visible during rendering.
  • nodeMeasurer - an object that allows us to measure elements to be displayed on the screen.
  • lines - finished lines that can be passed on for rendering. Deque, because initially I thought I would need to look at the last elements of this collection, but eventually you can use any collection.
  • currentLine - the line we are currently calculating.
  • wordStartSegment - index to the object in the current line where the latest word begins.
  • beforeWordStartAscent - the height of the current line, up to the point where the newest word begins.
  • beforeWordStartDescent - as above, only for the descent height.
  • isEndOfWord - flag indicating whether the current word is complete. It may be completed by any white space, but also by the appearance of a JavaFX node or a block segment.
  • lastX - current position of drawing on the x axis.
  • lastY - current position of drawing on the y axis.
  • lineHeightAscent - height ascent of the current line.
  • lineHeightDescent - height descent of the current line
  • maxWidth - maximum allowed line width (after which we must move on to calculating the next one)
  • maxDiscoveredX - the biggest discovered x without jumping. In short, a block element can take up the entire width of the available line, but we also want to know what the actual maximum width of the text in such a block is.
  • startX - the place where we start drawing the line (e.g., when we want to have a margin around the Canvas, or we are inside a block that starts, for example, at coordinate x=230)

The greatest difficulty and complexity of this mechanism is in detecting when a word ends. The end of a segment is not necessarily the end of a word. If a word no longer fits in the current line, we need to divide it and move it to the next line (perhaps with other segments already processed in the current line).

To compute segments, use the appendSegment() method, which will do this depending on the segment type:

public void appendSegment(Segment segment) {
    if (segment instanceof TextSegment textSegment) {
        handleTextSegment(textSegment);
    } else if (segment instanceof NodeSegment nodeSegment) {
        handleNodeSegment(nodeSegment);
    } else if (segment instanceof InlineBlockSegment blockSegment) {
        handleInlineBlockSegment(blockSegment);
    } else if (segment instanceof BlockSegment blockSegment) {
        handleBlockSegment(blockSegment);
    }
}

It begs to implement injecting some additional type of segments into the class (e.g., a strategy pattern). I haven’t done this (yet), mainly because of the long list of parameters needed to perform the calculations. For now, we have four segment types, and I have no plans for others - the current approach seemed good enough.


Calculating text layout

Let’s start with the most interesting and difficult type of segment – TextSegment. The method is unintelligibly long, and I would really like to shorten it… but I didn’t want to :). However, if you want to do it yourself, keep in mind that performance and memory consumption are more important than readability. This is probably the most costly operation of the entire mechanism. In addition, it will be performed very often, and the longer the “document” you want to present, the more times it will be called. To sum up, focus on performance rather than readability. Looking at my code, you may think that I didn’t focus on anything here, but that’s mainly because I kept coming back to it and correcting errors in my previous assumptions so that it would render correctly.

Let’s try to describe what is happening there:

  • We split the text from segment into words so that the word separator is inside it. Then we iterate over each word, while remembering the current textOffset inside the segment.
  • If a word ends with a line break character, we truncate it and remember that we will need to move to a new line at the end of processing the current word. For this reason, we keep separately the word we are currently processing from displayText as the text we actually want to render.
  • We measure the current word and then check whether it still fits in the current line.
    • If it does not fit, but we are at the end of the word (the current word is not a continuation of the previous token), then if there is an “open” token, we add it to the current line. Then we commit the current line along with the information about the calculated ascent and descent so far.
    • If it doesn’t fit, but we are in the middle of a word, things start to get complicated.
      • We are looking for the token where the word began (that is why we have the variable wordStartSegment).
      • We split the token at the point where the word began (we cut off its limit and width, and create a new token for the word beginning at that point).
      • If the beginning was not in the last added token, then all next tokens (including the new token created during splitting) are moved to the collection, which will be added to the next line.
      • We commit the current line along with the ascent and descent heights that were stored in the beforeWordStart* variables. After moving the segments to the new line, its height may decrease because the segments in it may have been written in a larger font, for example.
      • We count the positions of the elements transferred from the old line to the new one again.
      • Next, we check again whether the new token we want to add will fit in the current line (after all, we may have added a few tokens from the previous one, or the word may simply be longer than the line width). If the token is longer, we need to split it based on the letters in the word – we look for the shortest string that will fit in the current line and insert it into displayText for further processing within this iteration. We store what does not fit in the current line in the variable restOfWord for later. If no letter of the current token fits in this line, end this line (starting a new one) and then repeat the entire processing of this word (starting from the beginning of the line).
    • Here, we finally return to inserting the word within the current line (splits into new lines are already complete). Set the processing data for the current word about the line height, end-of-word information (if it ends with whitespace), and the height of the line that was before the end of the word.
    • If we have an “open” token, add information about the current word to it, and if we don’t have - create it. Note here – I purposely do not create a dedicated token for each word in order to save memory. In real life, in most cases there will be only one token with multiple words in a single line, which will save a lot of memory. It would be a shame to waste it by keeping separate objects for each word (or worse – each letter).
    • We move the information about where we are on the line and about segment offset.
    • Here we return to the previously stored restOfWord variable – if we have stored something, we replace the word in the word array read at the beginning of the operation and repeat the entire process for this word (except that we only process its remaining part – the line splitting mechanism will be triggered again if necessary, etc.).
    • If we discovered at the beginning that the word ends with a new line, we can finally add it and finish processing the current word.
  • In this way, we iterate through all words, and finally, if we have unclosed currentToken, we add it to the current line as well.

Sounds pretty complicated? Well, it was, but it works… So now we can move on to the other types of segments.


Calculating the layout of a JavaFX node

This is the simplest case of a segment. It is simply a token that contains information about how much space to leave in the layout to render the JavaFX node above that space. So we calculate the size of the node, then check if it fits in the current line. If not, we end the current line. Next, we create a SegmentReference, to which we pass the current position and width of the node. We set the calculated height as the height of the line ascent (because we want the node to be rendered at the level of the text) and set the information that we are at the end of the word (for the purposes of handling next segments with text).

private void handleNodeSegment(NodeSegment segment) {
    double[] measured = nodeMeasurer.measure(segment.node);
    double nodeWidth = measured[0];
    double nodeHeight = measured[1];

    boolean shouldWrapLine = lastX + nodeWidth > startX + maxWidth;
    if (shouldWrapLine) {
        commitLine(lineHeightAscent, lineHeightDescent);
    }

    SegmentReference currentToken = new SegmentReference(segment, (short) 0, (short) 1, lastX, nodeWidth, nodeHeight);
    currentLine.segments.add(currentToken);

    increaseX(nodeWidth);
    lineHeightAscent = Math.max(lineHeightAscent, nodeHeight);
    lineHeightDescent = Math.max(lineHeightDescent, 0);

    isEndOfWord = true;
    wordStartSegment = currentLine.segments.size();
}

Calculating the layout for InlineBlockSegment

To calculate the layout of block segments, we start a new layout process by creating a LayoutEngine that will handle the elements inside the block. The result of this operation will be added to the current LayoutEngine as a single SegmentReference. The difficulty with InlineBlock is that we first need to calculate how much space the elements inside the block will occupy. If there are other block elements inside InlineBlock, they will use all the available space, so here we will also use the maxDiscoveredX attribute for calculations to know the actual width of the text inside.

With the information about the sizes of the elements inside the block, we can check whether it will fit in the current line, and we also need to recalculate their position and size, indicating that they cannot take up more space than they actually need. Finally, we transfer the results from innerEngine to the current layout and create a segment that will contain information about the lines created inside the block.

private void handleInlineBlockSegment(InlineBlockSegment segment) {
    // PHASE 1: First calculation - to discover the max width of the block
    LayoutEngine innerEngine = new LayoutEngine(0, lastY, maxWidth, nodeMeasurer);
    for (Segment seg : segment.childSegments()) {
        innerEngine.appendSegment(seg);
    }

    // PHASE 2: Calculate final block width
    double availableWidthInCurrentLine = maxWidth - lastX;
    double discoveredWidth = innerEngine.maxDiscoveredX;

    if (discoveredWidth > availableWidthInCurrentLine) {
        commitLine(lineHeightAscent, lineHeightDescent);
    }

    double correctBlockWidth = Math.min(discoveredWidth, maxWidth - lastX);

    // PHASE 3: Second calculation - to fit to the given width
    innerEngine = new LayoutEngine(lastX, lastY, correctBlockWidth, nodeMeasurer);
    for (Segment seg : segment.childSegments()) {
        innerEngine.appendSegment(seg);
    }

    // PHASE 4: Update layout with inner layout
    double height = (innerEngine.lastY - lastY) + innerEngine.lineHeightAscent; // ascentHeight - to make text match baseLine (like in a web browser)
    double width = innerEngine.maxDiscoveredX - lastX;

    SegmentReference currentToken = new SegmentReference(segment, (short) 0, (short) 1, lastX, width, height);
    currentLine.segments.add(currentToken);

    jumpToX(innerEngine.maxDiscoveredX);
    lineHeightAscent = Math.max(height, lineHeightAscent);
    lineHeightDescent = Math.max(innerEngine.lineHeightDescent, lineHeightDescent);
    currentToken.updateInnerLines(new ArrayList<>(innerEngine.getLines()));

    isEndOfWord = true;
    wordStartSegment = currentLine.segments.size();
}

Calculating the layout for BlockSegment

The mechanism works similarly to InlineBlock, but we do not need to measure the elements in the block multiple times. Basically, we only perform phases 3 and 4 from the InlineBlock method:

private void handleBlockSegment(BlockSegment blockSegment) {
    commitLine(lineHeightAscent, lineHeightDescent);

    LayoutEngine innerEngine = new LayoutEngine(lastX, lastY, maxWidth, nodeMeasurer);
    for (Segment seg : blockSegment.childSegments()) {
        innerEngine.appendSegment(seg);
    }
    double height = (innerEngine.lastY - lastY) + innerEngine.lineHeightAscent; // ascentHeight - to make text match baseLine (like in a web browser)
    double blockStartX = lastX;

    increaseX(innerEngine.maxDiscoveredX - lastX); // to register inner width
    jumpToX(innerEngine.lastX + maxWidth); // to jump to the end of the line
    lineHeightAscent = height;
    lineHeightDescent = innerEngine.lineHeightDescent;

    SegmentReference currentToken = new SegmentReference(blockSegment, (short) 0, (short) 1, blockStartX, maxWidth, height);
    currentToken.updateInnerLines(new ArrayList<>(innerEngine.getLines()));
    currentLine.segments.add(currentToken);

    isEndOfWord = true;
    wordStartSegment = currentLine.segments.size();
}

Other methods of the Layout layer

Returning the result - getLines() - returns a list of counted lines, to be used during rendering. Before returning the result, it checks whether there are any lines that have not been fully processed. If so, it finishes them.

Ending a line - commitLine() - takes ascent and descent line height to remember - so that when rendering, it is known where to start drawing a given line. It moves the current position of lastX to the beginning of the allowed drawing area, and lastY below the line height. If we wanted to have settings for the spacing between lines, this is where it should be calculated. If we have an open line with some segments, it is added to the resulting lines collection. We also reset the flag for the end of the word and the beginning segment of the current word (we are only interested in this information at the end of the line, when we need to break it). We store also the actual maximum line width.

Rendering phase

Once you have calculated the position of the elements in the view, you can finally move on to rendering them. For this purpose, I use JavaFX’s Canvas and its GraphicsContext object. In reality, it doesn’t really matter which rendering mechanism is used. You can even create an image and save everything to it. Ultimately, you will simply create a graphic object to display on the screen. The rendering class has only one public method, which accepts a list of lines and information about the visible area (visibleBounds). The visibleBounds attribute allows us to create a kind of virtual component where only the visible area is drawn, if necessary. If we want to render everything, not just the visible area, we can simply pass the actual size of the calculated layout (e.g., the position + height of the last line) as the visible area. Returning to rendering, the class that implements this for a given GraphicsContext looks like this:

public final class FxRenderer {
    private final GraphicsContext gc;
    private final FxNodes nodesRegistry;

    public FxRenderer(GraphicsContext gc, FxNodes nodesRegistry) {
        this.gc = gc;
        this.nodesRegistry = nodesRegistry;
    }

    public void render(List<LayoutLine> lines, Bounds visibleBounds) {
        for (LayoutLine line : lines) {
            if (line.y > visibleBounds.getMaxY()) {
                unregisterLine(line);
                continue; // Line is after visible bounds, so skip it
            } else if (line.y + line.ascent + line.descent < visibleBounds.getMinY()) {
                unregisterLine(line);
                continue; // Line is before visible bounds, so skip it
            }

            for (SegmentReference segment : line.segments) {
                drawSegment(line, segment, visibleBounds);
            }
        }
    }

    private void unregisterLine(LayoutLine line) {
        // ....
    }

    private void drawSegment(LayoutLine line, SegmentReference ref, Bounds visibleBounds) {
        // ...
    }
}

During rendering, we go through the lines one by one, and if a line is not visible on the screen, we “unregister” it. I will write about this later, but it is related to the display of additional JavaFx nodes. There is also some room for optimization here – for example, you can remember which area was previously rendered and what its layout was, and when deregistering, focus only on the lines that will actually disappear or when the area actually changed. But regardless of these optimizations, it seems to work quite well even when redrawing the entire area each time.


Support for JavaFX Nodes on Canvas

I mentioned above the need to “unregister” segments. As I mentioned, this is related to the p resence of JavaFX nodes, which we cannot draw (they are not images). Actually, we can – we can take a snapshot of them and draw them, but we want them to be interactive with the user. The solution to this is to reserve empty space for the node when calculating the layout, and to present the node itself by “ hanging” it over the visible area. During rendering, we set its position above the Canvas according to the previously calculated layout. To do this, during rendering, we must ensure that the Node is added to the list of children of the component responsible for display. When it is no longer visible, it can be removed from this list. This is what the FxNodes class is for. It is also responsible for listening for any changes in the size of Nodes during their interaction with the user (after changing the size, we have to recalculate the layout of Segments). Below is what this class looks like:

public class FxNodes {

    private final ObservableList<Node> canvasNodes;
    private final InvalidationListener sizeChangeListener;

    public FxNodes(ObservableList<Node> canvasNodes, Runnable changeListener) {
        this.canvasNodes = canvasNodes;
        this.sizeChangeListener = _ -> changeListener.run();
    }

    public void registerOnScreen(Region node) {
        if (!canvasNodes.contains(node)) {
            canvasNodes.add(node);
            node.widthProperty().addListener(sizeChangeListener);
            node.heightProperty().addListener(sizeChangeListener);
        }
    }

    public void unregisterFromScreen(Region node) {
        canvasNodes.remove(node);
        node.widthProperty().removeListener(sizeChangeListener);
        node.heightProperty().removeListener(sizeChangeListener);
    }
}

The class is unaware that it is part of the TextCanvas mechanism; it simply supervises a list of children, performing additional actions when adding new elements to it.

So, returning to the rendering class, the implementation of the unregisterLine method looks like this:

private void unregisterLine(LayoutLine line) {
    for (int i=0; i<line.segments.size(); i++) {
        SegmentReference segment = line.segments.get(i);
        if (segment.segment() instanceof NodeSegment nodeSegment) {
            nodesRegistry.unregisterFromScreen(nodeSegment.node);
        } else if (segment.segment() instanceof BaseBlockSegment blockSegment) {
            for (LayoutLine innerLine : segment.innerLines()) {
                unregisterLine(innerLine);
            }
        }
    }
}

We have here dedicated handling for NodeSegment, and additionally BaseBlockSegment. In the case of the second ones, we also need to visit all of its children so that the internal Nodes are also unregistered.


Drawing segments

So, how does drawing segments finally look like? Once everything is calculated, it is very simple. Below is a method that draws all types of segments, even with an additional border (as an example) for block elements. This is probably one of the features that can be additionally controlled with the SegmentStyle class.

The drawSegment method from the FxRenderer.java class:

private void drawSegment(LayoutLine line, SegmentReference ref, Bounds visibleBounds) {
    if (ref.segment() instanceof TextSegment textSegment) {
        String segmentText = textSegment.text();
        String textToDraw = segmentText.substring(ref.offset(), ref.offset() + ref.limit());

        gc.setFont(textSegment.style().font);
        gc.setFill(textSegment.style().fill);
        gc.fillText(textToDraw, ref.x() - visibleBounds.getMinX(), (line.y + line.ascent) - visibleBounds.getMinY());

    } else if (ref.segment() instanceof BaseBlockSegment blockSegment) {
        render(ref.innerLines(), visibleBounds);

        // Draw block border
        double locationX = ref.x() - visibleBounds.getMinX();
        double locationY = line.y + (line.ascent - ref.ascentHeight()) - visibleBounds.getMinY();
        double width = ref.width();
        double height = ref.ascentHeight() + line.descent;
        gc.strokeRect(locationX, locationY, width, height);

    } else if (ref.segment() instanceof NodeSegment segment) {

        nodesRegistry.registerOnScreen(segment.node);
        Region node = segment.node;
        double locationX = ref.x() - visibleBounds.getMinX();
        double locationY = line.y - visibleBounds.getMinY();

        node.relocate(locationX, locationY);
    }
}

The component that manages all of this

The complete picture there is still missing a component that will bring it all together. So here it is:

public class TextCanvas extends Region {
    private static final double PADDING_LEFT = 20;
    private static final double PADDING_RIGHT = 20;
    private static final double PADDING_TOP = 20;

    private final List<Segment> segments = new ArrayList<>();
    private final FxNodes nodesRegistry;
    private final NodeMeasurer measurer = new FxNodeMeasurer();
    private final ObjectProperty<Bounds> visibleBounds = new SimpleObjectProperty<>();

    private final Canvas canvas;
    private final List<LayoutLine> renderLines = new ArrayList<>();

    public TextCanvas() {

        canvas = new Canvas(800, 400);
        nodesRegistry = new FxNodes(getChildren(), this::repaint);
        setFocusTraversable(true);
        getChildren().add(canvas);

        // simple resize handling
        canvas.widthProperty().bind(widthProperty());
        canvas.heightProperty().bind(heightProperty());
        canvas.widthProperty().addListener(o -> moveBounds(0, 0));
        canvas.heightProperty().addListener(o -> moveBounds(0, 0));
        visibleBounds.addListener(observable -> repaint());

        visibleBounds.setValue(new BoundingBox(0, 0, getWidth(), getHeight()));

        addEventFilter(KeyEvent.KEY_PRESSED, event -> {
            switch (event.getCode()) {
                case LEFT -> moveBounds(-10, 0);
                case RIGHT -> moveBounds(10, 0);
                case UP -> moveBounds(0, -10);
                case DOWN -> moveBounds(0, 10);
            }
        });
        addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
            requestFocus();
        });
        addEventHandler(ScrollEvent.SCROLL, event -> {
            double deltaX = event.getDeltaX();
            double deltaY = event.getDeltaY() * -1;
            moveBounds(deltaX, deltaY);
            event.consume();
        });
    }

    private void moveBounds(double deltaX, double deltaY) {
        Bounds bounds = visibleBounds.get();
        visibleBounds.set(
                new BoundingBox(bounds.getMinX() + deltaX, bounds.getMinY() + deltaY, getWidth(), getHeight())
        );
    }

    public void addSegment(Segment segment) {
        segments.add(segment);
    }

    public void repaint() {
        calculateSegmentsTokens();
        redrawFrame();
    }

    private void redrawFrame() {
        // Clear area
        GraphicsContext gc = canvas.getGraphicsContext2D();
        gc.setFill(Color.WHITE);
        gc.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());

        Bounds visibleBounds = this.visibleBounds.get();
        FxRenderer fxRenderer = new FxRenderer(gc, nodesRegistry);
        fxRenderer.render(renderLines, visibleBounds);
    }

    private void calculateSegmentsTokens() {
        LayoutEngine layoutEngine = new LayoutEngine(PADDING_LEFT, PADDING_TOP, canvas.getWidth() - (PADDING_LEFT + PADDING_RIGHT), measurer);
        for (Segment seg : segments) {
            layoutEngine.appendSegment(seg);
        }
        renderLines.clear();
        renderLines.addAll(layoutEngine.getLines());
    }

    @Override
    protected void layoutChildren() {
        super.layoutChildren();
        Insets insets = getInsets();
        double width = getWidth();
        double height = getHeight();
        canvas.resizeRelocate(
                insets.getLeft(),
                insets.getTop(),
                width - insets.getLeft() - insets.getRight(),
                height - insets.getTop() - insets.getBottom()
        );
    }

    @Override
    protected double computePrefWidth(double height) {
        return canvas.prefWidth(height);
    }

    @Override
    protected double computePrefHeight(double width) {
        return canvas.prefHeight(width);
    }

    public ObjectProperty<Bounds> visibleBoundsProperty() {
        return visibleBounds;
    }
}

This is a completely custom component inheriting from the Region class, so we have to manage its layout ourselves. And that’s exactly what we do – we set the position of NodeSegment on the screen, for example. Underneath, Canvas acts as the underlying component across the entire width. In the layoutChildren method, we also handle the Insets of the current component, as if we needed to draw a border around it – we don’t want the border to be covered by Canvas. I know, I know – I started a bit from the end, so let’s go back to the constructor. We create Canvas and the mentioned FxNodes to manage the actual position of child nodes drawn on Canvas. We set setFocusTraversable so that the element can receive focus and respond to keystrokes when it has it. We add listeners so that the size of canvas adjusts to the size of the region in which it is presented. In the dedicated visibleBounds property, we register the visible area and a listener that will refresh the view when it changes. We also register simple navigation using the keyboard and mouse scroll – they affect visibleBounds, after which we recalculate the position of the segments and render them on the screen. For simplicity, each change in visibleBounds causes the components to be recalculated and redrawn. However, optimization can be applied here so that only a change in the size of the displayed area (or, of course, a change in the document itself) causes the layout to be recalculated.

How to run all of this

Once the component is ready, it can be tested. Below is an example call showing the use of different segments.

public class Main extends Application {

    @Override
    public void start(Stage stage) {
        TextCanvas textCanvas = new TextCanvas();

        Scene scene = new Scene(textCanvas);
        stage.setScene(scene);

        SegmentStyle defaultStyle = new SegmentStyle(Font.font("System", 20), Color.BLACK);
        SegmentStyle defaultBoldStyle = new SegmentStyle(Font.font("System", FontWeight.BOLD, 20), Color.BLACK);
        SegmentStyle defaultRedStyle = new SegmentStyle(Font.font("System", 20), Color.RED);
        SegmentStyle largeStyle = new SegmentStyle(Font.font("System", FontWeight.BOLD, 70), Color.BLACK);

        for (int i = 0; i < 2; i++) {
            textCanvas.addSegment(new TextSegment("Lorem ", defaultBoldStyle));

            Button b = new Button("ipsum" + i);
            b.setOnAction(e -> {
                if (b.getText().equals("ipsum")) {
                    b.setText("ipsum dolor sit amet");
                } else {
                    b.setText("ipsum");
                }
            });
            textCanvas.addSegment(new NodeSegment(b, defaultStyle));

            textCanvas.addSegment(new TextSegment(", consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad ", defaultStyle));
            textCanvas.addSegment(new TextSegment("miniym.", largeStyle));
            textCanvas.addSegment(new TextSegment(" veniam,\nquis nostrud exercitation ", defaultStyle));
            textCanvas.addSegment(new TextSegment("ullamco laboris nisi ", defaultRedStyle));
            textCanvas.addSegment(new TextSegment("ut aliquip ex ea commodo consequat. ", defaultStyle));

            InlineBlockSegment segment1 = new InlineBlockSegment(defaultStyle);
            segment1.addSegment(new TextSegment("Duis aute irure dolor", defaultBoldStyle));
            segment1.addSegment(new BlockSegment(defaultStyle, new TextSegment("Block 1", defaultStyle)));
            segment1.addSegment(new BlockSegment(defaultStyle, new TextSegment("Block 2", defaultStyle)));
            segment1.addSegment(new BlockSegment(defaultStyle, new TextSegment("Block 3", defaultStyle)));
            textCanvas.addSegment(segment1);

            textCanvas.addSegment(new TextSegment(" In reprehenderit in voluptate velit esse cillum dolore", defaultStyle));

            textCanvas.addSegment(new BlockSegment(defaultStyle, new TextSegment("Eu fugiat nulla pariatur", defaultStyle)));

        }

        stage.setWidth(820);
        stage.setHeight(420);
        stage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Here you can download the complete source code: