//
// Typodrome.java -- Typing drill/learning tool
//
//                   by Javva Brothers <jb@absurd.org>
//
//                   url: <http://www.absurd.org/absurd/typodrome>  
//
// This Program Is A Public Domain. It may be freely distributed in source or
// binary for without any restrictions. Author release all the rights on
// this program and disclaim any responsibility for any damages suffered 
// by the users of this program. 
//

import java.applet.*;
import java.awt.*;
import java.util.*;

public class Typodrome extends Applet 
{
        private Choice stage_;
        private Button drill_btn_;
        private Label err_lbl_;
        private Label lines_lbl_;
        private int errors_;
        private int lines_;

        private TypoSession     session_;
        private TypoController  ctl_;
        private TypoLesson      lesson_;
        private TypoTimer       timer_;
        private GraphPlot       cpm_graph_;
        private GraphPlot       err_graph_;
                
        private int             timer_running; // -1 not started, 1 running, 0 not
        
        private LockedInt       chars_typed_;
        
        private boolean         drill_mode_;
                        
        public void init() 
        {
                this.setBackground(Color.white);
                               
                stage_ = new Choice();
                
                drill_btn_ = new Button();
                set_drill_mode(false);

                Label time_lbl = new Label("00:00");
                Label cpm_lbl = new Label("000");
                err_lbl_ = new Label("00");
                lines_lbl_ = new Label("0000");
                
                errors_ = 0;
                lines_ = 0;
                
                session_ = new TypoSession();

                for( int i = 0; i < session_.lessons(); i++ )
                {
                        String str = new String("lesson" +
                                        String.valueOf(i) + " " +
                                        session_.lessonName(i) );
                        
                        stage_.addItem(str);
                }
 
                lesson_ = session_.new_lesson(0);

                ctl_ = new TypoController(lesson_);
                chars_typed_ = new LockedInt(0);
                
                timer_ = new TypoTimer(time_lbl, cpm_lbl, chars_typed_);
                timer_running = -1;
                
                cpm_graph_ = new GraphPlot("cpm", 100, 50, 10, 50, true);
                err_graph_ = new GraphPlot("errors", 4, 0, 2, 50, false);
                                
                // set up layout
                //
                GridBagLayout gridbag = new GridBagLayout();
                GridBagConstraints c = new GridBagConstraints();
                                               
                setLayout(gridbag);
                
                // first row : bunch of lil' controls
                //
                c.fill = GridBagConstraints.BOTH;
                
                addcompo(stage_, gridbag, c);
                addcompo(drill_btn_, gridbag, c);
                addlabel("time:", gridbag, c);
                addcompo(time_lbl, gridbag, c);
                addlabel("cpm:", gridbag, c);
                addcompo(cpm_lbl, gridbag, c);
                addlabel("errors:", gridbag, c);
                addcompo(err_lbl_, gridbag, c);
                addlabel("lines:", gridbag, c);
                
                // last item in the 1st row
                c.gridwidth = GridBagConstraints.REMAINDER;                
                addcompo(lines_lbl_, gridbag, c);
                
                // second row
                // 
                c.weightx = 1.0;                
                addcompo(ctl_, gridbag, c);
                
                // third row (the last)
                //
                c.weighty = 1.0;
                addcompo(err_graph_, gridbag, c);
                c.gridheight = GridBagConstraints.REMAINDER;
                c.weighty = 2.0;                
                addcompo(cpm_graph_, gridbag, c);
        }

        private void addlabel( String name, GridBagLayout gridbag,
                                                GridBagConstraints c )
        {
                Label label = new Label(name);
                addcompo(label, gridbag, c);
        }
        
        private void addcompo( Component compo, GridBagLayout gridbag,
                                                        GridBagConstraints c )
        {
                gridbag.setConstraints(compo, c);
                add(compo);
        }

        private void set_drill_mode( boolean on )
        {
                if( on )
                        drill_btn_.setLabel( "Drill" );
                else
                        drill_btn_.setLabel( "Study" );
                drill_mode_ = on;
        }

        // overloaded from Applet
             
