org.zephyrsoft.sdb2.presenter.SongView.java Source code

Java tutorial

Introduction

Here is the source code for org.zephyrsoft.sdb2.presenter.SongView.java

Source

/*
 * This file is part of the Song Database (SDB).
 * 
 * SDB 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 2 of the License, or
 * (at your option) any later version.
 * 
 * SDB 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 SDB. If not, see <http://www.gnu.org/licenses/>.
 */
package org.zephyrsoft.sdb2.presenter;

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Font;
import java.awt.GradientPaint;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.geom.Area;
import java.awt.geom.Rectangle2D;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

import javax.swing.BorderFactory;
import javax.swing.JPanel;
import javax.swing.JTextPane;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.DefaultCaret;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.Style;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyleContext;
import javax.swing.text.StyledDocument;

import org.apache.commons.lang3.Validate;
import org.jdesktop.core.animation.timing.Animator;
import org.jdesktop.core.animation.timing.PropertySetter;
import org.jdesktop.core.animation.timing.TimingTargetAdapter;
import org.jdesktop.core.animation.timing.interpolators.AccelerationInterpolator;
import org.zephyrsoft.sdb2.model.AddressableLine;
import org.zephyrsoft.sdb2.model.AddressablePart;
import org.zephyrsoft.sdb2.model.Song;
import org.zephyrsoft.sdb2.model.SongElement;
import org.zephyrsoft.sdb2.model.SongElementEnum;
import org.zephyrsoft.sdb2.model.SongParser;
import org.zephyrsoft.util.StringTools;

/**
 * Renders the contents of a {@link Song} in order to display it on a screen. Scrolling is handled internally - no
 * scrollpane around this component is needed!
 * 
 * @author Mathis Dirksen-Thedens
 */
public class SongView extends JPanel implements Scroller {

    private static final long serialVersionUID = 4746652382939122421L;

    private static final Style DEFAULT_STYLE = StyleContext.getDefaultStyleContext()
            .getStyle(StyleContext.DEFAULT_STYLE);
    private static final String TITLE_LYRICS_DISTANCE = "TITLE_LYRICS_DISTANCE";
    private static final String LYRICS_COPYRIGHT_DISTANCE = "LYRICS_COPYRIGHT_DISTANCE";
    private static final String LYRICS_FINAL_NEWLINE = "\n";
    private static final String LYRICS_COPYRIGHT_DISTANCE_TEXT = " \n";

    private Song song;
    private boolean showTitle;
    private boolean showChords;
    private Font titleFont;
    private Font lyricsFont;
    private Font translationFont;
    private Font copyrightFont;
    private int topMargin;
    private int leftMargin;
    private int rightMargin;
    private int bottomMargin;
    private int titleLyricsDistance;
    private int lyricsCopyrightDistance;
    private Color foregroundColor;
    private Color backgroundColor;

    private JTextPane text;
    private List<AddressablePart> parts;

    private StyledDocument document;

    protected Animator animator;
    protected Point animatorTarget;

    /**
     * Private constructor: only the builder may call it.
     */
    private SongView(Builder builder) {
        song = builder.song;
        showTitle = builder.showTitle;
        showChords = builder.showChords;
        titleFont = builder.titleFont;
        lyricsFont = builder.lyricsFont;
        translationFont = builder.translationFont;
        copyrightFont = builder.copyrightFont;
        topMargin = builder.topMargin;
        leftMargin = builder.leftMargin;
        rightMargin = builder.rightMargin;
        bottomMargin = builder.bottomMargin;
        titleLyricsDistance = builder.titleLyricsDistance;
        lyricsCopyrightDistance = builder.lyricsCopyrightDistance;
        foregroundColor = builder.foregroundColor;
        backgroundColor = builder.backgroundColor;

        text = new JTextPane();
        ((DefaultCaret) text.getCaret()).setUpdatePolicy(DefaultCaret.NEVER_UPDATE);
        text.setRequestFocusEnabled(false);
        text.setEditable(false);
        text.setEnabled(false);

        setLayout(new BorderLayout());
        add(text, BorderLayout.CENTER);
        setBackground(backgroundColor);
        setOpaque(true);
        text.setForeground(foregroundColor);
        text.setDisabledTextColor(foregroundColor);

        text.setBorder(BorderFactory.createEmptyBorder(topMargin, leftMargin, 0, rightMargin));

        render();

        // workaround for Nimbus L&F:
        text.setOpaque(false);
        text.setBackground(new Color(0, 0, 0, 0));
    }

