There are many ways to add line numbers to the JEditorPane and JTextPane Swing components. This post looks at two solutions: One providing a custom ParagraphView which paints a child element that contains the line number for that paragraph. The other implements a separate component which is passed as the RowHeaderView of the JScrollPane. Both support scrolling and line wrapping, and in this example the latter includes current line highlighting, as seen in the image below.

Line numbers with RowHeaderView

ParagraphView

The ParagraphView solution is inspired by Stanislav Lapitsky’s old post on the topic, although with some improvements. The basic idea is to paint the line number in the child of the ParagraphView, using the paintChild() method. The advantage is that the relationship between the line and the line number is already established, so alignment is easy. We just have to count which element we’re dealing with to know which number to print. The two methods below show this part. Notice also that only the index 0 of the ParagraphView should be used for the line number, so we don’t count a wrapped line twice. Furthermore, a mono-spaced font helps with padding and alignment.

In order to create this custom ParagraphView, it has to be returned through a ViewFactory, which again has to be provided through an EditorKit. The file below shows the full implementation. Notice how the default ViewFactory is used for all other elements; only the Paragraph Element gets a custom view.

    public void paintChild(Graphics g, Rectangle alloc, int index) {
      super.paintChild(g, alloc, index);

      // Allow of wrapped paragraph lines, but don't print redundant line
      // numbers.
      if (index > 0) {
        return;
      }

      // Pad left so the numbers align
      int lineNumber = getLineNumber() + 1;
      String lnStr = String.format("%3d", lineNumber);

      // Make sure we use a monospaced font.
      font = font != null ? font : new Font(Font.MONOSPACED, Font.PLAIN, getFont().getSize());
      g.setFont(font);

      int x = alloc.x - getLeftInset();
      int y = alloc.y + alloc.height - 3;
      g.drawString(lnStr, x, y);
    }

    private int getLineNumber() {
      // According to the Document.getRootElements() doc, there will "typically"
      // only be one root element.
      Element root = getDocument().getDefaultRootElement();
      int len = root.getElementCount();
      for (int i = 0; i < len; i++) {
        if (root.getElement(i) == thisElement) {
          return i;
        }
      }
      return 0;
    }

setRowHeaderView

The second solution renders the line numbers in a completely separate component, which can be a JComponent (or even an old style AWT Component). This component is passed in to the setRowHeaderView() method of JScrollPane. That way, the coupling becomes a bit cleaner than overriding multiple classes and methods as with the the ParagraphView solution. However, the down-side is that we must do the alignment with the Editor component manually. Luckily, the Swing Text Component API provides a solid API for this purpose.

The code below is inspired by Rob Camick’s article, but simplifies several aspects of his stand-alone API style class. At the centre is the translation between the Document model and its onscreen view. The viewToModel() method translates an x,y point on the screen to the nearest character offset, while the modelToView() method goes the other way. That way we can align the line number at the same y point as the line in the editor. In Camick’s code, he loops through the text by character offsets rather than paragraphs; the getRowEnd() method helps by finding the end of a line. Camick also points out the the line number should take the font decent of the editor font into account when positioning the line number. The FontMetrics class helps with measuring sizes of fonts.

