/*
 * Copyright (c) 2017, L. Adamson
 * All rights reserved.
 * 
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * 
 * 1. Redistributions of source code must retain the above copyright notice, this
 *    list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 *    
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
 * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 * 
 * The views and conclusions contained in the software and documentation are those
 * of the authors and should not be interpreted as representing official policies,
 * either expressed or implied, of L. Adamson or DizzyDragon.net.
 * 
 */

package vintageterminal;

//
// How it works:
// -------------
//
// In an ideal world where computers were infinitely fast, we'd just keep an
// unprocessed "clean" buffer and send the whole thing through a filter chain
// each frame to produce the screen effects we want.
//
// However, this doesn't work in reality, because the screen buffer is pretty
// big, and the filters are slow.
//
// So we have to define two different types of filters: Static and Dynamic.
//
// Static filters are filters that, given the same internal settings, will
// always produce the exact same output for a given input.
// Example: Scanlines, blur, brightness, contrast, v/h pos/size, pinch,
// trapezoid, color separation, hue, saturation.
//
// Dynamic filters are filters that produce different output every time for a
// given input.
// Example: Rolling horizontal distortion, static, random pixel jitter.
//
// So, what we have to do to speed things up is to reduce the number of filters
// that the buffer is run through each frame. The idea is to only run the
// dynamic filters each frame. Since the static filters produce the same
// output every time, we can just run them once when the unprocessed screen
// buffer is updated.
//
// So, we keep two buffers: An unprocessed buffer and a processed buffer.
//
// When a drawing funtion is called, the unprocessed buffer is updated. Then
// the updated area, along with some margin pixels (typically a 3x3 character
// area) are coped out of the unprocessed buffer. This copied area is then run
// through the static filters and copied overtop of the processed buffer.
//
// Then when the screen buffer is fetched for a window refresh, the dynamic
// filters are applied to it, and then it is copied onto the real screen.
//
// When a static filter setting is changed, the whole unprocessed buffer is
// sent through the static filter chain, and then copied into the processed
// buffer.
//
// This, unfortunately, does not allow us to interleave static and dynamic
// filters, but it should be significantly faster than running the whole screen
// buffer through every filter during every frame!
//
// We also have to be able to draw a blinking cursor. To do this, we'll add a
// "semi-cooked" screen buffer to the mix.
//
// So the buffer rendering order will be:
// Raw Screen (raw screen contents) -> Semi-Cooked Screen (render cursor)
// -> Cooked Screen (static filters) -> Render Loop (dynamic filters)
// -> Screen Buffer (scaled to JComponent size)
//

import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Composite;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsEnvironment;
import java.awt.RenderingHints;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.util.Properties;
import java.util.concurrent.ConcurrentLinkedQueue;

import javax.imageio.ImageIO;
import javax.swing.JComponent;
import javax.swing.JPanel;

public class VintageTerminal
{
	final static private long					FPS_LIMIT				= 60;
	
	final static private String					PROPERTIES_PATH			= "vintageterminal/";
	final static private String					FONT_PATH				= "vintageterminal/images/fonts/";
	final static private String					BEZEL_PATH				= "vintageterminal/images/bezels/";
	final static private String					GLASS_PATH				= "vintageterminal/images/glass/";
	
	/* FIXME */ @SuppressWarnings("unused") final static private Color					VINTAGE_AMBER			= new Color( 255, 176, 0 );
	/* FIXME */ @SuppressWarnings("unused") final static private Color					VINTAGE_GREEN			= new Color( 0, 255, 0 );
	
	final private static GraphicsConfiguration	graphicsConfig			= GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice().getDefaultConfiguration();
	
	private enum CursorType			
	{
		UNDERLINE,
		BLOCK
	}
	
	// Font settings.
	final private BufferedImage	fontImage;
	/* FIXME */ @SuppressWarnings("unused") final private BufferedImage	fontImageBold;
	final private int			charWidth;
	final private int			charHeight;
	final private boolean		doubleWidth;
	final private boolean		doubleHeight;
	
	// Screen settings.
	final private BufferedImage	bezelImage;
	final private BufferedImage	glassImage;
	final private float			glassAlpha;
	final private int			borderWidth;
	final private int			screenWidth;
	final private int			screenHeight;
	final private Color			bgColor;
	final private Color			fgColor;
	/* FIXME */ @SuppressWarnings("unused") final private CursorType	cursorType;
	final private int			cursorSpeed;
	