    @Override
    public void paint(Graphics g) {
        super.paint(g);
        Graphics2D g2d = (Graphics2D) g;
        int topBorderHeight = topMargin;
        int bottomBorderHeight = bottomMargin;

        // gradient upper border as overlay
        Area areaUpper = new Area(new Rectangle2D.Double(0, 0, getWidth(), topBorderHeight));
        g2d.setPaint(new GradientPaint(0, 0,
                new Color(backgroundColor.getRed(), backgroundColor.getGreen(), backgroundColor.getBlue(), 255), 0,
                topBorderHeight,
                new Color(backgroundColor.getRed(), backgroundColor.getGreen(), backgroundColor.getBlue(), 0),
                false));
        g2d.fill(areaUpper);

        // gradient lower border as overlay
        Area areaLower = new Area(
                new Rectangle2D.Double(0, getHeight() - bottomBorderHeight, getWidth(), getHeight()));
        g2d.setPaint(new GradientPaint(0, getHeight() - bottomBorderHeight,
                new Color(backgroundColor.getRed(), backgroundColor.getGreen(), backgroundColor.getBlue(), 0), 0,
                getHeight(),
                new Color(backgroundColor.getRed(), backgroundColor.getGreen(), backgroundColor.getBlue(), 255),
                false));
        g2d.fill(areaLower);
    }

    /**
     * Display the song inside the JTextPane using the constraints indicated by the fields, e.g. "showTitle", and create
     * a list of addressable parts (paragraphs) and lines for the {@link Scroller} methods.
     */
    private void render() {
        List<SongElement> toDisplay = SongParser.parse(song, showTitle, showChords);
        if (toDisplay.size() > 0 && toDisplay.get(toDisplay.size() - 1).getType() != SongElementEnum.COPYRIGHT) {
            // add final newline to fetch the last line of the last part always
            toDisplay.add(new SongElement(SongElementEnum.NEW_LINE, LYRICS_FINAL_NEWLINE));
        }

        // TODO chord lines: correct font and correct spacing to correspond to the words below the chords!

        parts = new ArrayList<>();
        AddressablePart currentPart = new AddressablePart();
        String currentLineText = null;

        document = text.getStyledDocument();
        addStyles();

        // handle the elements of the song
        SongElement prevPrevElement = null;
        SongElement prevElement = null;
        for (SongElement element : toDisplay) {
            handleCopyrightLine(prevElement, element);

            handleTitlePosition(element);
            if (isBodyElement(element)) {
                if (((element.getType() == SongElementEnum.NEW_LINE && prevElement != null
                        && prevElement.getType() == SongElementEnum.NEW_LINE)
                        || (element.getType() == SongElementEnum.NEW_LINE && prevElement != null
                                && StringTools.isBlank(prevElement.getElement())
                                && ((prevPrevElement != null
                                        && prevPrevElement.getType() == SongElementEnum.NEW_LINE)
                                        || prevPrevElement == null)))
                        && currentPart.size() > 0) {
                    // [ two consecutive newlines OR two newlines, only separated by a blank line ] AND current part is
                    // populated with at least one line => save current part and begin a new one
                    parts.add(currentPart);
                    currentPart = new AddressablePart();
                } else if (element.getType() == SongElementEnum.NEW_LINE && prevElement != null
                        && prevElement.getType() == SongElementEnum.LYRICS
                        && !StringTools.isBlank(prevElement.getElement())
                        && ((prevPrevElement != null && (prevPrevElement.getType() == SongElementEnum.NEW_LINE
                                || prevPrevElement.getType() == SongElementEnum.TITLE))
                                || prevPrevElement == null)) {
                    // two newlines OR a title element and a newline, separated by a non-blank lyrics line =>
                    // save current line and begin a new one
                    currentLineText = prevElement.getElement();
                    AddressableLine currentLine = new AddressableLine(currentLineText,
                            createPosition(element, prevElement));
                    currentPart.add(currentLine);
                }
            } else if (element.getType() == SongElementEnum.COPYRIGHT && prevElement != null
                    && prevElement.getType() == SongElementEnum.LYRICS
                    && !StringTools.isBlank(prevElement.getElement())
                    && ((prevPrevElement != null && prevPrevElement.getType() == SongElementEnum.NEW_LINE)
                            || prevPrevElement == null)) {
                // a newline and a copyright element, separated by a non-blank lyrics line =>
                // save current line and begin a new one
                currentLineText = prevElement.getElement();
                AddressableLine currentLine = new AddressableLine(currentLineText, createPosition(prevElement)
                        - LYRICS_COPYRIGHT_DISTANCE_TEXT.length() - LYRICS_FINAL_NEWLINE.length());
                currentPart.add(currentLine);
            }

            String type = element.getType().name();
            if ((element.getType() == SongElementEnum.NEW_LINE && prevElement != null
                    && prevElement.getType() == SongElementEnum.NEW_LINE)
                    || (element.getType() == SongElementEnum.NEW_LINE && prevElement != null
                            && StringTools.isBlank(prevElement.getElement())
                            && ((prevPrevElement != null && prevPrevElement.getType() == SongElementEnum.NEW_LINE)
                                    || prevPrevElement == null))) {
                type = SongElementEnum.LYRICS.name();
            }
            appendText(element.getElement(), type);

            handleTitleLine(element);
            // keep history
            prevPrevElement = prevElement;
            prevElement = element;
        }
        if (currentPart.size() > 0) {
            // current part is populated with at least one line => save current part
            parts.add(currentPart);
        }

    }