Finally worth noting is the synchronisation between the editor and the margin component. Since they are separate, this must be handled through event listeners registered with the editor component. The DocumentListener and ComponentListener notifies of edit, movement and resizing events, while the CaretListener signals movement in the cursor position. All of these events force a repaint, but through a scheduled AWT thread, to make sure the editor view has updated first, as seen in the documentChanged() method at the bottom of the code section below.

    public void paintComponent(Graphics g) {
      super.paintComponent(g);

      Rectangle clip = g.getClipBounds();
      int startOffset = editor.viewToModel(new Point(0, clip.y));
      int endOffset = editor.viewToModel(new Point(0, clip.y + clip.height));

      while (startOffset <= endOffset) {
        try {
          String lineNumber = getLineNumber(startOffset);
          if (lineNumber != null) {
            int x = getInsets().left + 2;
            int y = getOffsetY(startOffset);

            font = font != null ? font : new Font(Font.MONOSPACED, Font.BOLD, editor.getFont().getSize());
            g.setFont(font);

            g.setColor(isCurrentLine(startOffset) ? Color.RED : Color.BLACK);

            g.drawString(lineNumber, x, y);
          }

          startOffset = Utilities.getRowEnd(editor, startOffset) + 1;
        } catch (BadLocationException e) {
          e.printStackTrace();
          // ignore and continue
        }
      }
    }

    private String getLineNumber(int offset) {
      Element root = editor.getDocument().getDefaultRootElement();
      int index = root.getElementIndex(offset);
      Element line = root.getElement(index);

      return line.getStartOffset() == offset ? String.format("%3d", index + 1) : null;
    }

    private int getOffsetY(int offset) throws BadLocationException {
      FontMetrics fontMetrics = editor.getFontMetrics(editor.getFont());
      int descent = fontMetrics.getDescent();

      Rectangle r = editor.modelToView(offset);
      int y = r.y + r.height - descent;

      return y;
    }

    private boolean isCurrentLine(int offset) {
      int caretPosition = editor.getCaretPosition();
      Element root = editor.getDocument().getDefaultRootElement();
      return root.getElementIndex(offset) == root.getElementIndex(caretPosition);
    }

    private void documentChanged() {
      SwingUtilities.invokeLater(() -> {
        repaint();
      });
    }

Here are the full code listings for both examples, as stand-alone applications.

ParagraphViewLineNumbers.java
GitHub Raw
/* Copyright rememberjava.com. Licensed under GPL 3. See http://rememberjava.com/license */
package com.rememberjava.ui;

import java.awt.Font;
import java.awt.Graphics;
import java.awt.Rectangle;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Random;
import java.util.stream.Collectors;

import javax.swing.JEditorPane;
import javax.swing.JFrame;
import javax.swing.JScrollPane;
import javax.swing.text.AbstractDocument;
import javax.swing.text.Element;
import javax.swing.text.ParagraphView;
import javax.swing.text.StyledEditorKit;
import javax.swing.text.View;
import javax.swing.text.ViewFactory;

/**
 * Example use of a left margin line number for a JEditorPane. Using a custom
 * EditorKit and ViewFactory to pass in ParagraphView which paints a child
 * element. Pads the line numbers up to 999, and handles wrapped lines in the
 * editor.
 */
public class ParagraphViewLineNumbers extends JFrame {

  private static final long serialVersionUID = 1L;

  public static void main(String[] args) throws IOException {
    new ParagraphViewLineNumbers();
  }

  public ParagraphViewLineNumbers() throws IOException {
    JEditorPane editor = new JEditorPane();
    editor.setEditorKit(new CustomEditorKit());
    editor.setText(getRandomText());

    JScrollPane scroll = new JScrollPane(editor);
    getContentPane().add(scroll);

    setSize(500, 500);
    setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    setVisible(true);
  }

  /**
   * Returns the first 100 words of the default dictionary (on Ubuntu / Debian)
   * and injects random line breaks.
   */
  private String getRandomText() throws IOException {
    Random rnd = new Random();
    return Files.readAllLines(Paths.get("/usr/share/dict/words")).stream().limit(100)
        .map(word -> (rnd.nextInt(15) == 0 ? "\n" : "") + word).collect(Collectors.joining(" "));
  }

  /**
   * Passes in the custom ViewFactory. Inherits from the StyledEditorKit since
   * that already comes with a default ViewFactory (while the DefaultEditorKit
   * does not).
   */
  class CustomEditorKit extends StyledEditorKit {

    private static final long serialVersionUID = 1L;

    @Override
    public ViewFactory getViewFactory() {
      return new CustomViewFactory(super.getViewFactory());
    }
  }

  /**
   * Produces custom ParagraphViews, but uses the default ViewFactory for all
   * other elements.
   */
  class CustomViewFactory implements ViewFactory {

    private ViewFactory defaultViewFactory;

