package com.mucommander.ui.viewer.text; import java.awt.Color; import java.awt.Dimension; import java.awt.Font; import java.awt.FontMetrics; import java.awt.Graphics; import java.awt.Insets; import java.awt.Point; import java.awt.Rectangle; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.util.HashMap; import javax.swing.JPanel; import javax.swing.SwingUtilities; import javax.swing.border.Border; import javax.swing.border.CompoundBorder; import javax.swing.border.EmptyBorder; import javax.swing.event.CaretEvent; import javax.swing.event.CaretListener; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; import javax.swing.text.AttributeSet; import javax.swing.text.BadLocationException; import javax.swing.text.Element; import javax.swing.text.JTextComponent; import javax.swing.text.StyleConstants; import javax.swing.text.Utilities; /** * Panel in which the line numbers at a given text component are presented. * it is used in JScrollPane as a row header. * * @author Arik Hadas */ public class TextLineNumbersPanel extends JPanel implements CaretListener, DocumentListener, PropertyChangeListener { private final static int HEIGHT = Integer.MAX_VALUE - 1000000; public static enum ALIGNMENT{ LEFT, CENTER, RIGHT } // Text component this TextTextLineNumber component is in sync with private JTextComponent component; // Properties that can be changed private Color currentLineForeground; private int minimumDisplayDigits; private double digitAlignment; // Keep history information to reduce the number of times the component needs to be repainted private int lastDigits; private int lastHeight; private int lastLine; private HashMap<String, FontMetrics> fonts; /** * Create a line number component for a text component. This minimum * display width will be based on 3 digits. * * @param component the related text component */ public TextLineNumbersPanel(JTextComponent component) { this(component, 3); } /** * Create a line number component for a text component. * * @param component the related text component * @param minimumDisplayDigits the number of digits used to calculate * the minimum width of the component */ public TextLineNumbersPanel(JTextComponent component, int minimumDisplayDigits) { this(component, minimumDisplayDigits, new EmptyBorder(0, 0, 0, 2), 4, ALIGNMENT.CENTER); } public TextLineNumbersPanel(JTextComponent component, int minimumDisplayDigits, Border border, int borderGap, ALIGNMENT alignment) { this.component = component; setBackground(Color.LIGHT_GRAY); setForeground(Color.black); setCurrentLineForeground(new Color(0,0,255)); setDigitAlignment(alignment); setBorder(border, borderGap); setMinimumDisplayDigits( minimumDisplayDigits); setFont(component.getFont()); component.getDocument().addDocumentListener(this); component.addPropertyChangeListener("font", this); component.addCaretListener(this); } /** * Set the alignment of the line numbers strings within the panel * * @param alignment the line numbers alignment */ private void setDigitAlignment(ALIGNMENT alignment) { switch(alignment) { case LEFT: digitAlignment = 0; case RIGHT: digitAlignment = 1; case CENTER: digitAlignment = 0.5; } } /** * If we'll want to highlight current line , we should * set the current line number color using this method * * @param currentLineForeground current line number color */ private void setCurrentLineForeground( Color currentLineForeground ) { this.currentLineForeground = currentLineForeground; } /** * The border gap is used in calculating the left and right insets of the * border. Default value is 5. * * @param borderGap the gap in pixels */ private void setBorder(Border border, int borderGap) { Border inner = new EmptyBorder(0, borderGap, 0, borderGap); setBorder( new CompoundBorder(border, inner) ); lastDigits = 0; setPreferredWidth(); } /** * Specify the minimum number of digits used to calculate the preferred * width of the component. Default is 3. * * @param minimumDisplayDigits the number digits used in the preferred * width calculation */ private void setMinimumDisplayDigits(int minimumDisplayDigits) { this.minimumDisplayDigits = minimumDisplayDigits; setPreferredWidth(); } /** * Calculate the width needed to display the maximum line number */ private void setPreferredWidth() { Element root = component.getDocument().getDefaultRootElement(); int lines = root.getElementCount(); int digits = Math.max(String.valueOf(lines).length(), minimumDisplayDigits); // Update sizes when number of digits in the line number changes if (lastDigits != digits) { lastDigits = digits; FontMetrics fontMetrics = getFontMetrics( getFont() ); int width = fontMetrics.charWidth( '0' ) * digits; Insets insets = getInsets(); int preferredWidth = insets.left + insets.right + width; Dimension d = getPreferredSize(); d.setSize(preferredWidth, HEIGHT); setPreferredSize( d ); setSize( d ); } } /* * We need to know if the caret is currently positioned on the line we * are about to paint so the line number can be highlighted. * if the current line foreground is not set, just return false */ private boolean isCurrentLine(int rowStartOffset) { if (currentLineForeground == null) return false; int caretPosition = component.getCaretPosition(); Element root = component.getDocument().getDefaultRootElement(); return root.getElementIndex( rowStartOffset ) == root.getElementIndex(caretPosition); } /** * Draw the line numbers */ @Override public void paintComponent(Graphics g) { super.paintComponent(g); // Determine the width of the space available to draw the line number FontMetrics fontMetrics = component.getFontMetrics( component.getFont() ); Insets insets = getInsets(); int availableWidth = getSize().width - insets.left - insets.right; // Determine the rows to draw within the clipped bounds. Rectangle clip = g.getClipBounds(); int rowStartOffset = component.viewToModel( new Point(0, clip.y) ); int endOffset = component.viewToModel( new Point(0, clip.y + clip.height) ); while (rowStartOffset <= endOffset) { try { g.setColor(isCurrentLine(rowStartOffset) ? currentLineForeground : getForeground()); // Get the line number as a string and then determine the // "X" and "Y" offsets for drawing the string. String lineNumber = getTextLineNumber(rowStartOffset); int stringWidth = fontMetrics.stringWidth( lineNumber ); int x = getOffsetX(availableWidth, stringWidth) + insets.left; int y = getOffsetY(rowStartOffset, fontMetrics); g.drawString(lineNumber, x, y); // Move to the next row rowStartOffset = Utilities.getRowEnd(component, rowStartOffset) + 1; } catch(Exception e) {} } } /* * Get the line number to be drawn. The empty string will be returned * when a line of text has wrapped. */ protected String getTextLineNumber(int rowStartOffset) { Element root = component.getDocument().getDefaultRootElement(); int index = root.getElementIndex( rowStartOffset ); Element line = root.getElement( index ); return line.getStartOffset() == rowStartOffset ? String.valueOf(index + 1) : ""; } /* * Determine the X offset to properly align the line number when drawn */ private int getOffsetX(int availableWidth, int stringWidth) { return (int)((availableWidth - stringWidth) * digitAlignment); } /* * Determine the Y offset for the current row */ private int getOffsetY(int rowStartOffset, FontMetrics fontMetrics) throws BadLocationException { // Get the bounding rectangle of the row Rectangle r = component.modelToView( rowStartOffset ); int lineHeight = fontMetrics.getHeight(); int y = r.y + r.height; int descent = 0; // The text needs to be positioned above the bottom of the bounding // rectangle based on the descent of the font(s) contained on the row. if (r.height == lineHeight) { // default font is being used descent = fontMetrics.getDescent(); } else { // We need to check all the attributes for font changes if (fonts == null) fonts = new HashMap<String, FontMetrics>(); Element root = component.getDocument().getDefaultRootElement(); int index = root.getElementIndex( rowStartOffset ); Element line = root.getElement( index ); for (int i = 0; i < line.getElementCount(); i++) { Element child = line.getElement(i); AttributeSet as = child.getAttributes(); String fontFamily = (String)as.getAttribute(StyleConstants.FontFamily); Integer fontSize = (Integer)as.getAttribute(StyleConstants.FontSize); String key = fontFamily + fontSize; FontMetrics fm = fonts.get( key ); if (fm == null) { Font font = new Font(fontFamily, Font.PLAIN, fontSize); fm = component.getFontMetrics( font ); fonts.put(key, fm); } descent = Math.max(descent, fm.getDescent()); } } return y - descent; } ///////////////////////////////////// // DocumentListener implementation // ///////////////////////////////////// public void changedUpdate(DocumentEvent e) { documentChanged(); } public void insertUpdate(DocumentEvent e) { documentChanged(); } public void removeUpdate(DocumentEvent e) { documentChanged(); } /* * A document change may affect the number of displayed lines of text. * Therefore the lines numbers will also change. */ private void documentChanged() { // Preferred size of the component has not been updated at the time // the DocumentEvent is fired SwingUtilities.invokeLater(new Runnable() { public void run() { int preferredHeight = component.getPreferredSize().height; // Document change has caused a change in the number of lines. // Repaint to reflect the new line numbers if (lastHeight != preferredHeight) { setPreferredWidth(); repaint(); lastHeight = preferredHeight; } } }); } ////////////////////////////////// // CaretListener implementation // ////////////////////////////////// public void caretUpdate(CaretEvent e) { if (currentLineForeground == null) return; // Get the line the caret is positioned on int caretPosition = component.getCaretPosition(); Element root = component.getDocument().getDefaultRootElement(); int currentLine = root.getElementIndex( caretPosition ); // Need to repaint so the correct line number can be highlighted if (lastLine != currentLine) { repaint(); lastLine = currentLine; } } /////////////////////////////////////////// // PropertyChangeListener implementation // /////////////////////////////////////////// public void propertyChange(PropertyChangeEvent evt) { if (evt.getNewValue() instanceof Font) { setFont((Font) evt.getNewValue()); lastDigits = 0; setPreferredWidth(); } } }