	// Filter settings.
	final private boolean		scanlinesEnabled;
	final private float			scanlinesDepth;
	
	final private class Renderer implements Runnable
	{
		private int					cursorX				= 0;
		private int					cursorY				= 0;
		private boolean				cursorState			= false;
		private boolean				forceCursorUpdate	= false;
		private boolean				needUpdate 			= false;
		
		// Unprocessed raw screen contents.
		final private BufferedImage	rawScreen;
		
		// Raw screen + blinking cursor.
		final private BufferedImage	semiCookedScreen;
		
		// Processed screen contents.
		final private BufferedImage	cookedScreen;
		
		// Scaled screen buffer, to be displayed in GUI in Swing thread.
		private BufferedImage		screenBuffer;
		
		final private AlphaComposite alpha = AlphaComposite.getInstance( AlphaComposite.SRC_OVER,glassAlpha );
		final private AlphaComposite opaque = AlphaComposite.getInstance( AlphaComposite.SRC_OVER,1.0f );
		
		private Renderer()
		{
			// Allocate the raw screen.
			rawScreen = createImage( charWidth * screenWidth, charHeight * screenHeight );
			semiCookedScreen = createImage( charWidth * screenWidth, charHeight * screenHeight );
			
			// Allocate the cooked screen.
			int w = rawScreen.getWidth();
			if( doubleWidth )
				w *= 2;
			w += borderWidth * 2;
			int h = rawScreen.getHeight();
			if( doubleHeight )
				h *= 2;
			h += borderWidth * 2;
			cookedScreen = createImage( w, h );
		}
		
		@Override
		public void run()
		{
			BufferedImage componentBuf;
			Graphics2D semiCookedGc = semiCookedScreen.createGraphics();
			long frameStartTime;
			long sleepTime;
			long frameCountStart = System.currentTimeMillis();
			int nFrames = 0;
			
			for( ;; )
			{
				frameStartTime = System.currentTimeMillis();
				
				nFrames++;
				if( frameCountStart + 1000L < System.currentTimeMillis() )
				{
					needUpdate = true;
					int x = getCursorX();
					int y = getCursorY();
					setCursor( 62, 0 );
					putString( nFrames + " FPS" );
					setCursor( x, y );
					nFrames = 0;
					frameCountStart = System.currentTimeMillis();
				}
				
				// Draw (or erase) the cursor.
				boolean tmpCursorState = ( System.currentTimeMillis() / cursorSpeed ) % 2 == 0;
				if( cursorState != tmpCursorState || forceCursorUpdate )
				{
					needUpdate = true;
					forceCursorUpdate = false;
					cursorState = tmpCursorState;
					int x = cursorX;
					if( x >= screenWidth )
						x = screenWidth - 1;
					int y = cursorY;
					if( y >= screenHeight )
						y = screenHeight - 1;
					if( cursorState )
					{
						// Draw cursor.
						semiCookedGc.setColor( fgColor );
						semiCookedGc.drawLine( x * charWidth, y * charHeight + charHeight - 1, x * charWidth + charWidth - 1, y * charHeight + charHeight - 1 );
					}
					else
					{
						// Erase cursor
						semiCookedGc.drawImage( rawScreen, x * charWidth, y * charHeight, x * charWidth + charWidth, y * charHeight + charHeight, x * charWidth, y * charHeight, x * charWidth + charWidth, y * charHeight + charHeight, Color.BLACK, null );
					}
					staticFilterToCookedScreen( x * charWidth - charWidth, y * charHeight - charHeight, charWidth * 3, charHeight * 3 );
				}
				
				if( needUpdate )
				{
					needUpdate = false;
				
					// FIXME Apply dynamic filters and rescale to backbuffer.
					
					// Scale to Swing component size.
					// FIXME This block is the most CPU-intensive part of the code, by far.
					componentBuf = createImage( component.getWidth(), component.getHeight() );
					Graphics2D gc = componentBuf.createGraphics();
					gc.setRenderingHint( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR );
					gc.setRenderingHint( RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY );
					gc.drawImage( cookedScreen, 0, 0, componentBuf.getWidth(), componentBuf.getHeight(), Color.BLACK, null );
					gc.setComposite( alpha );
					gc.drawImage( glassImage, 0, 0, componentBuf.getWidth(), componentBuf.getHeight(), null, null );
					gc.setComposite( opaque );
					gc.drawImage( bezelImage, 0, 0, componentBuf.getWidth(), componentBuf.getHeight(), null, null );
							
					
					synchronized( VintageTerminal.this )
					{
						screenBuffer = componentBuf;
					}
					component.repaint();
				}
				
				if( FPS_LIMIT > 0 )
				{
					sleepTime = ( 1000L / FPS_LIMIT ) - ( System.currentTimeMillis() - frameStartTime );
					if( sleepTime <= 0 )
					{
						Thread.yield();
					}
					else
					{
						try
						{
							Thread.sleep( sleepTime );
						}
						catch( InterruptedException ex )
						{
							throw new RuntimeException( ex );
						}
					}
				}
			}
		}
		
