/* * This file is part of WebLookAndFeel library. * * WebLookAndFeel library is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * WebLookAndFeel library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with WebLookAndFeel library. If not, see . */ /* * 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 com.alee.extended.label; import com.alee.laf.label.WebLabelStyle; import com.alee.utils.LafUtils; import com.alee.utils.SwingUtils; import javax.swing.*; import javax.swing.plaf.ComponentUI; import javax.swing.plaf.basic.BasicLabelUI; import javax.swing.text.*; import java.awt.*; 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 java.util.Map; /** * Label UI delegate that supports multiple lines and line wrapping. Hard line breaks (\n) 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 JLabel). * * @author Samuel Sjoberg, http://samuelsjoberg.com */ public class WebMultiLineLabelUI extends BasicLabelUI implements ComponentListener { /** * 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 ); // Variables private static int defaultSize = 4; private FontMetrics metrics; // View settings private boolean drawShade = WebLabelStyle.drawShade; private Color shadeColor = WebLabelStyle.shadeColor; /** * UI instance creation. * * @param c the component about to be installed * @return the shared UI delegate instance */ @SuppressWarnings ("UnusedParameters") public static ComponentUI createUI ( final JComponent c ) { return new WebMultiLineLabelUI (); } @Override public void installUI ( final JComponent c ) { super.installUI ( c ); // Default settings SwingUtils.setOrientation ( c ); } /** * {@inheritDoc} */ @Override protected void uninstallDefaults ( final JLabel c ) { super.uninstallDefaults ( c ); clearCache ( c ); } /** * {@inheritDoc} */ @Override protected void installListeners ( final JLabel c ) { super.installListeners ( c ); c.addComponentListener ( this ); } /** * {@inheritDoc} */ @Override protected void uninstallListeners ( final JLabel c ) { super.uninstallListeners ( c ); c.removeComponentListener ( this ); } /** * View settings */ public boolean isDrawShade () { return drawShade; } public void setDrawShade ( final boolean drawShade ) { this.drawShade = drawShade; } public Color getShadeColor () { return shadeColor; } public void setShadeColor ( final Color shadeColor ) { this.shadeColor = shadeColor; } /** * Clear the wrapped line cache. * * @param l the label containing a cached value */ protected void clearCache ( final JLabel l ) { l.putClientProperty ( PROPERTY_KEY, null ); } /** * {@inheritDoc} */ @Override public void propertyChange ( final 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 null to get the font metrics from the label * @param width label width * @param height label height */ protected void updateLayout ( final JLabel l, FontMetrics fm, final int width, final int height ) { if ( fm == null ) { fm = l.getFontMetrics ( l.getFont () ); } metrics = fm; final String text = l.getText (); final Icon icon = l.getIcon (); final 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 ); } /** * {@inheritDoc} */ @Override public void paint ( final Graphics g, final JComponent c ) { final Map hints = SwingUtils.setupTextAntialias ( g ); final JLabel label = ( JLabel ) c; final String text = label.getText (); final Icon icon = ( label.isEnabled () ) ? label.getIcon () : label.getDisabledIcon (); if ( ( icon == null ) && ( text == null ) ) { return; } final 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 ) { final 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 ); } } SwingUtils.restoreTextAntialias ( g, hints ); } /** * 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 ( final Graphics g, final JLabel label, final FontMetrics fm ) { final List lines = getTextLines ( label ); // Available component height to paint on. final 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 ); final int textX = paintTextR.x; int textY = paintTextR.y; for ( Iterator 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 ); } final int x = alignmentX ( label, fm, text ); if ( label.isEnabled () ) { paintEnabledText ( label, g, text, x, textY ); } else { paintDisabledText ( label, g, text, x, textY ); } } } /** * {@inheritDoc} */ @Override protected void paintEnabledText ( final JLabel l, final Graphics g, final String s, final int textX, final int textY ) { if ( drawShade ) { g.setColor ( l.getForeground () ); paintShadowText ( g, s, textX, textY ); } else { final int mnemIndex = l.getDisplayedMnemonicIndex (); g.setColor ( l.getForeground () ); SwingUtils.drawStringUnderlineCharAt ( g, s, mnemIndex, textX, textY ); } } /** * {@inheritDoc} */ @Override protected void paintDisabledText ( final JLabel l, final Graphics g, final String s, final int textX, final int textY ) { if ( drawShade ) { g.setColor ( l.getBackground ().darker () ); paintShadowText ( g, s, textX, textY ); } else { final int mnemIndex = l.getDisplayedMnemonicIndex (); g.setColor ( l.getForeground () ); SwingUtils.drawStringUnderlineCharAt ( g, s, mnemIndex, textX, textY ); } } /** * Paint the text with a text effect. * * @param g graphics component used to paint on * @param s the string to paint * @param textX the x coordinate * @param textY the y coordinate */ private void paintShadowText ( final Graphics g, final String s, final int textX, final int textY ) { g.translate ( textX, textY ); LafUtils.paintTextShadow ( ( Graphics2D ) g, s, shadeColor ); g.translate ( -textX, -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 ( final 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 * @return the clipped string */ protected String clip ( final String text ) { // 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 ( final JLabel label, final FontMetrics fm, final Rectangle bounds ) { final int height = getAvailableHeight ( label ); final int textHeight = bounds.height; final int align = 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 final int textY = paintViewInsets.top + ( height - textHeight ) / 2 + getAscent ( fm ); return Math.max ( textY, getAscent ( fm ) + paintViewInsets.top ); } private static int getAscent ( final 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 * @return the x-coordinate to use when painting for proper alignment */ protected int alignmentX ( final JLabel label, final FontMetrics fm, final String s ) { final boolean ltr = label.getComponentOrientation ().isLeftToRight (); final int align = label.getHorizontalAlignment (); if ( align == JLabel.RIGHT || align == JLabel.TRAILING && ltr || align == JLabel.LEADING && !ltr ) { return paintViewR.width - fm.stringWidth ( s ); } else if ( align == JLabel.CENTER ) { return paintViewR.width / 2 - fm.stringWidth ( s ) / 2; } else { return paintViewR.x; } } /** * Check the given string to see if it should be rendered as HTML. Code based on implementation found in * BasicHTML.isHTMLString(String) in future JDKs. * * @param s the string * @return true if string is HTML, otherwise false */ private static boolean isHTMLString ( final String s ) { if ( s != null ) { if ( ( s.length () >= 6 ) && ( s.charAt ( 0 ) == '<' ) && ( s.charAt ( 5 ) == '>' ) ) { final String tag = s.substring ( 1, 5 ); return tag.equalsIgnoreCase ( "html" ); } } return false; } /** * {@inheritDoc} */ @Override public Dimension getPreferredSize ( final JComponent c ) { final Dimension d = super.getPreferredSize ( c ); final 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. final Insets insets = c.getParent ().getInsets (); final 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 ( final JLabel label ) { final int numOfLines = getTextLines ( label ).size (); final 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 getTextLines ( final JLabel l ) { List lines = ( List ) l.getClientProperty ( PROPERTY_KEY ); if ( lines == null ) { lines = prepareLines ( l ); l.putClientProperty ( PROPERTY_KEY, lines ); } return lines; } /** * {@inheritDoc} */ @Override public void componentHidden ( final ComponentEvent e ) { // Don't care } /** * {@inheritDoc} */ @Override public void componentMoved ( final ComponentEvent e ) { // Don't care } /** * {@inheritDoc} */ @Override public void componentResized ( final ComponentEvent e ) { clearCache ( ( JLabel ) e.getSource () ); } /** * {@inheritDoc} */ @Override public void componentShown ( final 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 prepareLines ( final JLabel l ) { final List lines = new ArrayList ( defaultSize ); final String text = l.getText (); if ( text == null ) { return null; // Null guard } final PlainDocument doc = new PlainDocument (); try { doc.insertString ( 0, text, null ); } catch ( BadLocationException e ) { return null; } final 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 ( final List lines, final Element elem ) { final int p1 = elem.getEndOffset (); final Document doc = elem.getDocument (); for ( int p0 = elem.getStartOffset (); p0 < p1; ) { final 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 p1 if content does not need to wrap, otherwise it will be less than * p1. */ protected int calculateBreakPosition ( final Document doc, final int p0, final int p1 ) { final Segment segment = SegmentCache.getSegment (); try { doc.getText ( p0, p1 - p0, segment ); } catch ( BadLocationException e ) { throw new Error ( "Can't get line text" ); } final int width = paintTextR.width; final int p = p0 + Utilities.getBreakLocation ( segment, metrics, 0, width, null, p0 ); SegmentCache.releaseSegment ( segment ); return p; } /** * Static singleton {@link Segment} cache. * * @author Samuel Sjoberg * @see javax.swing.text.SegmentCache */ protected static final class SegmentCache { /** * Reused segments. */ private ArrayList segments = new ArrayList ( 2 ); /** * Singleton instance. */ private static SegmentCache cache = new SegmentCache (); /** * Private constructor. */ private SegmentCache () { // } /** * Returns a Segment. When done, the Segment should be recycled by invoking {@link * #releaseSegment(Segment)}. * * @return a Segment. */ public static Segment getSegment () { final int size = cache.segments.size (); if ( size > 0 ) { return cache.segments.remove ( size - 1 ); } return new Segment (); } /** * Releases a Segment. A segment should not be used after it is released, and a segment should never be released more * than once. */ public static void releaseSegment ( final Segment segment ) { segment.array = null; segment.count = 0; cache.segments.add ( segment ); } } }