        public boolean keyDown(Event e, int key)
        {
                
                if( e.id != Event.KEY_PRESS )
                        return true;
                
                if( timer_running <= 0 )
                {
                        timer_.reset();
                        if( timer_running < 0 )
                                timer_.start();
                        else
                                timer_.resume();
                        timer_running = 1;
                }
                
                if( (char)e.key == '\n' )
                {
                        if( ctl_.charsTyped() == 0 )
                                return true;
                        int errors = ctl_.errors();
                        ctl_.reset();
                        ctl_.repaint();
                        if( timer_running > 0 )
                        {
                                timer_.suspend();
                                timer_running = 0;
                        }
                        cpm_graph_.addTick( timer_.charPerMinute() ); 
                        err_graph_.addTick( errors );                       
                        chars_typed_.set(0);   
                        lines_++;
                        lines_lbl_.setText(String.valueOf(lines_));   
                 
                }
                else
                {
                        chars_typed_.inc();
                        ctl_.keyDown( (char)e.key );
                }
                       
                err_lbl_.setText(String.valueOf(ctl_.errors()));

                return true;
        }     
        
        public boolean action( Event e, Object arg )
        {
                if( e.target == stage_ )
                {
                        reset_lesson();
                        return true;
                }
                else if( e.target == drill_btn_ )
                {
                        set_drill_mode(!drill_mode_);
                        reset_lesson();
                }
                return super.action(e, arg);
        }
        
        private void reset_lesson()
        {
                        int idx = stage_.getSelectedIndex();
                        lesson_ = session_.new_lesson(idx);
                        if( drill_mode_ )
                                lesson_.shun_new_chars();
                        ctl_.reset( lesson_ );
                        ctl_.repaint();
                        err_lbl_.setText(String.valueOf(ctl_.errors()));
        }
}

//-----------------------------------------------------------------------
//
//  TypoLesson
//
//-----------------------------------------------------------------------
class TypoLesson extends Object
{

        private final static int CHARGROUPS = 5;
        private final static int ALPHA = 0;
        private final static int DIGIT = 1;
        private final static int DELIM = 2;
        private final static int OPENCH = 3;
        
        private String[]          chars_;
        private String[]          new_chars_;
        
        private String          name_;
        
        private int             alpha_num_;     // keeps current group, whether
                                                // is is DIGIT or ALPHA : used
                                                // to evade mixing alpha and 
                                                // digits in the same word
        private boolean         shift_;
        private boolean         shift_new_;
        private Random          rand_;        
        
        public TypoLesson( String chars, String new_chars, 
                                boolean shift, boolean shift_new )
        {
                chars_ = new String[CHARGROUPS];
                new_chars_ = new String[CHARGROUPS];
                for( int i = 0; i < CHARGROUPS; i++ )
                {
                        chars_[i] = new String();
                        new_chars_[i] = new String();
                }
                
                shift_ = shift;
                shift_new_ = shift_new;
                
                rand_ = new Random();
                
                sieve_chars(chars, chars_);
                sieve_chars(new_chars, new_chars_);

                name_ = new String(new_chars);
                if( shift_new_ )
                        name_ += " Shift";
        }
        
        public void shun_new_chars()
        {
                for( int i = 0; i < CHARGROUPS ; i++ )
                {
                        if( new_chars_[i].length() == 0 )
                                continue;
                        chars_[i] += new_chars_[i];
                        new_chars_[i] = new String();
                }
                if( shift_new_ )
                {
                        shift_ = true;
                        shift_new_ = false;
                }
        }
        
        public String name() { return name_; }
        