		private void staticFilterToCookedScreen( int x, int y, int width, int height )
		{
			// Calculate destination coordinates.
			int dx = x;
			int dWidth = width;
			if( doubleWidth )
			{
				dx *= 2;
				dWidth *= 2;
			}
			dx += borderWidth;
			int dy = y;
			int dHeight = height;
			if( doubleHeight )
			{
				dy *= 2;
				dHeight *= 2;
			}
			dy += borderWidth;
			
			// Allocate buffer.
			BufferedImage buf = createImage( dWidth, dHeight );
			
			// Draw raw screen into buffer, optionally with scanlines.
			Graphics2D gc = buf.createGraphics();
			if( scanlinesEnabled && doubleHeight )
			{
				// Pretty rendering for scanlines + doubleheight.
				Composite original = gc.getComposite();
				Composite transparent = AlphaComposite.getInstance( AlphaComposite.SRC_OVER, scanlinesDepth );
				for( int ty = 0; ty < height; ty++ )
				{
					gc.setComposite( transparent );
					gc.drawImage( semiCookedScreen, 0, ty * 2 - 1, dWidth + 1, ty * 2 + 1 - 1, x, y + ty, x + width + 1, y + ty + 1, null );
					gc.drawImage( semiCookedScreen, 0, ty * 2 + 1, dWidth + 1, ty * 2 + 1 + 1, x, y + ty, x + width + 1, y + ty + 1, null );
					gc.setComposite( original );
					gc.drawImage( semiCookedScreen, 0, ty * 2, dWidth + 1, ty * 2 + 1, x, y + ty, x + width + 1, y + ty + 1, null );
				}
			}
			else
			{
				// Uglier rendering for lesser settings.
				gc.drawImage( semiCookedScreen, 0, 0, dWidth, dHeight, x, y, x + width, y + height, Color.BLACK, null );
				if( scanlinesEnabled )
				{
					// FIXME Overlay non-doubleheight scanlines.
				}
			}
			gc.dispose();
			
			// Draw the buffer onto the cooked screen.
			gc = cookedScreen.createGraphics();
			gc.drawImage( buf, dx, dy, Color.BLACK, null );
			gc.dispose();
		}
		
		private void hardwareScroll()
		{
			Graphics2D gc = rawScreen.createGraphics();
			gc.drawImage( rawScreen, 0, -charHeight, null );
			gc.setColor( Color.BLACK );
			gc.fillRect( 0, ( screenHeight - 1 ) * charHeight, screenWidth * charWidth, charHeight );
			gc.dispose();
			// Copy raw buffer to semi cooked buffer.
			gc = semiCookedScreen.createGraphics();
			gc.drawImage( rawScreen, 0, 0, Color.BLACK, null );
			gc.dispose();
			staticFilterToCookedScreen( 0, 0, screenWidth * charWidth, screenHeight * charHeight );
			forceCursorUpdate = true;
		}
	}
	
	final private class ScreenComponent extends JPanel
	{
		private static final long	serialVersionUID	= 1L;
		
		@Override
		public void paintComponent( Graphics screenGc )
		{
			synchronized( VintageTerminal.this )
			{
				screenGc.drawImage( renderer.screenBuffer, 0, 0, null );
			}
		}
	}
	
	private static BufferedImage createImage( BufferedImage src )
	{
		BufferedImage dest = graphicsConfig.createCompatibleImage( src.getWidth(), src.getHeight(), src.getColorModel().getTransparency() );
		Graphics2D gc = dest.createGraphics();
		gc.drawImage( src, 0, 0, null );
		gc.dispose();
		return dest;
	}
	
