001/*
002 * SonarQube
003 * Copyright (C) 2009-2016 SonarSource SA
004 * mailto:contact AT sonarsource DOT com
005 *
006 * This program is free software; you can redistribute it and/or
007 * modify it under the terms of the GNU Lesser General Public
008 * License as published by the Free Software Foundation; either
009 * version 3 of the License, or (at your option) any later version.
010 *
011 * This program is distributed in the hope that it will be useful,
012 * but WITHOUT ANY WARRANTY; without even the implied warranty of
013 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
014 * Lesser General Public License for more details.
015 *
016 * You should have received a copy of the GNU Lesser General Public License
017 * along with this program; if not, write to the Free Software Foundation,
018 * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
019 */
020package org.sonar.batch.protocol.viewer;
021
022import java.awt.Color;
023import java.awt.Dimension;
024import java.awt.Font;
025import java.awt.FontMetrics;
026import java.awt.Graphics;
027import java.awt.Insets;
028import java.awt.Point;
029import java.awt.Rectangle;
030import java.beans.PropertyChangeEvent;
031import java.beans.PropertyChangeListener;
032import java.util.HashMap;
033import javax.swing.JPanel;
034import javax.swing.SwingUtilities;
035import javax.swing.border.Border;
036import javax.swing.border.CompoundBorder;
037import javax.swing.border.EmptyBorder;
038import javax.swing.border.MatteBorder;
039import javax.swing.event.CaretEvent;
040import javax.swing.event.CaretListener;
041import javax.swing.event.DocumentEvent;
042import javax.swing.event.DocumentListener;
043import javax.swing.text.AttributeSet;
044import javax.swing.text.BadLocationException;
045import javax.swing.text.Element;
046import javax.swing.text.JTextComponent;
047import javax.swing.text.StyleConstants;
048import javax.swing.text.Utilities;
049
050/**
051 *  This class will display line numbers for a related text component. The text
052 *  component must use the same line height for each line. TextLineNumber
053 *  supports wrapped lines and will highlight the line number of the current
054 *  line in the text component.
055 *
056 *  This class was designed to be used as a component added to the row header
057 *  of a JScrollPane.
058 */
059public class TextLineNumber extends JPanel implements CaretListener, DocumentListener, PropertyChangeListener {
060  public static final float LEFT = 0.0f;
061  public static final float CENTER = 0.5f;
062  public static final float RIGHT = 1.0f;
063
064  private static final Border OUTER = new MatteBorder(0, 0, 0, 2, Color.GRAY);
065
066  private static final int HEIGHT = Integer.MAX_VALUE - 1000000;
067
068  // Text component this TextTextLineNumber component is in sync with
069
070  private JTextComponent component;
071
072  // Properties that can be changed
073
074  private boolean updateFont;
075  private int borderGap;
076  private Color currentLineForeground;
077  private float digitAlignment;
078  private int minimumDisplayDigits;
079
080  // Keep history information to reduce the number of times the component
081  // needs to be repainted
082
083  private int lastDigits;
084  private int lastHeight;
085  private int lastLine;
086
087  private HashMap<String, FontMetrics> fonts;
088
089  /**
090   *  Create a line number component for a text component. This minimum
091   *  display width will be based on 3 digits.
092   *
093   *  @param component  the related text component
094   */
095  public TextLineNumber(JTextComponent component) {
096    this(component, 3);
097  }
098
099  /**
100   *  Create a line number component for a text component.
101   *
102   *  @param component  the related text component
103   *  @param minimumDisplayDigits  the number of digits used to calculate
104   *                               the minimum width of the component
105   */
106  public TextLineNumber(JTextComponent component, int minimumDisplayDigits) {
107    this.component = component;
108
109    setFont(component.getFont());
110
111    setBorderGap(5);
112    setCurrentLineForeground(Color.RED);
113    setDigitAlignment(RIGHT);
114    setMinimumDisplayDigits(minimumDisplayDigits);
115
116    component.getDocument().addDocumentListener(this);
117    component.addCaretListener(this);
118    component.addPropertyChangeListener("font", this);
119  }
120
121  /**
122   *  Gets the update font property
123   *
124   *  @return the update font property
125   */
126  public boolean getUpdateFont() {
127    return updateFont;
128  }
129
130  /**
131   *  Set the update font property. Indicates whether this Font should be
132   *  updated automatically when the Font of the related text component
133   *  is changed.
134   *
135   *  @param updateFont  when true update the Font and repaint the line
136   *                     numbers, otherwise just repaint the line numbers.
137   */
138  public void setUpdateFont(boolean updateFont) {
139    this.updateFont = updateFont;
140  }
141
142  /**
143   *  Gets the border gap
144   *
145   *  @return the border gap in pixels
146   */
147  public int getBorderGap() {
148    return borderGap;
149  }
150
151  /**
152   *  The border gap is used in calculating the left and right insets of the
153   *  border. Default value is 5.
154   *
155   *  @param borderGap  the gap in pixels
156   */
157  public void setBorderGap(int borderGap) {
158    this.borderGap = borderGap;
159    Border inner = new EmptyBorder(0, borderGap, 0, borderGap);
160    setBorder(new CompoundBorder(OUTER, inner));
161    lastDigits = 0;
162    setPreferredWidth();
163  }
164
165  /**
166   *  Gets the current line rendering Color
167   *
168   *  @return the Color used to render the current line number
169   */
170  public Color getCurrentLineForeground() {
171    return currentLineForeground == null ? getForeground() : currentLineForeground;
172  }
173
174  /**
175   *  The Color used to render the current line digits. Default is Coolor.RED.
176   *
177   *  @param currentLineForeground  the Color used to render the current line
178   */
179  public void setCurrentLineForeground(Color currentLineForeground) {
180    this.currentLineForeground = currentLineForeground;
181  }
182
183  /**
184   *  Gets the digit alignment
185   *
186   *  @return the alignment of the painted digits
187   */
188  public float getDigitAlignment() {
189    return digitAlignment;
190  }
191
192  /**
193   *  Specify the horizontal alignment of the digits within the component.
194   *  Common values would be:
195   *  <ul>
196   *  <li>TextLineNumber.LEFT
197   *  <li>TextLineNumber.CENTER
198   *  <li>TextLineNumber.RIGHT (default)
199   *  </ul>
200   *  @param currentLineForeground  the Color used to render the current line
201   */
202  public void setDigitAlignment(float digitAlignment) {
203    this.digitAlignment = digitAlignment > 1.0f ? 1.0f : digitAlignment < 0.0f ? -1.0f : digitAlignment;
204  }
205
206  /**
207   *  Gets the minimum display digits
208   *
209   *  @return the minimum display digits
210   */
211  public int getMinimumDisplayDigits() {
212    return minimumDisplayDigits;
213  }
214
215  /**
216   *  Specify the mimimum number of digits used to calculate the preferred
217   *  width of the component. Default is 3.
218   *
219   *  @param minimumDisplayDigits  the number digits used in the preferred
220   *                               width calculation
221   */
222  public void setMinimumDisplayDigits(int minimumDisplayDigits) {
223    this.minimumDisplayDigits = minimumDisplayDigits;
224    setPreferredWidth();
225  }
226
227  /**
228   *  Calculate the width needed to display the maximum line number
229   */
230  private void setPreferredWidth() {
231    Element root = component.getDocument().getDefaultRootElement();
232    int lines = root.getElementCount();
233    int digits = Math.max(String.valueOf(lines).length(), minimumDisplayDigits);
234
235    // Update sizes when number of digits in the line number changes
236
237    if (lastDigits != digits) {
238      lastDigits = digits;
239      FontMetrics fontMetrics = getFontMetrics(getFont());
240      int width = fontMetrics.charWidth('0') * digits;
241      Insets insets = getInsets();
242      int preferredWidth = insets.left + insets.right + width;
243
244      Dimension d = getPreferredSize();
245      d.setSize(preferredWidth, HEIGHT);
246      setPreferredSize(d);
247      setSize(d);
248    }
249  }
250
251  /**
252   *  Draw the line numbers
253   */
254  @Override
255  public void paintComponent(Graphics g) {
256    super.paintComponent(g);
257
258    // Determine the width of the space available to draw the line number
259
260    FontMetrics fontMetrics = component.getFontMetrics(component.getFont());
261    Insets insets = getInsets();
262    int availableWidth = getSize().width - insets.left - insets.right;
263
264    // Determine the rows to draw within the clipped bounds.
265
266    Rectangle clip = g.getClipBounds();
267    int rowStartOffset = component.viewToModel(new Point(0, clip.y));
268    int endOffset = component.viewToModel(new Point(0, clip.y + clip.height));
269
270    while (rowStartOffset <= endOffset) {
271      try {
272        if (isCurrentLine(rowStartOffset))
273          g.setColor(getCurrentLineForeground());
274        else
275          g.setColor(getForeground());
276
277        // Get the line number as a string and then determine the
278        // "X" and "Y" offsets for drawing the string.
279
280        String lineNumber = getTextLineNumber(rowStartOffset);
281        int stringWidth = fontMetrics.stringWidth(lineNumber);
282        int x = getOffsetX(availableWidth, stringWidth) + insets.left;
283        int y = getOffsetY(rowStartOffset, fontMetrics);
284        g.drawString(lineNumber, x, y);
285
286        // Move to the next row
287
288        rowStartOffset = Utilities.getRowEnd(component, rowStartOffset) + 1;
289      } catch (Exception e) {
290        break;
291      }
292    }
293  }
294
295  /*
296   * We need to know if the caret is currently positioned on the line we
297   * are about to paint so the line number can be highlighted.
298   */
299  private boolean isCurrentLine(int rowStartOffset) {
300    int caretPosition = component.getCaretPosition();
301    Element root = component.getDocument().getDefaultRootElement();
302
303    return root.getElementIndex(rowStartOffset) == root.getElementIndex(caretPosition);
304  }
305
306  /*
307   * Get the line number to be drawn. The empty string will be returned
308   * when a line of text has wrapped.
309   */
310  protected String getTextLineNumber(int rowStartOffset) {
311    Element root = component.getDocument().getDefaultRootElement();
312    int index = root.getElementIndex(rowStartOffset);
313    Element line = root.getElement(index);
314
315    if (line.getStartOffset() == rowStartOffset)
316      return String.valueOf(index + 1);
317    else
318      return "";
319  }
320
321  /*
322   * Determine the X offset to properly align the line number when drawn
323   */
324  private int getOffsetX(int availableWidth, int stringWidth) {
325    return (int) ((availableWidth - stringWidth) * digitAlignment);
326  }
327
328  /*
329   * Determine the Y offset for the current row
330   */
331  private int getOffsetY(int rowStartOffset, FontMetrics fontMetrics)
332    throws BadLocationException {
333    // Get the bounding rectangle of the row
334
335    Rectangle r = component.modelToView(rowStartOffset);
336    int lineHeight = fontMetrics.getHeight();
337    int y = r.y + r.height;
338    int descent = 0;
339
340    // The text needs to be positioned above the bottom of the bounding
341    // rectangle based on the descent of the font(s) contained on the row.
342
343    if (r.height == lineHeight) // default font is being used
344    {
345      descent = fontMetrics.getDescent();
346    } else // We need to check all the attributes for font changes
347    {
348      if (fonts == null)
349        fonts = new HashMap<>();
350
351      Element root = component.getDocument().getDefaultRootElement();
352      int index = root.getElementIndex(rowStartOffset);
353      Element line = root.getElement(index);
354
355      for (int i = 0; i < line.getElementCount(); i++) {
356        Element child = line.getElement(i);
357        AttributeSet as = child.getAttributes();
358        String fontFamily = (String) as.getAttribute(StyleConstants.FontFamily);
359        Integer fontSize = (Integer) as.getAttribute(StyleConstants.FontSize);
360        String key = fontFamily + fontSize;
361
362        FontMetrics fm = fonts.get(key);
363
364        if (fm == null) {
365          Font font = new Font(fontFamily, Font.PLAIN, fontSize);
366          fm = component.getFontMetrics(font);
367          fonts.put(key, fm);
368        }
369
370        descent = Math.max(descent, fm.getDescent());
371      }
372    }
373
374    return y - descent;
375  }
376
377  //
378  // Implement CaretListener interface
379  //
380  @Override
381  public void caretUpdate(CaretEvent e) {
382    // Get the line the caret is positioned on
383
384    int caretPosition = component.getCaretPosition();
385    Element root = component.getDocument().getDefaultRootElement();
386    int currentLine = root.getElementIndex(caretPosition);
387
388    // Need to repaint so the correct line number can be highlighted
389
390    if (lastLine != currentLine) {
391      repaint();
392      lastLine = currentLine;
393    }
394  }
395
396  //
397  // Implement DocumentListener interface
398  //
399  @Override
400  public void changedUpdate(DocumentEvent e) {
401    documentChanged();
402  }
403
404  @Override
405  public void insertUpdate(DocumentEvent e) {
406    documentChanged();
407  }
408
409  @Override
410  public void removeUpdate(DocumentEvent e) {
411    documentChanged();
412  }
413
414  /*
415   * A document change may affect the number of displayed lines of text.
416   * Therefore the lines numbers will also change.
417   */
418  private void documentChanged() {
419    // View of the component has not been updated at the time
420    // the DocumentEvent is fired
421
422    SwingUtilities.invokeLater(new Runnable() {
423      @Override
424      public void run() {
425        try {
426          int endPos = component.getDocument().getLength();
427          Rectangle rect = component.modelToView(endPos);
428
429          if (rect != null && rect.y != lastHeight) {
430            setPreferredWidth();
431            repaint();
432            lastHeight = rect.y;
433          }
434        } catch (BadLocationException ex) {
435          /* nothing to do */
436        }
437      }
438    });
439  }
440
441  //
442  // Implement PropertyChangeListener interface
443  //
444  @Override
445  public void propertyChange(PropertyChangeEvent evt) {
446    if (evt.getNewValue() instanceof Font) {
447      if (updateFont) {
448        Font newFont = (Font) evt.getNewValue();
449        setFont(newFont);
450        lastDigits = 0;
451        setPreferredWidth();
452      } else {
453        repaint();
454      }
455    }
456  }
457}