    private void handleTitlePosition(SongElement element) {
        if (element.getType() == SongElementEnum.TITLE) {
            Integer position = createPosition();
            AddressablePart titlePart = new AddressablePart();
            titlePart.add(new AddressableLine(element.getElement(), position));
            parts.add(titlePart);
        }
    }

    private Integer createPosition(SongElement... toSubtract) {
        int toSubtractInt = 0;
        for (SongElement element : toSubtract) {
            toSubtractInt += element.getElement() == null ? 0 : element.getElement().length();
        }
        return document.getLength() - toSubtractInt + 1;
    }

    private void addStyles() {
        addStyleFromFont(SongElementEnum.TITLE.name(), titleFont);
        addStyle(TITLE_LYRICS_DISTANCE, false, false, lyricsFont.getFamily(), titleLyricsDistance);
        addStyleFromFont(SongElementEnum.LYRICS.name(), lyricsFont);
        addStyleFromFont(SongElementEnum.TRANSLATION.name(), translationFont);
        addStyle(LYRICS_COPYRIGHT_DISTANCE, false, false, lyricsFont.getFamily(), lyricsCopyrightDistance);
        addStyleFromFont(SongElementEnum.COPYRIGHT.name(), copyrightFont);
    }

    private void handleTitleLine(SongElement element) {
        if (isTitleLine(element)) {
            // append space
            appendText(LYRICS_FINAL_NEWLINE, SongElementEnum.NEW_LINE.name());
            appendText(LYRICS_COPYRIGHT_DISTANCE_TEXT, TITLE_LYRICS_DISTANCE);
        }
    }

    private static boolean isTitleLine(SongElement element) {
        return element.getType() == SongElementEnum.TITLE;
    }

    private static boolean isBodyElement(SongElement element) {
        return element.getType() == SongElementEnum.CHORDS || element.getType() == SongElementEnum.LYRICS
                || element.getType() == SongElementEnum.TRANSLATION
                || element.getType() == SongElementEnum.NEW_LINE;
    }

    private void handleCopyrightLine(SongElement previousElement, SongElement element) {
        if (isFirstCopyrightLine(previousElement, element)) {
            // prepend space
            appendText(LYRICS_FINAL_NEWLINE, SongElementEnum.NEW_LINE.name());
            appendText(LYRICS_COPYRIGHT_DISTANCE_TEXT, LYRICS_COPYRIGHT_DISTANCE);
        } else if (isCopyrightLineButNotFirstOne(previousElement, element)) {
            // prepend newline
            appendText(LYRICS_FINAL_NEWLINE, SongElementEnum.NEW_LINE.name());
        }
    }

    private static boolean isCopyrightLineButNotFirstOne(SongElement previousElement, SongElement element) {
        return previousElement != null && previousElement.getType() == SongElementEnum.COPYRIGHT
                && element.getType() == SongElementEnum.COPYRIGHT;
    }

    private static boolean isFirstCopyrightLine(SongElement previousElement, SongElement element) {
        return previousElement != null && previousElement.getType() != SongElementEnum.COPYRIGHT
                && element.getType() == SongElementEnum.COPYRIGHT;
    }

    private void appendText(String string, String type) {
        try {
            int offset = document.getLength();
            // add style only if type is anything apart from NEW_LINE
            AttributeSet style = SimpleAttributeSet.EMPTY;
            if (type != null && type != SongElementEnum.NEW_LINE.name()) {
                style = document.getStyle(type);
            }
            document.insertString(offset, string, style);
        } catch (BadLocationException e) {
            throw new IllegalStateException("could not insert text into document", e);
        }
    }

    private Style addStyleFromFont(String styleName, Font font) {
        return addStyle(styleName, font.isItalic(), font.isBold(), font.getFamily(), font.getSize());
    }

    private Style addStyle(String styleName, boolean italic, boolean bold, String fontFamily, int fontSize) {
        Style style = document.addStyle(styleName, DEFAULT_STYLE);
        StyleConstants.setItalic(style, italic);
        StyleConstants.setBold(style, bold);
        StyleConstants.setFontFamily(style, fontFamily);
        StyleConstants.setFontSize(style, fontSize);
        return style;
    }

    @Override
    public List<AddressablePart> getParts() {
        Validate.notNull(parts, "the song parts are not initialized");
        return parts;
    }

