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}