        public String generateString( int length )
        {
                StringBuffer buf = new StringBuffer();
        
                while( buf.length() < length )
                {
                    char startch;
                    int i, n;
                    
                        // do we need opening thing, like bracket ?
                        if( (startch = want_startch())  != 0 )
                                buf.append( startch );

                        n = rand_nchars();
                        for( i = 0; i < n ; i++ )
                                buf.append( rand_char(i == 0) );
                        
                        if( startch != 0 )
                        {
                                buf.append( endchar_for(startch) );
                                buf.append( ' ' );
                        }
                        else
                        {
                             char ch;
                                if( (ch = want_delim()) != 0 )
                                        buf.append(ch);
                                if( ch == 0 || space_for_delim(ch) )
                                        buf.append( ' ' );
                        }
                }
                
                char ch_arr[] = new char[length];
                buf.setLength( length );
                buf.getChars( 0, length, ch_arr, 0 ); 
                return new String(ch_arr);
        }
        
        //
        // private methods
        //
        
        
         // random number generation functions
         //
        private int rand_in_range( int min, int max )
        {
            float frand = rand_.nextFloat();
                return (min + (int)(frand * (float)(max - min + 1)));
        }
        
        private int rand_upto( int max ) { return rand_in_range( 0, max ); }      
        private int rand_nchars() { return rand_in_range(1, 5); }
        private boolean rand_bin() { return (rand_upto(1) == 1); }
        
        //
        // choice determination functions
        //
        
        private char want_startch()
        {
                return pick_nonalphanum( OPENCH, 5, 4 );
        }

        private char pick_nonalphanum( int group, int max_odd, int min_odd )
        {
            int len;
                if( (len = new_chars_[group].length()) > 0 && 
                                                        (rand_upto(2) >1) )
                        return new_chars_[group].charAt(rand_upto(len-1));
                else if( (len = chars_[group].length()) > 0 && 
                                                (rand_upto(max_odd) > min_odd) )
                        return chars_[group].charAt(rand_upto(len-1));
                else
                        return 0;        
        }

        private char endchar_for( char ch )
        {
                String starts = new String("{[(<\"");
                String ends = new String("}])>\"");
                int idx = starts.indexOf( ch );
                return ((idx >= 0) ? ends.charAt(idx) : ch);
        }
        
        private char rand_char( boolean is_first ) 
        {
                if( is_first )
                        alpha_num_ = want_number();
                
                String chars = want_new_chars(alpha_num_); 
                char ch = chars.charAt(rand_upto(chars.length() - 1));
                        
                if( alpha_num_ == ALPHA && is_first && want_uppercase() &&
                                                        (ch >= 'a' && ch <= 'z') )
                        ch += 'A' - 'a';

                return ch;
        }

        // decide whether we want element from (old) chars_ array or
        // new_chars_ array
        private String want_new_chars( int idx )
        {
                if( chars_[idx].length() == 0 )
                        return new_chars_[idx];
                else if( new_chars_[idx].length() == 0 )
                        return chars_[idx];
                else                            // pick new char with frequency 1:3
                        return ((rand_upto(4) > 1) ? chars_[idx] : new_chars_[idx]);
        }

        // decide whether we will be picking up from
        // ALPHA group or DIGIT group. This made we will be sure we never 
        // intermix alpha and numerics (is it so bad?)
        //
        private int want_number()
        {
                // we always should have somthing in (new_)chars_[ALPHA]
                // therefore we always will pick ALPHA if there's no digits in.
                //
                if( (new_chars_[DIGIT].length() > 0) ||
                                        (chars_[DIGIT].length() > 0) )
                {
                        // if we have new digits, they will go with
                        // frequency 1:2, otherwise 1:10
                        if( new_chars_[DIGIT].length() > 0 )
                                return (rand_bin() ? ALPHA : DIGIT);
                        else
                                return ((rand_upto(10) > 0) ? ALPHA : DIGIT);
                }
                else    // only alpha, no digits
                        return ALPHA;
        }

        private boolean want_uppercase()
        {
                // if shift is a part of this lesson, we always want new.
                // otherwise if shift is present, we will get it with freq. 1:5
                if( shift_new_ )
                        return true;
                else if( shift_ )
                        return (rand_upto(6) > 5);
                else
                        return false;
        }

        private char want_delim()
        {
                return pick_nonalphanum( DELIM, 5, 4 );
        }
        
        private boolean space_for_delim( char delim )
        {
                String delims = new String(".,:;?!");
                return (delims.indexOf(delim) >= 0);
                        
        }