    CustomViewFactory(ViewFactory defaultViewFactory) {
      this.defaultViewFactory = defaultViewFactory;
    }

    @Override
    public View create(Element elem) {
      if (elem != null && elem.getName().equals(AbstractDocument.ParagraphElementName)) {
        return new CustomParagraphView(elem);
      }
      return defaultViewFactory.create(elem);
    }
  }

  /**
   * Paints a left hand child view with the line number for this Paragraph.
   */
  class CustomParagraphView extends ParagraphView {

    public final short MARGIN_WIDTH_PX = 25;

    private Element thisElement;

    private Font font;

    public CustomParagraphView(Element elem) {
      super(elem);
      thisElement = elem;
      this.setInsets((short) 0, (short) 0, (short) 0, (short) 0);
    }

    @Override
    protected void setInsets(short top, short left, short bottom, short right) {
      super.setInsets(top, (short) (left + MARGIN_WIDTH_PX), bottom, right);
    }

    @Override
    public void paintChild(Graphics g, Rectangle alloc, int index) {
      super.paintChild(g, alloc, index);

      // Allow of wrapped paragraph lines, but don't print redundant line
      // numbers.
      if (index > 0) {
        return;
      }

      // Pad left so the numbers align
      int lineNumber = getLineNumber() + 1;
      String lnStr = String.format("%3d", lineNumber);

      // Make sure we use a monospaced font.
      font = font != null ? font : new Font(Font.MONOSPACED, Font.PLAIN, getFont().getSize());
      g.setFont(font);

      int x = alloc.x - getLeftInset();
      int y = alloc.y + alloc.height - 3;
      g.drawString(lnStr, x, y);
    }

    private int getLineNumber() {
      // According to the Document.getRootElements() doc, there will "typically"
      // only be one root element.
      Element root = getDocument().getDefaultRootElement();
      int len = root.getElementCount();
      for (int i = 0; i < len; i++) {
        if (root.getElement(i) == thisElement) {
          return i;
        }
      }
      return 0;
    }
  }
}
RowHeaderViewLineNumbers.java
GitHub Raw
/* Copyright rememberjava.com. Licensed under GPL 3. See http://rememberjava.com/license */
package com.rememberjava.ui;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Random;
import java.util.stream.Collectors;

import javax.swing.JComponent;
import javax.swing.JEditorPane;
import javax.swing.JFrame;
import javax.swing.JScrollPane;
import javax.swing.JTextPane;
import javax.swing.SwingUtilities;
import javax.swing.event.CaretEvent;
import javax.swing.event.CaretListener;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.BadLocationException;
import javax.swing.text.Element;
import javax.swing.text.JTextComponent;
import javax.swing.text.Utilities;

/**
 * Example use of a left margin line number for a JEditorPane in a JScrollPane.
 * Uses a separate component for the RowHeaderView of the JScrollPane. Pads the
 * line numbers up to 999, highlights the currently active line, handles wrapped
 * lines and resizing of the editor.
 */
public class RowHeaderViewLineNumbers extends JFrame {

  private static final long serialVersionUID = 1L;

  public final int MARGIN_WIDTH_PX = 28;

  public static void main(String[] args) throws IOException {
    new RowHeaderViewLineNumbers();
  }

  public RowHeaderViewLineNumbers() throws IOException {
    JEditorPane editor = new JTextPane();
    LineNumbersView lineNumbers = new LineNumbersView(editor);

    JScrollPane scroll = new JScrollPane(editor);
    scroll.setRowHeaderView(lineNumbers);
    getContentPane().add(scroll);

    setSize(500, 500);
    setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    setVisible(true);

    editor.setText(getRandomText());
  }

  /**
   * Returns the first 100 words of the default dictionary (on Ubuntu / Debian)
   * and injects random line breaks.
   */
  private String getRandomText() throws IOException {
    Random rnd = new Random();
    return Files.readAllLines(Paths.get("/usr/share/dict/words")).stream().limit(100)
        .map(word -> (rnd.nextInt(15) == 0 ? "\n" : "") + word).collect(Collectors.joining(" "));
  }

