/*
 * @(#)PrettyCanvas.java
 *
 * Summary: Renders a string of tokens, usually representing Java source code. Does not handle its own scrolling.
 *
 * Copyright: (c) 2004-2017 Roedy Green, Canadian Mind Products, http://mindprod.com
 *
 * Licence: This software may be copied and used freely for any purpose but military.
 *          http://mindprod.com/contact/nonmil.html
 *
 * Requires: JDK 1.5+
 *
 * Created with: IntelliJ IDEA IDE.
 *
 * Version History:
 *  4.0 2009-04-12 shorter style names, improved highlighting.
 */
package com.mindprod.jdisplay;

import com.mindprod.jtokens.NL;
import com.mindprod.jtokens.Token;

import javax.swing.JPanel;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.util.Arrays;

import static java.lang.System.out;

/**
 * Renders a string of tokens, usually representing Java source code. Does not handle its own scrolling.
 *
 * @author Roedy Green, Canadian Mind Products
 * @version 4.0 2009-04-12 shorter style names, improved highlighting.
 * @since 2004
 */
public class PrettyCanvas extends JPanel
    {
    // ------------------------------ CONSTANTS ------------------------------

    /**
     * true if want extra debug output
     */
    private static final boolean DEBUGGING = false;

    // ------------------------------ FIELDS ------------------------------

    /**
     * Dimensions of the scrollable footprint. Start with dummy in case we get queried before set called.
     */
    // private Dimension dimension = new Dimension( 10, 10 );

    /**
     * Array of tokens to render
     */
    private Token[] tokens;

    /**
     * has the accelerator to render only necessary tokens kicked in yet?
     */
    private boolean accelerated = false;

    /**
     * true if want lineNumbers
     */
    private boolean hasLineNumbers;

    /**
     * baseline in pixels down from top of canvas that bandCount renders on. indexed by bandCount. There will be one
     * entry per non-blank line here.
     */
    private int[] baselines;

    /**
     * first line number to render in a given band.
     */
    private int[] firstLineNumbersInBand;

    /**
     * first token to render in a given band.
     */
    private int[] firstTokensInBand;

    /**
     * counts how many bands we have. If there were no blank lines, would be same as number of lines. Normally the value
     * is a little less that the number of lines since a strip of vertical white space counts as one bandCount.
     */
    private int bandCount;

    /**
     * how many pixels wide line numbers are
     */
    private int lineNumberWidth;

    /**
     * top most baseline where we start rendering a bandCount.
     */
    private int startAtBaseline;

    /**
     * 1-based line number to start rendering the current bandCount.
     */
    private int startAtLineNumber;

    /**
     * Total lines of text in the entire array of Tokens, which is considerably smaller than the total number of
     * tokens.
     */
    private int totalLines;
    // -------------------------- PUBLIC INSTANCE  METHODS --------------------------

    /**
     * Constructor
     */
    public PrettyCanvas()
    {
    // get swing to clear the background for us, tell it we don't paint all the pixels.
    this.setOpaque( true );
    }

    /**
     * called whenever system has a slice to render
     *
     * @param g Graphics defining where and region to paint.
     */
    public void paintComponent( Graphics g )
    {
    // paintComponent will clear the region to the background colour
    super.paintComponent( g );
    // don't meddle with original
    Graphics2D g2d = ( Graphics2D ) g;
    g2d.setRenderingHint( RenderingHints.KEY_TEXT_ANTIALIASING,
            RenderingHints.VALUE_TEXT_ANTIALIAS_ON );
    g2d.setRenderingHint( RenderingHints.KEY_RENDERING,
            RenderingHints.VALUE_RENDER_QUALITY );
    // if wanted to smooth geometric shapes too
    // g2d.setRenderingHint( RenderingHints.KEY_ANTIALIASING,
    // RenderingHints.VALUE_ANTIALIAS_ON );
    render( g2d );
    }

    /**
     * @param width           in pixels of the scrollable region including room for line numbers and margins, but not
     *                        scrollbars.
     * @param height          in pixel of the sscrollableregion including room for margins, but not scrollbars.
     * @param hasLineNumbers  true if want line numbers applied down the left hand side.
     * @param lineNumberWidth with of the line number column
     */
    public void set( int width,
                     int height,
                     boolean hasLineNumbers,
                     int lineNumberWidth )
    {
    if ( DEBUGGING )
        {
        out.println( "PrettyCanvas.set width:" + width
                     + " height:" + height
                     + " hasLineNumbers:" + hasLineNumbers
                     + " lineNumberWidth:" + lineNumberWidth );
        }
    Dimension dimension = new Dimension( width, height );
    // only available in 1.5+
    this.setMinimumSize( dimension );
    this.setPreferredSize( dimension );
    this.setMaximumSize( dimension );
    this.setSize( dimension );
    this.hasLineNumbers = hasLineNumbers;
    this.lineNumberWidth = lineNumberWidth;
    // adding or removing line numbers means new size.
    // caller must setPreferred size
    accelerated = false;
    this.invalidate();
    }

    /**
     * Set tokens to display
     *
     * @param tokens     array of tokens, without lead or trailing NL()
     * @param totalLines number of lines of text to render.
     */
    public void setTokens( Token[] tokens, int totalLines )
    {
    this.tokens = tokens;
    this.totalLines = totalLines;
    this.accelerated = false;
    }

    // -------------------------- OTHER METHODS --------------------------

    /**
     * accelerate rendering by computing just which tokens need to be rendered for a given bandCount. Get the index of
     * the first Token
     *
     * @param r clip region to be rendered.
     *
     * @return first token index that needs to be rendered.
     */
    private int firstTokenNeedToRender( Rectangle r )
    {
    int topOfBand = r.y;
    // pick a baseline just prior to the band for safety.
    int firstBaseline = topOfBand - Geometry.LEADING_PX;
    // home in on a unique 0-based band
    int band = Arrays.binarySearch( baselines, firstBaseline );
    if ( band < 0 )
        {
        // convert insertion point to the band below.
        int insert = -band - 1;
        band = insert - 1;
        band = Math.min( Math.max( 0, band ), bandCount - 1 );
        }
    // As side benefit, we get the startAtBaseline and starAtLineNumber
    startAtBaseline = baselines[ band ];
    // startAtLineNumber is 1-based.
    startAtLineNumber = firstLineNumbersInBand[ band ];
    return firstTokensInBand[ band ];
    }

    /**
     * accelerate rendering by computing just which tokens need to be rendered for a given bandCount.
     *
     * @param r clip region to be rendered.
     *
     * @return last token index that needs to be rendered.
     */
    @SuppressWarnings( { "UnusedAssignment" } )
    private int lastTokenNeedToRender( Rectangle r )
    {
    int bottomOfBand = r.y + r.height;/* y increases down the screen */
    // pick a baseline just after the band for safety.
    // With multiple nls it could be part way through the band, but with
    // nothing
    // to render after it.
    int lastBaseline = bottomOfBand + Geometry.LEADING_PX;
    // home in on a unique 0-based band
    int band = Arrays.binarySearch( baselines, lastBaseline );
    if ( band < 0 )
        {
        // with an inexact hit we want the conservative choice the band
        // after the insert point.
        band = Math.min( Math.max( 0, -band - 1 ), bandCount - 1 );
        }
    if ( band == bandCount - 1 )
        {
        // we are on the last band, render even the very last token.
        return tokens.length - 1;
        }
    else
        {
        /*
        * the last token of the band is the token just before the token on
        * the start of the next band.
        */
        return firstTokensInBand[ band + 1 ] - 1;
        }
    }

    /**
     * Record where on page we started rendering a given band i.e. line with text on it.
     *
     * @param baseline   y in of baseline in pixels from the top of canvas.
     * @param lineNumber one-based line number being rendered
     * @param tokenIndex index of first token on the line, including possibly NL though normally it would be the last
     *                   token of the previous line.
     */
    private void lineRenderedAt( int baseline, int lineNumber, int tokenIndex )
    {
    baselines[ bandCount ] = baseline;
    firstTokensInBand[ bandCount ] = tokenIndex;
    firstLineNumbersInBand[ bandCount ] = lineNumber;
    bandCount++;
    }

    /**
     * Clear binary search arrays used to accelerate rendering by finding only those tokens we need to render.
     */
    private void prepareAccelerator1()
    {
    if ( tokens == null || tokens.length == 0 )
        {
        return;
        }
    // these are a little bigger than we need.
    bandCount = 0;
    baselines = new int[ totalLines ];
    firstTokensInBand = new int[ totalLines ];
    firstLineNumbersInBand = new int[ totalLines ];
    // debugging, fill with easy to spot invalid values.
    for ( int i = 0; i < totalLines; i++ )
        {
        baselines[ i ] = -10;
        firstTokensInBand[ i ] = -20;
        firstLineNumbersInBand[ i ] = -30;
        }
    }

    /**
     * Prepare to use the accelerator by trimming its arrays back to perfect size. We have collected data on where each
     * band is rendering.
     */
    private void prepareAccelerator2()
    {
    // trim arrays back precisely to size so that binary search will work.
    int[] old = baselines;
    baselines = new int[ bandCount ];
    System.arraycopy( old, 0, baselines, 0, bandCount );
    old = firstTokensInBand;
    firstTokensInBand = new int[ bandCount ];
    System.arraycopy( old, 0, firstTokensInBand, 0, bandCount );
    old = firstLineNumbersInBand;
    firstLineNumbersInBand = new int[ bandCount ];
    System.arraycopy( old, 0, firstLineNumbersInBand, 0, bandCount );
    }

    /**
     * does drawing. similar to logic in Footprint.s2CalcPayloadFootprint
     *
     * @param g where to paint
     */
    @SuppressWarnings( { "PointlessArithmeticExpression" } )
    private void render( Graphics2D g )
    {
    // We avoid rendering before or after the clip region.
    // Normally we only render a 4 pixel high band at a time.
    Rectangle r = g.getClipBounds();
    // No need to clear the background of just the clip region
    // because setOpaque handles it
    // g.setColor( this.getBackground() );
    // g.fillRect ( r.x, r.y, r.width, r.height );
    // render all the tokens, some may be offscreen, but no matter.
    if ( tokens == null || tokens.length == 0 )
        {
        return;
        }
    // use rendering hints
    // locals
    // true if this is the first token on theline
    boolean firstTokenOnLine;
    // index of first token to render
    // index of last token to render.
    int firstTokenToRender;
    int lastTokenToRender;
    // x left, for token rendering
    int x;
    // y  basesline for token rendering
    int y;
    // line number rendering
    int lineNumber;
    if ( accelerated )
        {
        firstTokenToRender = firstTokenNeedToRender( r );
        lastTokenToRender = lastTokenNeedToRender( r );
        x = Geometry.LEFT_PADDING_PX;
        y = startAtBaseline;
        lineNumber = startAtLineNumber;
        firstTokenOnLine = true;
        }
    else
        {
        prepareAccelerator1();
        firstTokenToRender = 0;
        lastTokenToRender = tokens.length - 1;
        x = Geometry.LEFT_PADDING_PX;
        y = Geometry.TOP_PADDING_PX + Geometry.LEADING_PX;
        lineNumber = 1;
        firstTokenOnLine = true;
        }
    if ( DEBUGGING )
        {
        out.println( "firstToken:"
                     + firstTokenToRender
                     + " lastToken:"
                     + lastTokenToRender
                     + " x:"
                     + x
                     + " ybaseline:"
                     + y
                     + " r.y:"
                     + r.y
                     + " r.height:"
                     + r.height
                     + " ln:"
                     + lineNumber );
        }
    for ( int i = firstTokenToRender; i <= lastTokenToRender; i++ )
        {
        Token t = tokens[ i ];
        if ( !accelerated && firstTokenOnLine )
            {
            // capture base line info of this line for the accelerator.
            lineRenderedAt( y, lineNumber, i );
            }
        if ( t instanceof NL )
            {
            // render blank lines compressed.
            int lines = ( ( NL ) t ).getCount();
            switch ( lines )
                {
                case 1:
                    // single space
                    y += Geometry.LEADING_PX;
                    break;
                case 2:
                    // 1.5 spacing
                    y += Geometry.LEADING_PX + Geometry.BLANK_LINE_HEIGHT_PX;
                    break;
                case 3:
                default:
                    // anything bigger, just double space.
                    y += Geometry.LEADING_PX + ( Geometry.BLANK_LINE_HEIGHT_PX * 2 );
                    break;
                }
            lineNumber += lines;// leave off line numbers, to avoid
            // scrunching
            x = Geometry.LEFT_PADDING_PX;
            firstTokenOnLine = true;
            }
        else
            {
            // text-rendering or space token
            if ( hasLineNumbers && firstTokenOnLine )
                {
                // draw the line number
                g.setColor( Token.getLineNumberForeground() );
                g.setFont( Token.getLineNumberFont() );
                String digits = Integer.toString( lineNumber );
                // right justify
                int width = g.getFontMetrics().stringWidth( digits );
                // x,y is bottom left corner of text
                g.drawString( digits, x + lineNumberWidth - width, y );
                x += lineNumberWidth + Geometry
                        .LINE_NUMBER_PADDING_PX;
                }
            g.setColor( t.getForeground() );
            final Font font = t.getFont();
            g.setFont( font );
            final String text = t.getText();
            g.drawString( text, x, y );
            x += g.getFontMetrics().stringWidth( text );
            firstTokenOnLine = false;
            }// end else
        }// end for
    // we now have captured all token baselines, even if bandCount was tiny.
    if ( !accelerated )
        {
        prepareAccelerator2();
        accelerated = true;
        }
    }// end render
    }