        // the only language-specific routine in this class
        //
        private void sieve_chars( String chars, String [] str_arr )
        {
                for( int i = 0; i < chars.length(); i++ )
                {
                        int idx;
                        char ch = chars.charAt(i);
                        char ch_arr[] = { ch };
                        
                        if( ch >= 'a' && ch <= 'z' )
                                idx = ALPHA;
                        else if( ch >= '0' && ch <= '9' )
                                idx = DIGIT;
                        else if( ch == '{' || ch == '<' || ch == '[' || ch == '(' || 
                                                                        ch == '"' )
                                idx = OPENCH;
                        else if( ch == '}' || ch == '>' || ch == ']' || ch == ')' )
                                continue;
                        else 
                                idx = DELIM;
                        
                        if( str_arr[idx].indexOf(ch) < 0 )
                                str_arr[idx] += new String(ch_arr);
                }
        }       
}

//-----------------------------------------------------------------------
//
//  TypoSession
//
//-----------------------------------------------------------------------

class TypoSession extends Object
{
        private Vector lessons_;
        int level_w_shift_;
        
        public TypoSession()
        {
                lessons_ = new Vector();
                add_lesson( "asdfjkl;" );
                add_lesson( "eu" );
                add_lesson( "ri" );
                add_lesson( "go" ); 
                add_lesson( "." ); 
                level_w_shift_ = lessons_.size()-1;
                add_lesson( "th" );
                add_lesson( "wy," );
                add_lesson( "qp:" );
                // TAB goes here ?
                add_lesson( "cv/" );
                add_lesson( "bm\'" );
                add_lesson( "xn?" );
                add_lesson( "z\"" );
                add_lesson( "[]" );
                add_lesson( "<>" );
                add_lesson( "{}" );
                add_lesson( "-_" );      // CAPS LOCK here
                add_lesson( "137" );
                add_lesson( "26" );
                add_lesson( "59" );
                add_lesson( "480" );
                // BACKSPACE should have a special lesson, too
                // this will include modifications to the controller, btw
                add_lesson( "=~\\" );
                add_lesson( "%()" );
                add_lesson( "^!+" );
                add_lesson( "&@$" );
                add_lesson( "*#" );
                add_lesson( "`|" );
        }

        private void add_lesson( String new_chars ) 
        { 
                lessons_.addElement( new String(new_chars) );
        }
        
        public int lessons()
        {
                return lessons_.size();
        }
        public String lessonName( int level )
        {
                String str = new String(string_at(level));
                if( level == level_w_shift_ )
                        str += " Shift";
                return str;
        }
        public TypoLesson new_lesson( int level )
        {
                // level number starts from 0
                if( level < 0 )
                        level = 0;
                if( level >= lessons_.size() )
                        level = lessons_.size() - 1;
                
                String str = new String();
                for( int i = 0; i < level; i++ )
                        str += string_at(i);

                return new TypoLesson(str, string_at(level), 
                                        level >= level_w_shift_,
                                        level == level_w_shift_ );
        }
        
        private String string_at( int index )
        {
                return (String)lessons_.elementAt(index);
        }
}

//-----------------------------------------------------------------------
//
//  TypoController
//
//-----------------------------------------------------------------------

class TypoController extends Canvas
{
        int errors_;
        private char[] text_;
        private int text_len_;
        private int pos_;
        private Color [] colors_;
        private int xstart_;
        private int ybase_;
        
        // font metrics
        private int advance_;
        private int ascent_;
        private int descent_;
        private int char_wid_;

        private TypoLesson lesson_;

        public final static Color TYPED = Color.lightGray;
        public final static Color MISTYPED = Color.red;
        public final static Color INITIAL = Color.black;

        public final static int MAXTEXTLEN = 50;

        public TypoController( TypoLesson lesson )
        {
                colors_ = new Color[MAXTEXTLEN];
                xstart_ = 10;
                ybase_ = 10;
                advance_ = ascent_ = descent_ = -1;
                lesson_ = lesson;
                do_reset();
        }