	private static BufferedImage createImage( int width, int height )
	{
		if( width <= 0 )
			width = 1;
		if( height <= 0 )
			height = 1;
		return graphicsConfig.createCompatibleImage( width, height, graphicsConfig.getColorModel().getTransparency() );
	}
	
	private static Color parseColor( String s )
	{
		String a[] = s.split(",");
		if( a.length != 3 )
			throw new RuntimeException( "Invalid color spec!" );
		return new Color( Integer.parseInt(a[0].trim()), Integer.parseInt(a[1].trim()), Integer.parseInt(a[2].trim()) );
	}
	
	final public InputStream						in					= new InputStream()
																		{
																			@Override
																			public int read() throws IOException
																			{
																				if( !inputStreamEnabled )
																					return -1;
																				while( inputQueue.isEmpty() )
																				{
																					try
																					{
																						Thread.sleep( 10 );
																					}
																					catch( InterruptedException ex )
																					{
																						throw new IOException( ex );
																					}
																				}
																				return inputQueue.poll();
																			}
																		};
	
	final public PrintStream						out					= new PrintStream( new OutputStream()
																		{
																			@Override
																			public void write( int b ) throws IOException
																			{
																				putChar( b );
																			}
																		} );
	
	final private ConcurrentLinkedQueue<Character>	inputQueue			= new ConcurrentLinkedQueue<Character>();
	final private ScreenComponent					component;
	final private Renderer							renderer;
	
	private boolean									inputStreamEnabled	= true;
	private boolean									echo				= true;
	