  /**
   * Left hand side RowHeaderView for a JEditorPane in a JScrollPane. Highlights
   * the currently selected line. Handles line wrapping, frame resizing.
   */
  class LineNumbersView extends JComponent implements DocumentListener, CaretListener, ComponentListener {

    private static final long serialVersionUID = 1L;

    private JTextComponent editor;

    private Font font;

    public LineNumbersView(JTextComponent editor) {
      this.editor = editor;

      editor.getDocument().addDocumentListener(this);
      editor.addComponentListener(this);
      editor.addCaretListener(this);
    }

    @Override
    public void paintComponent(Graphics g) {
      super.paintComponent(g);

      Rectangle clip = g.getClipBounds();
      int startOffset = editor.viewToModel(new Point(0, clip.y));
      int endOffset = editor.viewToModel(new Point(0, clip.y + clip.height));

      while (startOffset <= endOffset) {
        try {
          String lineNumber = getLineNumber(startOffset);
          if (lineNumber != null) {
            int x = getInsets().left + 2;
            int y = getOffsetY(startOffset);

            font = font != null ? font : new Font(Font.MONOSPACED, Font.BOLD, editor.getFont().getSize());
            g.setFont(font);

            g.setColor(isCurrentLine(startOffset) ? Color.RED : Color.BLACK);

            g.drawString(lineNumber, x, y);
          }

          startOffset = Utilities.getRowEnd(editor, startOffset) + 1;
        } catch (BadLocationException e) {
          e.printStackTrace();
          // ignore and continue
        }
      }
    }

    /**
     * Returns the line number of the element based on the given (start) offset
     * in the editor model. Returns null if no line number should or could be
     * provided (e.g. for wrapped lines).
     */
    private String getLineNumber(int offset) {
      Element root = editor.getDocument().getDefaultRootElement();
      int index = root.getElementIndex(offset);
      Element line = root.getElement(index);

      return line.getStartOffset() == offset ? String.format("%3d", index + 1) : null;
    }

    /**
     * Returns the y axis position for the line number belonging to the element
     * at the given (start) offset in the model.
     */
    private int getOffsetY(int offset) throws BadLocationException {
      FontMetrics fontMetrics = editor.getFontMetrics(editor.getFont());
      int descent = fontMetrics.getDescent();

      Rectangle r = editor.modelToView(offset);
      int y = r.y + r.height - descent;

      return y;
    }

    /**
     * Returns true if the given start offset in the model is the selected (by
     * cursor position) element.
     */
    private boolean isCurrentLine(int offset) {
      int caretPosition = editor.getCaretPosition();
      Element root = editor.getDocument().getDefaultRootElement();
      return root.getElementIndex(offset) == root.getElementIndex(caretPosition);
    }

    /**
     * Schedules a refresh of the line number margin on a separate thread.
     */
    private void documentChanged() {
      SwingUtilities.invokeLater(() -> {
        repaint();
      });
    }

    /**
     * Updates the size of the line number margin based on the editor height.
     */
    private void updateSize() {
      Dimension size = new Dimension(MARGIN_WIDTH_PX, editor.getHeight());
      setPreferredSize(size);
      setSize(size);
    }

    @Override
    public void insertUpdate(DocumentEvent e) {
      documentChanged();
    }

    @Override
    public void removeUpdate(DocumentEvent e) {
      documentChanged();
    }

    @Override
    public void changedUpdate(DocumentEvent e) {
      documentChanged();
    }

    @Override
    public void caretUpdate(CaretEvent e) {
      documentChanged();
    }

    @Override
    public void componentResized(ComponentEvent e) {
      updateSize();
      documentChanged();
    }

    @Override
    public void componentMoved(ComponentEvent e) {
    }

    @Override
    public void componentShown(ComponentEvent e) {
      updateSize();
      documentChanged();
    }

    @Override
    public void componentHidden(ComponentEvent e) {
    }
  }
}