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();
}
}
}