	public VintageTerminal( String mode )
	{
		InputStream in;
		String s, fontFileName, fontBoldFileName, bezelFileName, glassFileName;
		
		// Load the mode properties.
		Properties props = new Properties();
		in = getClass().getClassLoader().getResourceAsStream( PROPERTIES_PATH + mode + ".properties" );
		if( in == null )
		{
			throw new RuntimeException( "Unable to load mode properties file!" );
		}
		try
		{
			props.load( in );
			in.close();
		}
		catch( IOException ex )
		{
			throw new RuntimeException( ex );
		}
		
		// Load font stuff.
		fontFileName = props.getProperty("fontImage");
		if( fontFileName == null )
			fontFileName = "Default";
		in = getClass().getClassLoader().getResourceAsStream( FONT_PATH + fontFileName + ".png" );
		if( in == null )
		{
			throw new RuntimeException( "Unable to load font image!" );
		}
		try
		{
			fontImage = createImage( ImageIO.read( in ) );
			in.close();
		}
		catch( IOException ex )
		{
			throw new RuntimeException( ex );
		}
		fontBoldFileName = props.getProperty("fontImageBold");
		if( fontBoldFileName == null )
			fontBoldFileName = fontFileName+"Bold";
		in = getClass().getClassLoader().getResourceAsStream( FONT_PATH + fontBoldFileName + ".png" );
		if( in == null )
		{
			fontImageBold = fontImage;
		}
		else
		{
			try
			{
				fontImageBold = createImage( ImageIO.read( in ) );
				in.close();
			}
			catch( IOException ex )
			{
				throw new RuntimeException( ex );
			}
		}
		s = props.getProperty( "charWidth" );
		if( s == null )
			charWidth = fontImage.getWidth() / 16;
		else
			charWidth = Integer.parseInt( s );
		s = props.getProperty( "charHeight" );
		if( s == null )
			charHeight = fontImage.getHeight() / 16;
		else
			charHeight = Integer.parseInt( s );
		doubleWidth = Boolean.parseBoolean( props.getProperty( "doubleWidth" ) );
		doubleHeight = Boolean.parseBoolean( props.getProperty( "doubleHeight" ) );
		
		// Load the screen stuff.
		bezelFileName = props.getProperty("bezelImage");
		if( bezelFileName == null )
			bezelFileName = "Default";
		in = getClass().getClassLoader().getResourceAsStream( BEZEL_PATH + bezelFileName + ".png" );
		if( in == null )
		{
			throw new RuntimeException( "Unable to load bezel image!" );
		}
		try
		{
			bezelImage = createImage( ImageIO.read( in ) );
			in.close();
		}
		catch( IOException ex )
		{
			throw new RuntimeException( ex );
		}
		glassFileName = props.getProperty("glassImage");
		if( glassFileName == null )
			glassFileName = "Default";
		in = getClass().getClassLoader().getResourceAsStream( GLASS_PATH + glassFileName + ".png" );
		if( in == null )
		{
			throw new RuntimeException( "Unable to load glass image!" );
		}
		try
		{
			glassImage = createImage( ImageIO.read( in ) );
			in.close();
		}
		catch( IOException ex )
		{
			throw new RuntimeException( ex );
		}
		s = props.getProperty( "glassAlpha" );
		if( s == null )
			glassAlpha = 0.25f;
		else
			glassAlpha = Float.parseFloat( s );
		s = props.getProperty( "borderWidth" );
		if( s == null )
			borderWidth = 35;
		else
			borderWidth = Integer.parseInt( s );
		s = props.getProperty( "screenWidth" );
		if( s == null )
			screenWidth = 80;
		else
			screenWidth = Integer.parseInt( s );
		s = props.getProperty( "screenHeight" );
		if( s == null )
			screenHeight = 25;
		else
			screenHeight = Integer.parseInt( s );
		s = props.getProperty( "bgColor" );
		if( s == null )
			bgColor = Color.BLACK;
		else
			bgColor = parseColor( s );
		s = props.getProperty( "fgColor" );
		if( s == null )
			fgColor = Color.WHITE;
		else
			fgColor = parseColor( s );
		s = props.getProperty( "cursorType" );
		if( s == null || !s.toLowerCase().trim().equals("block") )
			cursorType = CursorType.UNDERLINE;
		else
			cursorType = CursorType.BLOCK;
		s = props.getProperty( "cursorSpeed" );
		if( s == null )
			cursorSpeed = 500;
		else
			cursorSpeed = Integer.parseInt( s );
		
		// Load the filter stuff
		scanlinesEnabled = Boolean.parseBoolean( props.getProperty( "scanlinesEnabled" ) );
		s = props.getProperty( "scanlinesDepth" );
		if( s == null )
			scanlinesDepth = 0.333333f;
		else
			scanlinesDepth = Float.parseFloat( s );
		
		// Create component and start render thread.
		component = new ScreenComponent();
		component.setFocusable( true );
		component.addKeyListener( new KeyListener()
		{
			@Override
			public void keyTyped( KeyEvent e )
			{
				inputQueue.add( e.getKeyChar() );
				if( echo )
					out.write( e.getKeyChar() );
			}
			
			@Override
			public void keyPressed( KeyEvent e )
			{
			}
			
			@Override
			public void keyReleased( KeyEvent e )
			{
			}
		} );
		renderer = new Renderer();
		new Thread( renderer ).start();
	}
	
	public JComponent getComponent()
	{
		return component;
	}
	
	public boolean getInputStreamEnabled()
	{
		return inputStreamEnabled;
	}
	
	public void setInputStreamEnabled( boolean inputStreamEnabled )
	{
		this.inputStreamEnabled = inputStreamEnabled;
	}
	
	public boolean getEcho()
	{
		return echo;
	}
	
	public void setEcho( boolean echo )
	{
		this.echo = echo;
	}
	
	public void setChar( int ch, int x, int y )
	{
		final int charmapNumColumns = 16;
		final int charmapLength = 256;
		
		// Calculate pixel coordinates.
		ch = ch % charmapLength;
		x = x * charWidth;
		y = y * charHeight;
		Graphics2D gc = renderer.rawScreen.createGraphics();
		int chx = ( ch % charmapNumColumns ) * charWidth;
		int chy = ( ch / charmapNumColumns ) * charHeight;
		
		// Draw the character to the raw screen.
		gc.drawImage( fontImage, x, y, x + charWidth, y + charHeight, chx, chy, chx + charWidth, chy + charHeight, fgColor, null );
		gc.setColor( Color.BLACK );
		gc.setXORMode( bgColor );
		gc.drawImage( fontImage, x, y, x + charWidth, y + charHeight, chx, chy, chx + charWidth, chy + charHeight, null );
		gc.dispose();
		
		// Copy the area to the semi-cooked screen.
		gc = renderer.semiCookedScreen.createGraphics();
		gc.drawImage( renderer.rawScreen, x, y, x + charWidth, y + charHeight, x, y, x + charWidth, y + charHeight, Color.BLACK, null );
		gc.dispose();
		
		// Update area on cooked screen.
		renderer.staticFilterToCookedScreen( x - charWidth, y - charHeight, charWidth * 3, charHeight * 3 );
	}
	