    @Override
    public void moveToPart(Integer part) {
        Validate.notNull(parts, "the song parts are not initialized");
        adjustHeightIfNecessary();
        try {
            AddressablePart addressablePart = parts.get(part);
            Validate.notNull(addressablePart, "part index does not correspond to a part of the song");
            Rectangle target = text.modelToView(addressablePart.getPosition());
            animatedMoveTo(new Point(text.getLocation().x, topMargin - target.y));
        } catch (BadLocationException e) {
            throw new IllegalStateException("could not identify position in text", e);
        }
    }

    @Override
    public void moveToLine(Integer part, Integer line) {
        Validate.notNull(parts, "the song parts are not initialized");
        adjustHeightIfNecessary();
        try {
            AddressablePart addressablePart = parts.get(part);
            Validate.notNull(addressablePart, "part index does not correspond to a part of the song");
            AddressableLine addressableLine = addressablePart.get(line);
            Validate.notNull(addressableLine, "line index does not correspond to a line of the addressed part");
            Rectangle target = text.modelToView(addressableLine.getPosition());
            animatedMoveTo(new Point(text.getLocation().x, topMargin - target.y));
        } catch (BadLocationException e) {
            throw new IllegalStateException("could not identify position in text", e);
        }
    }

    private void animatedMoveTo(Point targetLocation) {
        if (animator != null && animator.isRunning() && animatorTarget != null
                && animatorTarget.equals(targetLocation)) {
            // animator is already moving the song text to the requested location
            return;
        } else {
            animatorTarget = targetLocation;
        }
        if (animator != null && animator.isRunning()) {
            animator.stop();
            // discard old animator because it takes too long to let it stop completely
            animator = null;
        }
        animator = createAnimator();
        TimingTargetAdapter target = PropertySetter.getTargetTo(text, "location",
                new AccelerationInterpolator(0.5, 0.5), targetLocation);
        animator.addTarget(target);
        animator.start();
    }

    private Animator createAnimator() {
        return new Animator.Builder().setDuration(1200, TimeUnit.MILLISECONDS).build();
    }

    private void adjustHeightIfNecessary() {
        // adjust height to meet at least the required value so that all text is visible
        if (text.getSize().height < text.getPreferredSize().height) {
            text.setSize(text.getSize().width, text.getPreferredSize().height);
        }
    }

    public static class Builder {
        private Song song;
        private Boolean showTitle;
        private Boolean showChords;
        private Font titleFont;
        private Font lyricsFont;
        private Font translationFont;
        private Font copyrightFont;
        private Integer topMargin;
        private Integer leftMargin;
        private Integer rightMargin;
        private Integer bottomMargin;
        private Integer titleLyricsDistance;
        private Integer lyricsCopyrightDistance;
        private Color foregroundColor;
        private Color backgroundColor;

        public Builder(Song song) {
            this.song = song;
        }

        public Builder showTitle(Boolean bool) {
            this.showTitle = bool;
            return this;
        }

        public Builder showChords(Boolean bool) {
            this.showChords = bool;
            return this;
        }

        public Builder titleFont(Font font) {
            this.titleFont = font;
            return this;
        }

        public Builder lyricsFont(Font font) {
            this.lyricsFont = font;
            return this;
        }

        public Builder translationFont(Font font) {
            this.translationFont = font;
            return this;
        }

        public Builder copyrightFont(Font font) {
            this.copyrightFont = font;
            return this;
        }

        public Builder topMargin(Integer pixels) {
            this.topMargin = pixels;
            return this;
        }

        public Builder leftMargin(Integer pixels) {
            this.leftMargin = pixels;
            return this;
        }

        public Builder rightMargin(Integer pixels) {
            this.rightMargin = pixels;
            return this;
        }

        public Builder bottomMargin(Integer pixels) {
            this.bottomMargin = pixels;
            return this;
        }

        public Builder titleLyricsDistance(Integer pixels) {
            this.titleLyricsDistance = pixels;
            return this;
        }

        public Builder lyricsCopyrightDistance(Integer pixels) {
            this.lyricsCopyrightDistance = pixels;
            return this;
        }

        public Builder foregroundColor(Color color) {
            this.foregroundColor = color;
            return this;
        }

        public Builder backgroundColor(Color color) {
            this.backgroundColor = color;
            return this;
        }

        public SongView build() {
            // make sure every variable was initialized
            if (song == null || showTitle == null || showChords == null || titleFont == null || lyricsFont == null
                    || translationFont == null || copyrightFont == null || topMargin == null || leftMargin == null
                    || rightMargin == null || bottomMargin == null || titleLyricsDistance == null
                    || lyricsCopyrightDistance == null || foregroundColor == null || backgroundColor == null) {
                throw new IllegalStateException("not every builder method was called with a non-null value");
            }

            return new SongView(this);
        }

    }
}