Make JScrollPane control multiple components

For my application I am designing a script editor. At the moment I have a JPanel which contains another JPanel that holds the line number (positioned to the left), and a JTextArea which is used to allow users to type their code in (positioned to the right).

At the moment, I have implemented a JScrollPane on the JTextArea to allow the user to scroll through their code.

For the JPanel containing the line numbers, they increment every time the user presses the enter key.

However, the problem is that I want the same JScrollPane (the one implemented on the JTextArea) to control the scrolling of the line number JPanel as well; i.e. when the user scrolls on the JTextArea, the line number JPanel should also scroll. But since the line numbers are held in a JPanel, I cant add that component to a JTextArea.

The constructor for the JPanel class containing the JTextArea and the line number JPanel:

private ScriptEditor() {

    setBackground(Color.WHITE);

    lineNumPanel = new LineNumberPanel();

    scriptArea = new JTextArea();
    scriptArea.setLineWrap(true);
    scriptArea.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 15));
    scriptArea.setMargin(new Insets(3, 10, 0, 10));

    JScrollPane scrollPane = new JScrollPane(scriptArea);
    scrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
    scrollPane.setPreferredSize(new Dimension(width, height));

    scriptArea.addKeyListener(this);

    add(lineNumPanel);
    add(scrollPane);
}

The constructor for the line number JPanel which adds JLabels within itself to represent the line numbers:

public LineNumberPanel() {

    setPreferredSize(new Dimension(width, height));

    box = Box.createVerticalBox();
    add(box);

    //setup the label
    label = new JLabel(String.valueOf(lineCount));
    label.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 15));

    //setup the label alignment
    label.setVerticalAlignment(JLabel.TOP);
    label.setHorizontalAlignment(JLabel.CENTER);
    label.setVerticalTextPosition(JLabel.TOP);
    setAlignmentY(TOP_ALIGNMENT);

    box.add(label);
}

You should use JScrollPane#setRowHeaderView to set the component that will appear at the left hand side of the scroll pane.

The benefit of this is the row header won't scroll to the left as the view scrolls to the right...

The example deliberately uses line wrapping...

Line Numbering

import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Insets;
import java.awt.Point;
import java.awt.Rectangle;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.Element;
import javax.swing.text.Utilities;

public class ScrollColumnHeader {

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

    public ScrollColumnHeader() {
        EventQueue.invokeLater(new Runnable() {
            @Override
            public void run() {
                try {
                    UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
                } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
                }

                JTextArea ta = new JTextArea(20, 40);
                ta.setWrapStyleWord(true);
                ta.setLineWrap(true);
                JScrollPane sp = new JScrollPane(ta);
                sp.setRowHeaderView(new LineNumberPane(ta));

                JFrame frame = new JFrame("Testing");
                frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                frame.setLayout(new BorderLayout());
                frame.add(sp);
                frame.pack();
                frame.setLocationRelativeTo(null);
                frame.setVisible(true);
            }
        });
    }

    public class LineNumberPane extends JPanel {

        private JTextArea ta;

        public LineNumberPane(JTextArea ta) {
            this.ta = ta;
            ta.getDocument().addDocumentListener(new DocumentListener() {

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

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

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

        @Override
        public Dimension getPreferredSize() {
            FontMetrics fm = getFontMetrics(getFont());
            int lineCount = ta.getLineCount();
            Insets insets = getInsets();
            int min = fm.stringWidth("000");
            int width = Math.max(min, fm.stringWidth(Integer.toString(lineCount))) + insets.left + insets.right;
            int height = fm.getHeight() * lineCount;
            return new Dimension(width, height);
        }

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

            FontMetrics fm = ta.getFontMetrics(ta.getFont());
            Insets insets = getInsets();

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

            Element root = ta.getDocument().getDefaultRootElement();
            while (rowStartOffset <= endOffset) {
                try {
                    int index = root.getElementIndex(rowStartOffset);
                    Element line = root.getElement(index);

                    String lineNumber = "";
                    if (line.getStartOffset() == rowStartOffset) {
                        lineNumber = String.valueOf(index + 1);
                    }

                    int stringWidth = fm.stringWidth(lineNumber);
                    int x = insets.left;
                    Rectangle r = ta.modelToView(rowStartOffset);
                    int y = r.y + r.height;
                    g.drawString(lineNumber, x, y - fm.getDescent());

                    //  Move to the next row
                    rowStartOffset = Utilities.getRowEnd(ta, rowStartOffset) + 1;
                } catch (Exception e) {
                    break;
                }
            }
        }

    }

}

And as I just discovered, @camickr has a much more useful example, Text Component Line Number


Create an Outer Panel which holds the Line Number panel and Text Area.

Then put this new panel into the Scroll Pane so you end up with this arrangement:

enter image description here

Which in code is something like:

private ScriptEditor() {

    setBackground(Color.WHITE);

    JPanel outerPanel = new JPanel();

    lineNumPanel = new LineNumberPanel();

    scriptArea = new JTextArea();
    scriptArea.setLineWrap(true);
    scriptArea.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 15));
    scriptArea.setMargin(new Insets(3, 10, 0, 10));

    outerPanel.add(lineNumPanel, BorderLayout.WEST)
    outerPanel.add(scriptArea, BorderLayout.CENTER)

    JScrollPane scrollPane = new JScrollPane(outerPanel);
    scrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
    scrollPane.setPreferredSize(new Dimension(width, height));

    scriptArea.addKeyListener(this);

    add(lineNumPanel);
    add(scrollPane);
}