/* * The MIT License * * Copyright (c) 2009 Samuel Sjoberg * * 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 org.demo.filebrowser.explorer.view; import java.awt.Dimension; import java.awt.FontMetrics; import java.awt.Graphics; import java.awt.Insets; import java.awt.Rectangle; import java.awt.event.ComponentEvent; import java.awt.event.ComponentListener; import java.beans.PropertyChangeEvent; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import javax.swing.Icon; import javax.swing.JComponent; import javax.swing.JLabel; import javax.swing.plaf.ComponentUI; import javax.swing.plaf.LabelUI; import javax.swing.plaf.basic.BasicLabelUI; import javax.swing.text.BadLocationException; import javax.swing.text.Document; import javax.swing.text.Element; import javax.swing.text.PlainDocument; import javax.swing.text.Segment; import javax.swing.text.Utilities; import javax.swing.text.View; /** * Label UI delegate that supports multiple lines and line wrapping. Hard line * breaks (<code>\n</code>) are preserved. If the dimensions of the label is too * small to fit all content, the string will be clipped and "..." appended to * the end of the visible text (similar to the default behavior of * <code>JLabel</code>). If used in conjunction with a {@link MultiLineLabel}, * text alignment (horizontal and vertical) is supported. The UI delegate can be * used on a regular <code>JLabel</code> if text alignment isn't required. The * default alignment, left and vertically centered, will then be used. * <p> * Example of usage: * * <pre> * JLabel myLabel = new JLabel(); * myLabel.setUI(MultiLineLabelUI.labelUI); * myLabel.setText("A long label that will wrap automatically."); * </pre> * * <p> * The line and wrapping support is implemented without using a * <code>View</code> to make it easy for subclasses to add custom text effects * by overriding {@link #paintEnabledText(JLabel, Graphics, String, int, int)} * and {@link #paintDisabledText(JLabel, Graphics, String, int, int)}. This * class is designed to be easily extended by subclasses. * * @author Samuel Sjoberg, http://samuelsjoberg.com * @version 1.3.0 */ public class MultiLineLabelUI extends BasicLabelUI implements ComponentListener { /** Shared instance of the UI delegate. */ public static LabelUI labelUI = new MultiLineLabelUI(); /** * Client property key used to store the calculated wrapped lines on the * JLabel. */ public static final String PROPERTY_KEY = "WrappedText"; // Static references to avoid heap allocations. protected static Rectangle paintIconR = new Rectangle(); protected static Rectangle paintTextR = new Rectangle(); protected static Rectangle paintViewR = new Rectangle(); protected static Insets paintViewInsets = new Insets(0, 0, 0, 0); /** Font metrics of the JLabel being rendered. */ protected FontMetrics metrics; /** Default size of the lines list. */ protected static int defaultSize = 4; /** * Get the shared UI instance. * * @param c * the component about to be installed * @return the shared UI delegate instance */ public static ComponentUI createUI(JComponent c) { return labelUI; } /** {@inheritDoc} */ protected void uninstallDefaults(JLabel c) { super.uninstallDefaults(c); clearCache(c); } /** {@inheritDoc} */ protected void installListeners(JLabel c) { super.installListeners(c); c.addComponentListener(this); } /** {@inheritDoc} */ protected void uninstallListeners(JLabel c) { super.uninstallListeners(c); c.removeComponentListener(this); } /** * Clear the wrapped line cache. * * @param l * the label containing a cached value */ protected void clearCache(JLabel l) { l.putClientProperty(PROPERTY_KEY, null); } /** {@inheritDoc} */ public void propertyChange(PropertyChangeEvent e) { super.propertyChange(e); final String name = e.getPropertyName(); if (name.equals("text") || "font".equals(name)) { clearCache((JLabel) e.getSource()); } } /** * Calculate the paint rectangles for the icon and text for the passed * label. * * @param l * a label * @param fm * the font metrics to use, or <code>null</code> to get the font * metrics from the label * @param width * label width * @param height * label height */ protected void updateLayout(JLabel l, FontMetrics fm, int width, int height) { if (fm == null) { fm = l.getFontMetrics(l.getFont()); } metrics = fm; String text = l.getText(); Icon icon = l.getIcon(); Insets insets = l.getInsets(paintViewInsets); paintViewR.x = insets.left; paintViewR.y = insets.top; paintViewR.width = width - (insets.left + insets.right); paintViewR.height = height - (insets.top + insets.bottom); paintIconR.x = paintIconR.y = paintIconR.width = paintIconR.height = 0; paintTextR.x = paintTextR.y = paintTextR.width = paintTextR.height = 0; layoutCL(l, fm, text, icon, paintViewR, paintIconR, paintTextR); } protected void prepareGraphics(Graphics g) { } /** {@inheritDoc} */ public void paint(Graphics g, JComponent c) { // parent's update method fills the background prepareGraphics(g); JLabel label = (JLabel) c; String text = label.getText(); Icon icon = (label.isEnabled()) ? label.getIcon() : label .getDisabledIcon(); if ((icon == null) && (text == null)) { return; } FontMetrics fm = g.getFontMetrics(); updateLayout(label, fm, c.getWidth(), c.getHeight()); if (icon != null) { icon.paintIcon(c, g, paintIconR.x, paintIconR.y); } if (text != null) { View v = (View) c.getClientProperty("html"); if (v != null) { // HTML view disables multi-line painting. v.paint(g, paintTextR); } else { // Paint the multi line text paintTextLines(g, label, fm); } } } /** * Paint the wrapped text lines. * * @param g * graphics component to paint on * @param label * the label being painted * @param fm * font metrics for current font */ protected void paintTextLines(Graphics g, JLabel label, FontMetrics fm) { List<String> lines = getTextLines(label); // Available component height to paint on. int height = getAvailableHeight(label); int textHeight = lines.size() * fm.getHeight(); while (textHeight > height) { // Remove one line until no. of visible lines is found. textHeight -= fm.getHeight(); } paintTextR.height = Math.min(textHeight, height); paintTextR.y = alignmentY(label, fm, paintTextR); int textX = paintTextR.x; int textY = paintTextR.y; for (Iterator<String> it = lines.iterator(); it.hasNext() && paintTextR.contains(textX, textY + getAscent(fm)); textY += fm .getHeight()) { String text = it.next().trim(); if (it.hasNext() && !paintTextR.contains(textX, textY + fm.getHeight() + getAscent(fm))) { // The last visible row, add a clip indication. text = clip(text, fm, paintTextR); } int x = alignmentX(label, fm, text, paintTextR); if (label.isEnabled()) { paintEnabledText(label, g, text, x, textY); } else { paintDisabledText(label, g, text, x, textY); } } } /** * Returns the available height to paint text on. This is the height of the * passed component with insets subtracted. * * @param l * a component * @return the available height */ protected int getAvailableHeight(JLabel l) { l.getInsets(paintViewInsets); return l.getHeight() - paintViewInsets.top - paintViewInsets.bottom; } /** * Add a clip indication to the string. It is important that the string * length does not exceed the length or the original string. * * @param text * the to be painted * @param fm * font metrics * @param bounds * the text bounds * @return the clipped string */ protected String clip(String text, FontMetrics fm, Rectangle bounds) { // Fast and lazy way to insert a clip indication is to simply replace // the last characters in the string with the clip indication. // A better way would be to use metrics and calculate how many (if any) // characters that need to be replaced. if (text.length() < 3) { return "..."; } return text.substring(0, text.length() - 3) + "..."; } /** * Establish the vertical text alignment. The default alignment is to center * the text in the label. * * @param label * the label to paint * @param fm * font metrics * @param bounds * the text bounds rectangle * @return the vertical text alignment, defaults to CENTER. */ protected int alignmentY(JLabel label, FontMetrics fm, Rectangle bounds) { final int height = getAvailableHeight(label); int textHeight = bounds.height; if (label instanceof JLabel) { int align = ((JLabel) label).getVerticalAlignment(); switch (align) { case JLabel.TOP: return getAscent(fm) + paintViewInsets.top; case JLabel.BOTTOM: return getAscent(fm) + height - paintViewInsets.top + paintViewInsets.bottom - textHeight; default: } } // Center alignment int textY = paintViewInsets.top + (height - textHeight) / 2 + getAscent(fm); return Math.max(textY, getAscent(fm) + paintViewInsets.top); } private static int getAscent(FontMetrics fm) { return fm.getAscent() + fm.getLeading(); } /** * Establish the horizontal text alignment. The default alignment is left * aligned text. * * @param label * the label to paint * @param fm * font metrics * @param s * the string to paint * @param bounds * the text bounds rectangle * @return the x-coordinate to use when painting for proper alignment */ protected int alignmentX(JLabel label, FontMetrics fm, String s, Rectangle bounds) { if (label instanceof JLabel) { int align = ((JLabel) label).getHorizontalAlignment(); switch (align) { case JLabel.RIGHT: return bounds.x + paintViewR.width - fm.stringWidth(s); case JLabel.CENTER: return bounds.x + paintViewR.width / 2 - fm.stringWidth(s) / 2; default: return bounds.x; } } return bounds.x; } /** * Check the given string to see if it should be rendered as HTML. Code * based on implementation found in * <code>BasicHTML.isHTMLString(String)</code> in future JDKs. * * @param s * the string * @return <code>true</code> if string is HTML, otherwise <code>false</code> */ private static boolean isHTMLString(String s) { if (s != null) { if ((s.length() >= 6) && (s.charAt(0) == '<') && (s.charAt(5) == '>')) { String tag = s.substring(1, 5); return tag.equalsIgnoreCase("html"); } } return false; } /** {@inheritDoc} */ public Dimension getPreferredSize(JComponent c) { Dimension d = super.getPreferredSize(c); JLabel label = (JLabel) c; if (isHTMLString(label.getText())) { return d; // HTML overrides everything and we don't need to process } // Width calculated by super is OK. The preferred width is the width of // the unwrapped content as long as it does not exceed the width of the // parent container. if (c.getParent() != null) { // Ensure that preferred width never exceeds the available width // (including its border insets) of the parent container. Insets insets = c.getParent().getInsets(); Dimension size = c.getParent().getSize(); if (size.width > 0) { // If width isn't set component shouldn't adjust. d.width = size.width - insets.left - insets.right; } } updateLayout(label, null, d.width, d.height); // The preferred height is either the preferred height of the text // lines, or the height of the icon. d.height = Math.max(d.height, getPreferredHeight(label)); return d; } /** * The preferred height of the label is the height of the lines with added * top and bottom insets. * * @param label * the label * @return the preferred height of the wrapped lines. */ protected int getPreferredHeight(JLabel label) { int numOfLines = getTextLines(label).size(); Insets insets = label.getInsets(paintViewInsets); return numOfLines * metrics.getHeight() + insets.top + insets.bottom; } /** * Get the lines of text contained in the text label. The prepared lines is * cached as a client property, accessible via {@link #PROPERTY_KEY}. * * @param l * the label * @return the text lines of the label. */ @SuppressWarnings("unchecked") protected List<String> getTextLines(JLabel l) { List<String> lines = (List<String>) l.getClientProperty(PROPERTY_KEY); if (lines == null) { lines = prepareLines(l); l.putClientProperty(PROPERTY_KEY, lines); } return lines; } /** {@inheritDoc} */ public void componentHidden(ComponentEvent e) { // Don't care } /** {@inheritDoc} */ public void componentMoved(ComponentEvent e) { // Don't care } /** {@inheritDoc} */ public void componentResized(ComponentEvent e) { clearCache((JLabel) e.getSource()); } /** {@inheritDoc} */ public void componentShown(ComponentEvent e) { // Don't care } /** * Prepare the text lines for rendering. The lines are wrapped to fit in the * current available space for text. Explicit line breaks are preserved. * * @param l * the label to render * @return a list of text lines to render */ protected List<String> prepareLines(JLabel l) { List<String> lines = new ArrayList<String>(defaultSize); String text = l.getText(); if (text == null) { return null; // Null guard } PlainDocument doc = new PlainDocument(); try { doc.insertString(0, text, null); } catch (BadLocationException e) { return null; } Element root = doc.getDefaultRootElement(); for (int i = 0, j = root.getElementCount(); i < j; i++) { wrap(lines, root.getElement(i)); } return lines; } /** * If necessary, wrap the text into multiple lines. * * @param lines * line array in which to store the wrapped lines * @param elem * the document element containing the text content */ protected void wrap(List<String> lines, Element elem) { int p1 = elem.getEndOffset(); Document doc = elem.getDocument(); for (int p0 = elem.getStartOffset(); p0 < p1;) { int p = calculateBreakPosition(doc, p0, p1); try { lines.add(doc.getText(p0, p - p0)); } catch (BadLocationException e) { throw new Error("Can't get line text. p0=" + p0 + " p=" + p); } p0 = (p == p0) ? p1 : p; } } /** * Calculate the position on which to break (wrap) the line. * * @param doc * the document * @param p0 * start position * @param p1 * end position * @return the actual end position, will be <code>p1</code> if content does * not need to wrap, otherwise it will be less than <code>p1</code>. */ protected int calculateBreakPosition(Document doc, int p0, int p1) { Segment segment = SegmentCache.getSegment(); try { doc.getText(p0, p1 - p0, segment); } catch (BadLocationException e) { throw new Error("Can't get line text"); } int width = paintTextR.width; int p = p0 + Utilities.getBreakLocation(segment, metrics, 0, width, null, p0); SegmentCache.releaseSegment(segment); return p; } /** * Static singleton {@link Segment} cache. * * @see javax.swing.text.SegmentCache * * @author Samuel Sjoberg */ protected static final class SegmentCache { /** Reused segments. */ private ArrayList<Segment> segments = new ArrayList<Segment>(2); /** Singleton instance. */ private static SegmentCache cache = new SegmentCache(); /** Private constructor. */ private SegmentCache() { } /** * Returns a <code>Segment</code>. When done, the <code>Segment</code> * should be recycled by invoking {@link #releaseSegment(Segment)}. * * @return a <code>Segment</code>. */ public static Segment getSegment() { int size = cache.segments.size(); if (size > 0) { return cache.segments.remove(size - 1); } return new Segment(); } /** * Releases a <code>Segment</code>. A segment should not be used after * it is released, and a segment should never be released more than * once. */ public static void releaseSegment(Segment segment) { segment.array = null; segment.count = 0; cache.segments.add(segment); } } }