	public void setCursor( int x, int y )
	{
		int oldCursorX = renderer.cursorX;
		int oldCursorY = renderer.cursorY;
		renderer.cursorX = ( x & 0x7fffffff ) % screenWidth;
		renderer.cursorY = ( y & 0x7fffffff ) % screenHeight;
		if( oldCursorX != renderer.cursorX || oldCursorY != renderer.cursorY )
		{
			// Erase old cursor location on semi-cooked buffer to raw buffer
			// contents.
			Graphics2D gc = renderer.semiCookedScreen.createGraphics();
			gc.setColor( Color.RED );
			gc.fillRect( oldCursorX * charWidth, oldCursorY * charHeight, charWidth, charHeight );
			gc.drawImage( renderer.rawScreen, oldCursorX * charWidth, oldCursorY * charHeight, oldCursorX * charWidth + charWidth, oldCursorY * charHeight + charHeight, oldCursorX * charWidth, oldCursorY * charHeight, oldCursorX * charWidth + charWidth, oldCursorY * charHeight + charHeight, Color.BLACK, null );
			gc.dispose();
			renderer.staticFilterToCookedScreen( oldCursorX * charWidth - charWidth, oldCursorY * charHeight - charHeight, charWidth * 3, charHeight * 3 );
		}
		renderer.forceCursorUpdate = true;
	}
	
	public void advanceCursor()
	{
		// FIXME --- This may cause the typical "lower-right" bug.
		// Ought to wait to scroll until the character that would exceed the
		// screen width is written instead.
		// Maybe???
		if( getCursorX() < screenWidth - 1 )
			setCursor( getCursorX() + 1, getCursorY() );
		else
			newLine();
	}
	
	public int getCursorX()
	{
		return renderer.cursorX;
	}
	
	public int getCursorY()
	{
		return renderer.cursorY;
	}
	
	private void newLine()
	{
		int y = getCursorY();
		if( y >= screenHeight - 1 )
		{
			renderer.hardwareScroll();
		}
		else
		{
			y++;
		}
		setCursor( 0, y );
	}
	
	public int getChar()
	{
		if( inputStreamEnabled )
			return -1;
		inputQueue.clear();
		while( inputQueue.isEmpty() )
		{
			try
			{
				Thread.sleep( 10 );
			}
			catch( InterruptedException ex )
			{
				throw new RuntimeException( ex );
			}
		}
		return inputQueue.poll();
	}
	
	public void putChar( int ch )
	{
		if( ch == '\n' )
		{
			newLine();
		}
		else if( ch >= ' ' )
		{
			setChar( ch, getCursorX(), getCursorY() );
			advanceCursor();
		}
	}
	
	public String getString()
	{
		if( inputStreamEnabled )
			return null;
		inputQueue.clear();
		StringBuilder sb = new StringBuilder();
		for( ;; )
		{
			int ch = -1;
			while( inputQueue.isEmpty() )
			{
				try
				{
					Thread.sleep( 10 );
				}
				catch( InterruptedException ex )
				{
					throw new RuntimeException( ex );
				}
			}
			ch = inputQueue.poll();
			if( ch < 0 || ch == '\n' )
			{
				return sb.toString();
			}
			else if( ch == '\b' )
			{
				if( sb.length() > 0 )
				{
					sb.deleteCharAt( sb.length() - 1 );
					int x = getCursorX();
					int y = getCursorY();
					x--;
					if( x < 0 )
					{
						x = screenWidth - 1;
						y--;
						if( y < 0 )
						{
							x = 0;
							y = 0;
						}
					}
					setChar( x, y, ' ' );
					setCursor( x, y );
					// putChar( ' ' );
					// setCursor( x, y );
				}
			}
			else
			{
				sb.append( (char)ch );
			}
		}
	}
	
	public void putString( String s )
	{
		for( char ch : s.toCharArray() )
			putChar( ch );
		// FIXME We could suppress setChar()s and semicooked screen updates
		// until all the chars are written to the raw screen, if we wanted to
		// optimize performance a bit.
		// Maybe???
	}
}
