// Copyright (c) 2012 Chan Wai Shing
//
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package syntaxhighlight;
import java.awt.Color;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.RenderingHints;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JViewport;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.BadLocationException;
import javax.swing.text.Document;
import javax.swing.text.Element;
import javax.swing.text.JTextComponent;
/**
* A row header panel for {@link JScrollPane} showing the line numbers of
* {@link JTextComponent}.
*
* The text lines in {@link JTextComponent} must be fixed height.
*
* @author Chan Wai Shing <cws1989@gmail.com>
*/
public class JTextComponentRowHeader extends JPanel {
private static final Logger LOG = Logger.getLogger(JTextComponentRowHeader.class.getName());
private static final long serialVersionUID = 1L;
/**
* The anti-aliasing setting of the line number text. See
* {@link RenderingHints}.
*/
protected Object textAntiAliasing = RenderingHints.VALUE_TEXT_ANTIALIAS_DEFAULT;
/**
* The color of the border that joint the gutter and the script text area.
*/
protected Color borderColor = new Color(184, 184, 184);
/**
* The background of the row when it is highlighted.
*/
protected Color highlightedColor = Color.black;
/**
* The minimum padding from 'the leftmost of the line number text' to
* 'the left margin'.
*/
protected int paddingLeft = 7;
/**
* The minimum padding from 'the rightmost of the line number text' to
* 'the right margin' (not to the gutter border).
*/
protected int paddingRight = 2;
/**
* The width of the border that joint the gutter and the script text area.
*/
protected int borderWidth = 1;
/**
* The JScrollPane that it be added into.
*/
protected JScrollPane scrollPane;
/**
* The text component to listen the change events on.
*/
protected JTextComponent textComponent;
/**
* The document of the text component.
*/
protected Document document;
/**
* The document listener for {@link #document}.
*/
protected DocumentListener documentListener;
/**
* The cached panel width.
*/
protected int panelWidth;
/**
* The cached largest row number (for determine panel width
* {@link #panelWidth}).
*/
protected int largestRowNumber;
/**
* The cached text component height, for determine panel height.
*/
protected int textComponentHeight;
/**
* The line number offset. E.g. set offset to 9 will make the first line
* number to appear at line 1 + 9 = 10
*/
protected int lineNumberOffset;
/**
* The list of line numbers that indicate which lines are needed to be
* highlighted.
*/
protected final List<Integer> highlightedLineList;
/**
* Indicator indicate whether it is listening to the document change events
* or not.
*/
protected boolean listenToDocumentUpdate;
/**
* Constructor.
*
* @param scrollPane the JScrollPane that it be added into
* @param textComponent the text component to listen the change events on
*/
public JTextComponentRowHeader(JScrollPane scrollPane, JTextComponent textComponent) {
super();
if (scrollPane == null) {
throw new NullPointerException("argument 'scrollPane' cannot be null");
}
if (textComponent == null) {
throw new NullPointerException("argument 'textComponent' cannot be null");
}
setFont(new Font("Verdana", Font.PLAIN, 10));
setForeground(Color.black);
setBackground(new Color(233, 232, 226));
this.scrollPane = scrollPane;
this.textComponent = textComponent;
panelWidth = 0;
largestRowNumber = 1;
textComponentHeight = 0;
lineNumberOffset = 0;
highlightedLineList = Collections.synchronizedList(new ArrayList<Integer>());
listenToDocumentUpdate = true;
document = textComponent.getDocument();
documentListener = new DocumentListener() {
public void insertUpdate(DocumentEvent e) {
handleEvent(e);
}
public void removeUpdate(DocumentEvent e) {
handleEvent(e);
}
public void changedUpdate(DocumentEvent e) {
handleEvent(e);
}
public void handleEvent(DocumentEvent e) {
if (!listenToDocumentUpdate) {
return;
}
Document _document = e.getDocument();
if (document == _document) {
checkPanelSize();
} else {
_document.removeDocumentListener(this);
}
}
};
document.addDocumentListener(documentListener);
checkPanelSize();
}
/**
* Check if the 'document of the textComponent' has changed to another
* document or not.
*/
protected void validateTextComponentDocument() {
Document _currentDocument = textComponent.getDocument();
if (document != _currentDocument) {
document.removeDocumentListener(documentListener);
document = _currentDocument;
_currentDocument.addDocumentListener(documentListener);
}
}
/**
* Check whether the height of the row header panel match with the height of
* the text component or not. If not, it will invoke
* {@link #updatePanelSize()}.
*/
public void checkPanelSize() {
validateTextComponentDocument();
int _largestRowNumber = document.getDefaultRootElement().getElementCount() + lineNumberOffset;
int _panelWidth = getFontMetrics(getFont()).stringWidth(Integer.toString(_largestRowNumber)) + paddingLeft + paddingRight;
if (panelWidth != _panelWidth || largestRowNumber != _largestRowNumber) {
panelWidth = _panelWidth;
largestRowNumber = _largestRowNumber;
updatePanelSize();
}
}
/**
* Update the panel size.
*/
protected void updatePanelSize() {
Container parent = getParent();
if (parent != null) {
parent.doLayout();
scrollPane.doLayout();
parent.repaint();
}
}
/**
* The font of the line number.
*/
@Override
public void setFont(Font font) {
super.setFont(font);
}
/**
* The color of the line number.
* @param foreground the color
*/
@Override
public void setForeground(Color foreground) {
super.setForeground(foreground);
}
/**
* The background of the panel.
* @param background the color
*/
@Override
public void setBackground(Color background) {
super.setBackground(background);
}
/**
* {@inheritDoc}
*/
@Override
public Dimension getPreferredSize() {
textComponentHeight = textComponent.getPreferredSize().height;
return new Dimension(panelWidth, textComponentHeight);
}
/**
* {@inheritDoc}
*/
@Override
public void paint(Graphics g) {
super.paint(g);
// check whether the height of this panel matches the height of the text component or not
Dimension textComponentPreferredSize = textComponent.getPreferredSize();
if (textComponentHeight != textComponentPreferredSize.height) {
textComponentHeight = textComponentPreferredSize.height;
updatePanelSize();
}
JViewport viewport = scrollPane.getViewport();
Point viewPosition = viewport.getViewPosition();
Dimension viewportSize = viewport.getSize();
validateTextComponentDocument();
Element defaultRootElement = document.getDefaultRootElement();
// maybe able to get the value when font changed and cache them
// however i'm not sure if there is any condition which will make the java.awt.FontMetrics get by getFontMetrics() from java.awt.Graphics is different from getFontMetrics() from java.awt.Component
FontMetrics fontMetrics = g.getFontMetrics(getFont());
int fontHeight = fontMetrics.getHeight();
int fontAscent = fontMetrics.getAscent();
int fontLeading = fontMetrics.getLeading();
FontMetrics textPaneFontMetrics = g.getFontMetrics(textComponent.getFont());
int textPaneFontHeight = textPaneFontMetrics.getHeight();
// get the location of the document of the left top and right bottom point of the visible part of the text component
int documentOffsetStart = textComponent.viewToModel(viewPosition);
int documentOffsetEnd = textComponent.viewToModel(new Point(viewPosition.x + viewportSize.width, viewPosition.y + viewportSize.height));
// convert the location to line number
int startLine = defaultRootElement.getElementIndex(documentOffsetStart) + 1 + lineNumberOffset;
int endLine = defaultRootElement.getElementIndex(documentOffsetEnd) + 1 + lineNumberOffset;
// draw right border
g.setColor(borderColor);
g.fillRect(panelWidth - borderWidth, viewPosition.y, borderWidth, viewportSize.height);
// draw line number
int startY = -1, baselineOffset = -1;
try {
startY = textComponent.modelToView(documentOffsetStart).y;
baselineOffset = (textPaneFontHeight / 2) + fontAscent - (fontHeight / 2) + fontLeading;
} catch (BadLocationException ex) {
LOG.log(Level.WARNING, null, ex);
return;
}
// text anti-aliasing
((Graphics2D) g).setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, textAntiAliasing);
// preserve the foreground color (for recover the color after highlighing the line)
Color foregroundColor = getForeground();
g.setColor(foregroundColor);
g.setFont(getFont());
for (int i = startLine, y = startY + baselineOffset; i <= endLine; y += textPaneFontHeight, i++) {
boolean highlighted = false;
if (highlightedLineList.indexOf((Integer) i) != -1) {
// highlight this line
g.setColor(borderColor);
g.fillRect(0, y - baselineOffset, panelWidth - borderWidth, textPaneFontHeight);
g.setColor(highlightedColor);
highlighted = true;
}
// draw the line number
String lineNumberString = Integer.toString(i);
int lineNumberStringWidth = fontMetrics.stringWidth(lineNumberString);
g.drawString(lineNumberString, panelWidth - lineNumberStringWidth - paddingRight, y);
// restore the line number text color
if (highlighted) {
g.setColor(foregroundColor);
}
}
}
/**
* The anti-aliasing setting of the line number text. See
* {@link java.awt.RenderingHints}.
*
* @return the setting
*/
public Object getTextAntiAliasing() {
return textAntiAliasing;
}
/**
* The anti-aliasing setting of the line number text. See
* {@link java.awt.RenderingHints}.
*
* @param textAntiAliasing the setting
*/
public void setTextAntiAliasing(Object textAntiAliasing) {
if (textAntiAliasing == null) {
throw new NullPointerException("argument 'textAntiAliasing' cannot be null");
}
this.textAntiAliasing = textAntiAliasing;
repaint();
}
/**
* The color of the border that joint the gutter and the script text area.
* @return the color
*/
public Color getBorderColor() {
return borderColor;
}
/**
* The color of the border that joint the gutter and the script text area.
* @param borderColor the color
*/
public void setBorderColor(Color borderColor) {
if (borderColor == null) {
throw new NullPointerException("argument 'borderColor' cannot be null");
}
this.borderColor = borderColor;
repaint();
}
/**
* The background of the highlighted row.
* @return the color
*/
public Color getHighlightedColor() {
return highlightedColor;
}
/**
* The background of the highlighted row.
* @param highlightedColor the color
*/
public void setHighlightedColor(Color highlightedColor) {
if (highlightedColor == null) {
throw new NullPointerException("argument 'highlightedColor' cannot be null");
}
this.highlightedColor = highlightedColor;
repaint();
}
/**
* The minimum padding from the 'leftmost of the line number text' to the
* 'left margin'.
*
* @return the padding in pixel
*/
public int getPaddingLeft() {
return paddingLeft;
}
/**
* The minimum padding from 'the leftmost of the line number text' to the
* 'left margin'.
*
* @param paddingLeft the padding in pixel
*/
public void setPaddingLeft(int paddingLeft) {
this.paddingLeft = paddingLeft;
checkPanelSize();
}
/**
* The minimum padding from the 'rightmost of the line number text' to the
* 'right margin' (not to the gutter border).
*
* @return the padding in pixel
*/
public int getPaddingRight() {
return paddingRight;
}
/**
* The minimum padding from the 'rightmost of the line number text' to the
* 'right margin' (not to the gutter border).
*
* @param paddingRight the padding in pixel
*/
public void setPaddingRight(int paddingRight) {
this.paddingRight = paddingRight;
checkPanelSize();
}
/**
* The width of the border that joint the gutter and the script text area.
*
* @return the width in pixel
*/
public int getBorderWidth() {
return borderWidth;
}
/**
* The width of the border that joint the gutter and the script text area.
*
* @param borderWidth the width in pixel
*/
public void setBorderWidth(int borderWidth) {
this.borderWidth = borderWidth;
repaint();
}
/**
* Get the line number offset
* @return the offset
*/
public int getLineNumberOffset() {
return lineNumberOffset;
}
/**
* Set the line number offset. E.g. set offset to 9 will make the first line
* number to appear at line 1 + 9 = 10
*
* @param offset the offset
*/
public void setLineNumberOffset(int offset) {
lineNumberOffset = Math.max(lineNumberOffset, offset);
checkPanelSize();
repaint();
}
/**
* Get the list of highlighted lines.
* @return a copy of the list
*/
public List<Integer> getHighlightedLineList() {
return new ArrayList<Integer>(highlightedLineList);
}
/**
* Set highlighted lines. Note that this will clear all previous recorded
* highlighted lines.
* @param highlightedLineList the list that contain the highlighted lines
*/
public void setHighlightedLineList(List<Integer> highlightedLineList) {
synchronized (this.highlightedLineList) {
this.highlightedLineList.clear();
if (highlightedLineList != null) {
this.highlightedLineList.addAll(highlightedLineList);
}
}
repaint();
}
/**
* Add highlighted line.
* @param lineNumber the line number to highlight
* @return see the return value of {@link List#add(Object)}
*/
public boolean addHighlightedLine(int lineNumber) {
boolean returnValue = highlightedLineList.add(lineNumber);
repaint();
return returnValue;
}
/**
* Clear highlighted lines.
*/
public void clearHighlightedLine() {
highlightedLineList.clear();
}
/**
* Check if it is listening to the document change events.
* @return true if it is listening, false if not
*/
public boolean isListenToDocumentUpdate() {
return listenToDocumentUpdate;
}
/**
* Set to listen to document change events or not. It is useful when a number
* of updates are needed to be done to the text component. May invoke
* {@link #checkPanelSize() ()} after setting this to true.
*
* @param listenToDocumentUpdate true to listen on document change, false not
*/
public void setListenToDocumentUpdate(boolean listenToDocumentUpdate) {
this.listenToDocumentUpdate = listenToDocumentUpdate;
}
}