        private void measure()
        {
                if( advance_ == -1 )
                        measure_now();
        }

        private Font pick_font()
        {
                int sizes[] = { 36, 24, 20, 18, 17, 16, 15, 14, 13, 12, 11, 10, 
                                                                9, 8, 7, 6, 5, 4, -1 }; 
                int fill = xstart_ * 2;         // allow margin on each side
                int max_len = MAXTEXTLEN;
                int w = size().width;
                
                Font font = null;                        
                for( int i = 0; sizes[i] > 0; i++)
                {
                        font = new Font( "Courier", Font.BOLD, sizes[i] );
                        if( font == null )
			{
				System.out.print( "ERROR: cannot create font size " + sizes[i] + "\n" );
                                continue;
			}
                                                        
                        FontMetrics fm = getFontMetrics(font);  
                        if( fm == null )
			{
				System.out.print( "ERROR: cannot get metrics for font size " 
									+ sizes[i] + "\n" );
                                continue;         // no luck, try later
			}
/*			
			System.out.print( "Trying font size " + sizes[i] + " : advance = " +
					fm.getMaxAdvance() + ", ascent = " + fm.getMaxAscent() +
					", descent = " + fm.getMaxDescent() + 
					", char wid = " + fm.charWidth('0') + "\n" );
*/
                        if( (fm.getMaxAdvance() * max_len + fill) < w )
                                return font;
                }
		// on UNIX, fonts are not so easily scaleable,
		// so we may not find the tight fit, therefore pick up thesmallest one
		return font;		
//                return null;
        }

        // NEVER call this method directly!
        // if font size SHOULD be changed, set advance_ to -1
        // and call measure()
        //
        private void measure_now()
        {
                // adjust font to our width
                //
                if( size().width == 0 )
                        return;         // width is not set, try later
                
                Font font = pick_font();
                if( font == null )
                        return;
                
                super.setFont(font);                
                FontMetrics fm = getFontMetrics(font);  
                
                advance_ = fm.getMaxAdvance();                         
                ascent_ = fm.getMaxAscent();
                descent_ = fm.getMaxDescent(); 
                char_wid_ = fm.charWidth('0');
                ybase_ = advance_+10;             
        }

        public Dimension minimumSize()
        {
                // this will not work in a separate window which 
                // supposed to be resized more than once
                //
                // the idea is to provide height only when we got 
                // reliable width
                //
                
                if( size().width <= 0 )
                        return new Dimension(1600,10);  // random size
                else
                {       
                        int dy = 20;                        
                        int min_hgt;
                        
                        // we've got non-zero width, now we can determine 
                        // minimum height 
                        measure();
                        
                        if( ascent_ == -1 )     // can't get font metrics
                                min_hgt = 10;   // -- don't know yet
                        else
                                min_hgt = (ascent_+descent_*2+dy);
                
                        return new Dimension(40, min_hgt);
                                // minimum width here doesn't matter 
                                // since we will be
                                // stretched anyway
                }
        }
        
        public Dimension preferredSize()
        {
                return minimumSize();
        }


        public void setFont( Font f )
        {
                // prevent clients from screwing with our font
                //
                // do nothing
        }
        

        private void do_reset()
        {
                String str = lesson_.generateString(MAXTEXTLEN);
                text_ = str.toCharArray();
                text_len_ = str.length();
                if( text_len_ > MAXTEXTLEN )
                        text_len_ = MAXTEXTLEN;
                pos_ = 0;
                errors_ = 0;
                for( int i = 0; i < text_len_; i++ )
                        colors_[i] = INITIAL;
        }

        public void reset()
        {
                Graphics g = getGraphics();
                draw_cursor(g, pos_, false);
                do_reset();
        }

        public int charsTyped()
        {
                return pos_;
        }

        public void reset( TypoLesson lesson )
        {
                lesson_ = lesson;
                reset();
        }

        // also inherited from Canvas
        //
        public void paint( Graphics g ) 
        {
                g.setColor( Color.lightGray);
                g.drawRect(0, 0, size().width-1, size().height-1);                
                g.setColor( getBackground() );
                
                int wid = text_len_ * advance_;
                g.fillRect( xstart_, ybase_-ascent_, wid, ascent_+descent_ );
                        
                for( int i = 0; i < text_len_; i++ )
                        draw_char( g, i, colors_[i] );
                draw_cursor(g, pos_, true);
        }
        
        public int errors()
        {
                return errors_;
        }
        
        public void keyDown( char key )
        {
                Graphics g = getGraphics();
                Color color;
                if( key == text_[pos_] )
                        color = TYPED;
                else
                {
                        color = MISTYPED;
                        errors_++;
                }
                
                draw_cursor(g, pos_, false);
                draw_char(g, pos_, color );
                colors_[pos_] = color;
                if( pos_ < text_len_ )
                        pos_++;
                draw_cursor(g, pos_, true);                        
        }
        
        private void draw_char( Graphics g, int pos, Color type )
        {
                measure();

                g.setColor( type );
                
                int x = xstart_ + pos * advance_;
                g.drawChars(text_, pos, 1, x, ybase_ );
        }
        
        private void draw_cursor( Graphics g, int pos, boolean is_on )
        {
                measure();

                g.setColor( is_on ? Color.black : Color.white );
                
                int x = xstart_ + pos * advance_;
                int y = ybase_+descent_;
                
                if( !is_on )
                        g.fillRect( x, y, char_wid_, descent_ );
                else
                {
                        int [] xpoints = {x, x+char_wid_/2, x+char_wid_};
                        int [] ypoints = {y+descent_, y, y+descent_};
                        g.fillPolygon( xpoints, ypoints, 3 );
                }
        } 
}

//-----------------------------------------------------------------------
//
//  TypoTimer
//
//-----------------------------------------------------------------------

class TypoTimer extends Thread
{
        private int sec_;
        private int cpm_;
        private Label time_lbl_;
        private Label cpm_lbl_;
        private LockedInt chars_typed_;
        char []time_str_;
        
        TypoTimer(Label time_lbl, Label cpm_lbl, LockedInt chars_typed )
        {
                time_lbl_ = time_lbl;
                cpm_lbl_ = cpm_lbl;
                time_str_ = new char[5];        // XX:XX
                time_str_[2] = ':';
                chars_typed_ = chars_typed;
                reset();
        }
        
        public void run()
        {
                try 
                {
                        for( ; ; )
                        {
                                sleep( 1000 );  // roughly 1 sec.
                                inc_time( chars_typed_.get() );
                                display();
                        }
                }
                catch( InterruptedException e )
                {
                        return;
                }
        }
        
        public int charPerMinute()
        {
                return cpm_;
        }
        
        public void reset()
        {
                sec_ = 0;
                cpm_ = 0;
                cvt_time(sec_);
                display();
        }
        
        private void display()
        {
                time_lbl_.setText(String.valueOf(time_str_, 0, 5));
                if( cpm_ != 0 )
                        cpm_lbl_.setText(String.valueOf(cpm_));
                else
                        cpm_lbl_.setText("000");
        }
        
        private void inc_time( int chars )
        {
                sec_++;
                cpm_ = (int)(((float)chars / (float)sec_) * 60.0);
                cvt_time( sec_ );
        }
        
        private void cvt_time( int sec )
        {
                time_str_[4] = (char)('0' + (sec % 10));
                sec /= 10;
                time_str_[3] = (char)('0' + (sec % 6 ));
                sec /= 6;
                time_str_[1] = (char)('0' + (sec % 10));
                sec /= 10;
                time_str_[0] = (char)('0' + (sec % 6 ));
                sec /= 6;                
        }
        
}

//-----------------------------------------------------------------------
//
//  TypoGraph
//
//-----------------------------------------------------------------------

class LockedInt extends Object
{
        private static int val_;
        
        LockedInt( int val ) { val_ = val; }
        synchronized int get() 
        { 
                int val = val_; 
                return val;
        }
        synchronized void set( int val )
        {
                val_ = val;
        }
        synchronized void inc()
        {
                val_++;
        }
        
}
