Merge commit '77d3a60869e7a780c6ae069e51530e1eacece5e2'
[fanfix.git] / src / jexer / backend / ECMA48Terminal.java
index 56c2c7c348943479a46c85d449d11932a4ec66a3..429e698d733177cd59f27095088afd71b4b4ec1f 100644 (file)
@@ -3,7 +3,7 @@
  *
  * The MIT License (MIT)
  *
- * Copyright (C) 2017 Kevin Lamonte
+ * Copyright (C) 2019 Kevin Lamonte
  *
  * Permission is hereby granted, free of charge, to any person obtaining a
  * copy of this software and associated documentation files (the "Software"),
  */
 package jexer.backend;
 
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
 import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
 import java.io.FileDescriptor;
 import java.io.FileInputStream;
 import java.io.InputStream;
@@ -40,24 +45,60 @@ import java.io.PrintWriter;
 import java.io.Reader;
 import java.io.UnsupportedEncodingException;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
-import java.util.LinkedList;
+import javax.imageio.ImageIO;
 
 import jexer.bits.Cell;
 import jexer.bits.CellAttributes;
 import jexer.bits.Color;
+import jexer.bits.StringUtils;
+import jexer.event.TCommandEvent;
 import jexer.event.TInputEvent;
 import jexer.event.TKeypressEvent;
 import jexer.event.TMouseEvent;
 import jexer.event.TResizeEvent;
+import static jexer.TCommand.*;
 import static jexer.TKeypress.*;
 
 /**
  * This class reads keystrokes and mouse events and emits output to ANSI
  * X3.64 / ECMA-48 type terminals e.g. xterm, linux, vt100, ansi.sys, etc.
  */
-public final class ECMA48Terminal extends LogicalScreen
-                                  implements TerminalReader, Runnable {
+public class ECMA48Terminal extends LogicalScreen
+                            implements TerminalReader, Runnable {
+
+    // ------------------------------------------------------------------------
+    // Constants --------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * States in the input parser.
+     */
+    private enum ParseState {
+        GROUND,
+        ESCAPE,
+        ESCAPE_INTERMEDIATE,
+        CSI_ENTRY,
+        CSI_PARAM,
+        MOUSE,
+        MOUSE_SGR,
+    }
+
+    /**
+     * Available Jexer images support.
+     */
+    private enum JexerImageOption {
+        DISABLED,
+        JPG,
+        PNG,
+        RGB,
+    }
+
+    // ------------------------------------------------------------------------
+    // Variables --------------------------------------------------------------
+    // ------------------------------------------------------------------------
 
     /**
      * Emit debugging to stderr.
@@ -65,8 +106,9 @@ public final class ECMA48Terminal extends LogicalScreen
     private boolean debugToStderr = false;
 
     /**
-     * If true, emit T.416-style RGB colors.  This is a) expensive in
-     * bandwidth, and b) potentially terrible looking for non-xterms.
+     * If true, emit T.416-style RGB colors for normal system colors.  This
+     * is a) expensive in bandwidth, and b) potentially terrible looking for
+     * non-xterms.
      */
     private static boolean doRgbColor = false;
 
@@ -75,15 +117,6 @@ public final class ECMA48Terminal extends LogicalScreen
      */
     private SessionInfo sessionInfo;
 
-    /**
-     * Getter for sessionInfo.
-     *
-     * @return the SessionInfo
-     */
-    public SessionInfo getSessionInfo() {
-        return sessionInfo;
-    }
-
     /**
      * The event queue, filled up by a thread reading on input.
      */
@@ -103,20 +136,7 @@ public final class ECMA48Terminal extends LogicalScreen
      * Parameters being collected.  E.g. if the string is \033[1;3m, then
      * params[0] will be 1 and params[1] will be 3.
      */
-    private ArrayList<String> params;
-
-    /**
-     * States in the input parser.
-     */
-    private enum ParseState {
-        GROUND,
-        ESCAPE,
-        ESCAPE_INTERMEDIATE,
-        CSI_ENTRY,
-        CSI_PARAM,
-        MOUSE,
-        MOUSE_SGR,
-    }
+    private List<String> params;
 
     /**
      * Current parsing state.
@@ -162,10 +182,78 @@ public final class ECMA48Terminal extends LogicalScreen
      */
     private TResizeEvent windowResize = null;
 
+    /**
+     * If true, emit wide-char (CJK/Emoji) characters as sixel images.
+     */
+    private boolean wideCharImages = true;
+
+    /**
+     * Window width in pixels.  Used for sixel support.
+     */
+    private int widthPixels = 640;
+
+    /**
+     * Window height in pixels.  Used for sixel support.
+     */
+    private int heightPixels = 400;
+
+    /**
+     * If true, emit image data via sixel.
+     */
+    private boolean sixel = true;
+
+    /**
+     * If true, use a single shared palette for sixel.
+     */
+    private boolean sixelSharedPalette = true;
+
+    /**
+     * The sixel palette handler.
+     */
+    private SixelPalette palette = null;
+
+    /**
+     * The sixel post-rendered string cache.
+     */
+    private ImageCache sixelCache = null;
+
+    /**
+     * Number of colors in the sixel palette.  Xterm 335 defines the max as
+     * 1024.  Valid values are: 2 (black and white), 256, 512, 1024, and
+     * 2048.
+     */
+    private int sixelPaletteSize = 1024;
+
+    /**
+     * If true, emit image data via iTerm2 image protocol.
+     */
+    private boolean iterm2Images = false;
+
+    /**
+     * The iTerm2 post-rendered string cache.
+     */
+    private ImageCache iterm2Cache = null;
+
+    /**
+     * If not DISABLED, emit image data via Jexer image protocol if the
+     * terminal supports it.
+     */
+    private JexerImageOption jexerImageOption = JexerImageOption.JPG;
+
+    /**
+     * The Jexer post-rendered string cache.
+     */
+    private ImageCache jexerCache = null;
+
     /**
      * If true, then we changed System.in and need to change it back.
      */
-    private boolean setRawMode;
+    private boolean setRawMode = false;
+
+    /**
+     * If true, '?' was seen in terminal response.
+     */
+    private boolean decPrivateModeFlag = false;
 
     /**
      * The terminal's input.  If an InputStream is not specified in the
@@ -194,130 +282,857 @@ public final class ECMA48Terminal extends LogicalScreen
      */
     private Object listener;
 
-    /**
-     * Set listener to a different Object.
-     *
-     * @param listener the new listening object that run() wakes up on new
-     * input
-     */
-    public void setListener(final Object listener) {
-        this.listener = listener;
-    }
+    // Colors to map DOS colors to AWT colors.
+    private static java.awt.Color MYBLACK;
+    private static java.awt.Color MYRED;
+    private static java.awt.Color MYGREEN;
+    private static java.awt.Color MYYELLOW;
+    private static java.awt.Color MYBLUE;
+    private static java.awt.Color MYMAGENTA;
+    private static java.awt.Color MYCYAN;
+    private static java.awt.Color MYWHITE;
+    private static java.awt.Color MYBOLD_BLACK;
+    private static java.awt.Color MYBOLD_RED;
+    private static java.awt.Color MYBOLD_GREEN;
+    private static java.awt.Color MYBOLD_YELLOW;
+    private static java.awt.Color MYBOLD_BLUE;
+    private static java.awt.Color MYBOLD_MAGENTA;
+    private static java.awt.Color MYBOLD_CYAN;
+    private static java.awt.Color MYBOLD_WHITE;
 
     /**
-     * Get the output writer.
-     *
-     * @return the Writer
+     * SixelPalette is used to manage the conversion of images between 24-bit
+     * RGB color and a palette of sixelPaletteSize colors.
      */
-    public PrintWriter getOutput() {
-        return output;
-    }
+    private class SixelPalette {
+
+        /**
+         * Color palette for sixel output, sorted low to high.
+         */
+        private List<Integer> rgbColors = new ArrayList<Integer>();
+
+        /**
+         * Map of color palette index for sixel output, from the order it was
+         * generated by makePalette() to rgbColors.
+         */
+        private int [] rgbSortedIndex = new int[sixelPaletteSize];
+
+        /**
+         * The color palette, organized by hue, saturation, and luminance.
+         * This is used for a fast color match.
+         */
+        private ArrayList<ArrayList<ArrayList<ColorIdx>>> hslColors;
+
+        /**
+         * Number of bits for hue.
+         */
+        private int hueBits = -1;
+
+        /**
+         * Number of bits for saturation.
+         */
+        private int satBits = -1;
+
+        /**
+         * Number of bits for luminance.
+         */
+        private int lumBits = -1;
+
+        /**
+         * Step size for hue bins.
+         */
+        private int hueStep = -1;
+
+        /**
+         * Step size for saturation bins.
+         */
+        private int satStep = -1;
+
+        /**
+         * Cached RGB to HSL result.
+         */
+        private int hsl[] = new int[3];
+
+        /**
+         * ColorIdx records a RGB color and its palette index.
+         */
+        private class ColorIdx {
+            /**
+             * The 24-bit RGB color.
+             */
+            public int color;
+
+            /**
+             * The palette index for this color.
+             */
+            public int index;
+
+            /**
+             * Public constructor.
+             *
+             * @param color the 24-bit RGB color
+             * @param index the palette index for this color
+             */
+            public ColorIdx(final int color, final int index) {
+                this.color = color;
+                this.index = index;
+            }
+        }
 
-    /**
-     * Check if there are events in the queue.
-     *
-     * @return if true, getEvents() has something to return to the backend
-     */
-    public boolean hasEvents() {
-        synchronized (eventQueue) {
-            return (eventQueue.size() > 0);
+        /**
+         * Public constructor.
+         */
+        public SixelPalette() {
+            makePalette();
         }
-    }
 
-    /**
-     * Call 'stty' to set cooked mode.
-     *
-     * <p>Actually executes '/bin/sh -c stty sane cooked &lt; /dev/tty'
-     */
-    private void sttyCooked() {
-        doStty(false);
-    }
+        /**
+         * Find the nearest match for a color in the palette.
+         *
+         * @param color the RGB color
+         * @return the index in rgbColors that is closest to color
+         */
+        public int matchColor(final int color) {
+
+            assert (color >= 0);
+
+            /*
+             * matchColor() is a critical performance bottleneck.  To make it
+             * decent, we do the following:
+             *
+             *   1. Find the nearest two hues that bracket this color.
+             *
+             *   2. Find the nearest two saturations that bracket this color.
+             *
+             *   3. Iterate within these four bands of luminance values,
+             *      returning the closest color by Euclidean distance.
+             *
+             * This strategy reduces the search space by about 97%.
+             */
+            int red   = (color >>> 16) & 0xFF;
+            int green = (color >>>  8) & 0xFF;
+            int blue  =  color         & 0xFF;
+
+            if (sixelPaletteSize == 2) {
+                if (((red * red) + (green * green) + (blue * blue)) < 35568) {
+                    // Black
+                    return 0;
+                }
+                // White
+                return 1;
+            }
 
-    /**
-     * Call 'stty' to set raw mode.
-     *
-     * <p>Actually executes '/bin/sh -c stty -ignbrk -brkint -parmrk -istrip
-     * -inlcr -igncr -icrnl -ixon -opost -echo -echonl -icanon -isig -iexten
-     * -parenb cs8 min 1 &lt; /dev/tty'
-     */
-    private void sttyRaw() {
-        doStty(true);
+
+            rgbToHsl(red, green, blue, hsl);
+            int hue = hsl[0];
+            int sat = hsl[1];
+            int lum = hsl[2];
+            // System.err.printf("%d %d %d\n", hue, sat, lum);
+
+            double diff = Double.MAX_VALUE;
+            int idx = -1;
+
+            int hue1 = hue / (360/hueStep);
+            int hue2 = hue1 + 1;
+            if (hue1 >= hslColors.size() - 1) {
+                // Bracket pure red from above.
+                hue1 = hslColors.size() - 1;
+                hue2 = 0;
+            } else if (hue1 == 0) {
+                // Bracket pure red from below.
+                hue2 = hslColors.size() - 1;
+            }
+
+            for (int hI = hue1; hI != -1;) {
+                ArrayList<ArrayList<ColorIdx>> sats = hslColors.get(hI);
+                if (hI == hue1) {
+                    hI = hue2;
+                } else if (hI == hue2) {
+                    hI = -1;
+                }
+
+                int sMin = (sat / satStep) - 1;
+                int sMax = sMin + 1;
+                if (sMin < 0) {
+                    sMin = 0;
+                    sMax = 1;
+                } else if (sMin == sats.size() - 1) {
+                    sMax = sMin;
+                    sMin--;
+                }
+                assert (sMin >= 0);
+                assert (sMax - sMin == 1);
+
+                // int sMin = 0;
+                // int sMax = sats.size() - 1;
+
+                for (int sI = sMin; sI <= sMax; sI++) {
+                    ArrayList<ColorIdx> lums = sats.get(sI);
+
+                    // True 3D colorspace match for the remaining values
+                    for (ColorIdx c: lums) {
+                        int rgbColor = c.color;
+                        double newDiff = 0;
+                        int red2   = (rgbColor >>> 16) & 0xFF;
+                        int green2 = (rgbColor >>>  8) & 0xFF;
+                        int blue2  =  rgbColor         & 0xFF;
+                        newDiff += Math.pow(red2 - red, 2);
+                        newDiff += Math.pow(green2 - green, 2);
+                        newDiff += Math.pow(blue2 - blue, 2);
+                        if (newDiff < diff) {
+                            idx = rgbSortedIndex[c.index];
+                            diff = newDiff;
+                        }
+                    }
+                }
+            }
+
+            if (((red * red) + (green * green) + (blue * blue)) < diff) {
+                // Black is a closer match.
+                idx = 0;
+            } else if ((((255 - red) * (255 - red)) +
+                    ((255 - green) * (255 - green)) +
+                    ((255 - blue) * (255 - blue))) < diff) {
+
+                // White is a closer match.
+                idx = sixelPaletteSize - 1;
+            }
+            assert (idx != -1);
+            return idx;
+        }
+
+        /**
+         * Clamp an int value to [0, 255].
+         *
+         * @param x the int value
+         * @return an int between 0 and 255.
+         */
+        private int clamp(final int x) {
+            if (x < 0) {
+                return 0;
+            }
+            if (x > 255) {
+                return 255;
+            }
+            return x;
+        }
+
+        /**
+         * Dither an image to a sixelPaletteSize palette.  The dithered
+         * image cells will contain indexes into the palette.
+         *
+         * @param image the image to dither
+         * @return the dithered image.  Every pixel is an index into the
+         * palette.
+         */
+        public BufferedImage ditherImage(final BufferedImage image) {
+
+            BufferedImage ditheredImage = new BufferedImage(image.getWidth(),
+                image.getHeight(), BufferedImage.TYPE_INT_ARGB);
+
+            int [] rgbArray = image.getRGB(0, 0, image.getWidth(),
+                image.getHeight(), null, 0, image.getWidth());
+            ditheredImage.setRGB(0, 0, image.getWidth(), image.getHeight(),
+                rgbArray, 0, image.getWidth());
+
+            for (int imageY = 0; imageY < image.getHeight(); imageY++) {
+                for (int imageX = 0; imageX < image.getWidth(); imageX++) {
+                    int oldPixel = ditheredImage.getRGB(imageX,
+                        imageY) & 0xFFFFFF;
+                    int colorIdx = matchColor(oldPixel);
+                    assert (colorIdx >= 0);
+                    assert (colorIdx < sixelPaletteSize);
+                    int newPixel = rgbColors.get(colorIdx);
+                    ditheredImage.setRGB(imageX, imageY, colorIdx);
+
+                    int oldRed   = (oldPixel >>> 16) & 0xFF;
+                    int oldGreen = (oldPixel >>>  8) & 0xFF;
+                    int oldBlue  =  oldPixel         & 0xFF;
+
+                    int newRed   = (newPixel >>> 16) & 0xFF;
+                    int newGreen = (newPixel >>>  8) & 0xFF;
+                    int newBlue  =  newPixel         & 0xFF;
+
+                    int redError   = (oldRed - newRed) / 16;
+                    int greenError = (oldGreen - newGreen) / 16;
+                    int blueError  = (oldBlue - newBlue) / 16;
+
+                    int red, green, blue;
+                    if (imageX < image.getWidth() - 1) {
+                        int pXpY  = ditheredImage.getRGB(imageX + 1, imageY);
+                        red   = ((pXpY >>> 16) & 0xFF) + (7 * redError);
+                        green = ((pXpY >>>  8) & 0xFF) + (7 * greenError);
+                        blue  = ( pXpY         & 0xFF) + (7 * blueError);
+                        red = clamp(red);
+                        green = clamp(green);
+                        blue = clamp(blue);
+                        pXpY = ((red & 0xFF) << 16);
+                        pXpY |= ((green & 0xFF) << 8) | (blue & 0xFF);
+                        ditheredImage.setRGB(imageX + 1, imageY, pXpY);
+
+                        if (imageY < image.getHeight() - 1) {
+                            int pXpYp = ditheredImage.getRGB(imageX + 1,
+                                imageY + 1);
+                            red   = ((pXpYp >>> 16) & 0xFF) + redError;
+                            green = ((pXpYp >>>  8) & 0xFF) + greenError;
+                            blue  = ( pXpYp         & 0xFF) + blueError;
+                            red = clamp(red);
+                            green = clamp(green);
+                            blue = clamp(blue);
+                            pXpYp = ((red & 0xFF) << 16);
+                            pXpYp |= ((green & 0xFF) << 8) | (blue & 0xFF);
+                            ditheredImage.setRGB(imageX + 1, imageY + 1, pXpYp);
+                        }
+                    } else if (imageY < image.getHeight() - 1) {
+                        int pXmYp = ditheredImage.getRGB(imageX - 1,
+                            imageY + 1);
+                        int pXYp  = ditheredImage.getRGB(imageX,
+                            imageY + 1);
+
+                        red   = ((pXmYp >>> 16) & 0xFF) + (3 * redError);
+                        green = ((pXmYp >>>  8) & 0xFF) + (3 * greenError);
+                        blue  = ( pXmYp         & 0xFF) + (3 * blueError);
+                        red = clamp(red);
+                        green = clamp(green);
+                        blue = clamp(blue);
+                        pXmYp = ((red & 0xFF) << 16);
+                        pXmYp |= ((green & 0xFF) << 8) | (blue & 0xFF);
+                        ditheredImage.setRGB(imageX - 1, imageY + 1, pXmYp);
+
+                        red   = ((pXYp >>> 16) & 0xFF) + (5 * redError);
+                        green = ((pXYp >>>  8) & 0xFF) + (5 * greenError);
+                        blue  = ( pXYp         & 0xFF) + (5 * blueError);
+                        red = clamp(red);
+                        green = clamp(green);
+                        blue = clamp(blue);
+                        pXYp = ((red & 0xFF) << 16);
+                        pXYp |= ((green & 0xFF) << 8) | (blue & 0xFF);
+                        ditheredImage.setRGB(imageX,     imageY + 1, pXYp);
+                    }
+                } // for (int imageY = 0; imageY < image.getHeight(); imageY++)
+            } // for (int imageX = 0; imageX < image.getWidth(); imageX++)
+
+            return ditheredImage;
+        }
+
+        /**
+         * Convert an RGB color to HSL.
+         *
+         * @param red red color, between 0 and 255
+         * @param green green color, between 0 and 255
+         * @param blue blue color, between 0 and 255
+         * @param hsl the hsl color as [hue, saturation, luminance]
+         */
+        private void rgbToHsl(final int red, final int green,
+            final int blue, final int [] hsl) {
+
+            assert ((red >= 0) && (red <= 255));
+            assert ((green >= 0) && (green <= 255));
+            assert ((blue >= 0) && (blue <= 255));
+
+            double R = red / 255.0;
+            double G = green / 255.0;
+            double B = blue / 255.0;
+            boolean Rmax = false;
+            boolean Gmax = false;
+            boolean Bmax = false;
+            double min = (R < G ? R : G);
+            min = (min < B ? min : B);
+            double max = 0;
+            if ((R >= G) && (R >= B)) {
+                max = R;
+                Rmax = true;
+            } else if ((G >= R) && (G >= B)) {
+                max = G;
+                Gmax = true;
+            } else if ((B >= G) && (B >= R)) {
+                max = B;
+                Bmax = true;
+            }
+
+            double L = (min + max) / 2.0;
+            double H = 0.0;
+            double S = 0.0;
+            if (min != max) {
+                if (L < 0.5) {
+                    S = (max - min) / (max + min);
+                } else {
+                    S = (max - min) / (2.0 - max - min);
+                }
+            }
+            if (Rmax) {
+                assert (Gmax == false);
+                assert (Bmax == false);
+                H = (G - B) / (max - min);
+            } else if (Gmax) {
+                assert (Rmax == false);
+                assert (Bmax == false);
+                H = 2.0 + (B - R) / (max - min);
+            } else if (Bmax) {
+                assert (Rmax == false);
+                assert (Gmax == false);
+                H = 4.0 + (R - G) / (max - min);
+            }
+            if (H < 0.0) {
+                H += 6.0;
+            }
+            hsl[0] = (int) (H * 60.0);
+            hsl[1] = (int) (S * 100.0);
+            hsl[2] = (int) (L * 100.0);
+
+            assert ((hsl[0] >= 0) && (hsl[0] <= 360));
+            assert ((hsl[1] >= 0) && (hsl[1] <= 100));
+            assert ((hsl[2] >= 0) && (hsl[2] <= 100));
+        }
+
+        /**
+         * Convert a HSL color to RGB.
+         *
+         * @param hue hue, between 0 and 359
+         * @param sat saturation, between 0 and 100
+         * @param lum luminance, between 0 and 100
+         * @return the rgb color as 0x00RRGGBB
+         */
+        private int hslToRgb(final int hue, final int sat, final int lum) {
+            assert ((hue >= 0) && (hue <= 360));
+            assert ((sat >= 0) && (sat <= 100));
+            assert ((lum >= 0) && (lum <= 100));
+
+            double S = sat / 100.0;
+            double L = lum / 100.0;
+            double C = (1.0 - Math.abs((2.0 * L) - 1.0)) * S;
+            double Hp = hue / 60.0;
+            double X = C * (1.0 - Math.abs((Hp % 2) - 1.0));
+            double Rp = 0.0;
+            double Gp = 0.0;
+            double Bp = 0.0;
+            if (Hp <= 1.0) {
+                Rp = C;
+                Gp = X;
+            } else if (Hp <= 2.0) {
+                Rp = X;
+                Gp = C;
+            } else if (Hp <= 3.0) {
+                Gp = C;
+                Bp = X;
+            } else if (Hp <= 4.0) {
+                Gp = X;
+                Bp = C;
+            } else if (Hp <= 5.0) {
+                Rp = X;
+                Bp = C;
+            } else if (Hp <= 6.0) {
+                Rp = C;
+                Bp = X;
+            }
+            double m = L - (C / 2.0);
+            int red   = ((int) ((Rp + m) * 255.0)) << 16;
+            int green = ((int) ((Gp + m) * 255.0)) << 8;
+            int blue  =  (int) ((Bp + m) * 255.0);
+
+            return (red | green | blue);
+        }
+
+        /**
+         * Create the sixel palette.
+         */
+        private void makePalette() {
+            // Generate the sixel palette.  Because we have no idea at this
+            // layer which image(s) will be shown, we have to use a common
+            // palette with sixelPaletteSize colors for everything, and
+            // map the BufferedImage colors to their nearest neighbor in RGB
+            // space.
+
+            if (sixelPaletteSize == 2) {
+                rgbColors.add(0);
+                rgbColors.add(0xFFFFFF);
+                rgbSortedIndex[0] = 0;
+                rgbSortedIndex[1] = 1;
+                return;
+            }
+
+            // We build a palette using the Hue-Saturation-Luminence model,
+            // with 5+ bits for Hue, 2+ bits for Saturation, and 1+ bit for
+            // Luminance.  We convert these colors to 24-bit RGB, sort them
+            // ascending, and steal the first index for pure black and the
+            // last for pure white.  The 8-bit final palette favors bright
+            // colors, somewhere between pastel and classic television
+            // technicolor.  9- and 10-bit palettes are more uniform.
+
+            // Default at 256 colors.
+            hueBits = 5;
+            satBits = 2;
+            lumBits = 1;
+
+            assert (sixelPaletteSize >= 256);
+            assert ((sixelPaletteSize == 256)
+                || (sixelPaletteSize == 512)
+                || (sixelPaletteSize == 1024)
+                || (sixelPaletteSize == 2048));
+
+            switch (sixelPaletteSize) {
+            case 512:
+                hueBits = 5;
+                satBits = 2;
+                lumBits = 2;
+                break;
+            case 1024:
+                hueBits = 5;
+                satBits = 2;
+                lumBits = 3;
+                break;
+            case 2048:
+                hueBits = 5;
+                satBits = 3;
+                lumBits = 3;
+                break;
+            }
+            hueStep = (int) (Math.pow(2, hueBits));
+            satStep = (int) (100 / Math.pow(2, satBits));
+            // 1 bit for luminance: 40 and 70.
+            int lumBegin = 40;
+            int lumStep = 30;
+            switch (lumBits) {
+            case 2:
+                // 2 bits: 20, 40, 60, 80
+                lumBegin = 20;
+                lumStep = 20;
+                break;
+            case 3:
+                // 3 bits: 8, 20, 32, 44, 56, 68, 80, 92
+                lumBegin = 8;
+                lumStep = 12;
+                break;
+            }
+
+            // System.err.printf("<html><body>\n");
+            // Hue is evenly spaced around the wheel.
+            hslColors = new ArrayList<ArrayList<ArrayList<ColorIdx>>>();
+
+            final boolean DEBUG = false;
+            ArrayList<Integer> rawRgbList = new ArrayList<Integer>();
+
+            for (int hue = 0; hue < (360 - (360 % hueStep));
+                 hue += (360/hueStep)) {
+
+                ArrayList<ArrayList<ColorIdx>> satList = null;
+                satList = new ArrayList<ArrayList<ColorIdx>>();
+                hslColors.add(satList);
+
+                // Saturation is linearly spaced between pastel and pure.
+                for (int sat = satStep; sat <= 100; sat += satStep) {
+
+                    ArrayList<ColorIdx> lumList = new ArrayList<ColorIdx>();
+                    satList.add(lumList);
+
+                    // Luminance brackets the pure color, but leaning toward
+                    // lighter.
+                    for (int lum = lumBegin; lum < 100; lum += lumStep) {
+                        /*
+                        System.err.printf("<font style = \"color:");
+                        System.err.printf("hsl(%d, %d%%, %d%%)",
+                            hue, sat, lum);
+                        System.err.printf(";\">=</font>\n");
+                        */
+                        int rgbColor = hslToRgb(hue, sat, lum);
+                        rgbColors.add(rgbColor);
+                        ColorIdx colorIdx = new ColorIdx(rgbColor,
+                            rgbColors.size() - 1);
+                        lumList.add(colorIdx);
+
+                        rawRgbList.add(rgbColor);
+                        if (DEBUG) {
+                            int red   = (rgbColor >>> 16) & 0xFF;
+                            int green = (rgbColor >>>  8) & 0xFF;
+                            int blue  =  rgbColor         & 0xFF;
+                            int [] backToHsl = new int[3];
+                            rgbToHsl(red, green, blue, backToHsl);
+                            System.err.printf("%d [%d] %d [%d] %d [%d]\n",
+                                hue, backToHsl[0], sat, backToHsl[1],
+                                lum, backToHsl[2]);
+                        }
+                    }
+                }
+            }
+            // System.err.printf("\n</body></html>\n");
+
+            assert (rgbColors.size() == sixelPaletteSize);
+
+            /*
+             * We need to sort rgbColors, so that toSixel() can know where
+             * BLACK and WHITE are in it.  But we also need to be able to
+             * find the sorted values using the old unsorted indexes.  So we
+             * will sort it, put all the indexes into a HashMap, and then
+             * build rgbSortedIndex[].
+             */
+            Collections.sort(rgbColors);
+            HashMap<Integer, Integer> rgbColorIndices = null;
+            rgbColorIndices = new HashMap<Integer, Integer>();
+            for (int i = 0; i < sixelPaletteSize; i++) {
+                rgbColorIndices.put(rgbColors.get(i), i);
+            }
+            for (int i = 0; i < sixelPaletteSize; i++) {
+                int rawColor = rawRgbList.get(i);
+                rgbSortedIndex[i] = rgbColorIndices.get(rawColor);
+            }
+            if (DEBUG) {
+                for (int i = 0; i < sixelPaletteSize; i++) {
+                    assert (rawRgbList != null);
+                    int idx = rgbSortedIndex[i];
+                    int rgbColor = rgbColors.get(idx);
+                    if ((idx != 0) && (idx != sixelPaletteSize - 1)) {
+                        /*
+                        System.err.printf("%d %06x --> %d %06x\n",
+                            i, rawRgbList.get(i), idx, rgbColors.get(idx));
+                        */
+                        assert (rgbColor == rawRgbList.get(i));
+                    }
+                }
+            }
+
+            // Set the dimmest color as true black, and the brightest as true
+            // white.
+            rgbColors.set(0, 0);
+            rgbColors.set(sixelPaletteSize - 1, 0xFFFFFF);
+
+            /*
+            System.err.printf("<html><body>\n");
+            for (Integer rgb: rgbColors) {
+                System.err.printf("<font style = \"color:");
+                System.err.printf("#%06x", rgb);
+                System.err.printf(";\">=</font>\n");
+            }
+            System.err.printf("\n</body></html>\n");
+            */
+
+        }
+
+        /**
+         * Emit the sixel palette.
+         *
+         * @param sb the StringBuilder to append to
+         * @param used array of booleans set to true for each color actually
+         * used in this cell, or null to emit the entire palette
+         * @return the string to emit to an ANSI / ECMA-style terminal
+         */
+        public String emitPalette(final StringBuilder sb,
+            final boolean [] used) {
+
+            for (int i = 0; i < sixelPaletteSize; i++) {
+                if (((used != null) && (used[i] == true)) || (used == null)) {
+                    int rgbColor = rgbColors.get(i);
+                    sb.append(String.format("#%d;2;%d;%d;%d", i,
+                            ((rgbColor >>> 16) & 0xFF) * 100 / 255,
+                            ((rgbColor >>>  8) & 0xFF) * 100 / 255,
+                            ( rgbColor         & 0xFF) * 100 / 255));
+                }
+            }
+            return sb.toString();
+        }
     }
 
     /**
-     * Call 'stty' to set raw or cooked mode.
-     *
-     * @param mode if true, set raw mode, otherwise set cooked mode
+     * ImageCache is a least-recently-used cache that hangs on to the
+     * post-rendered sixel or iTerm2 string for a particular set of cells.
      */
-    private void doStty(final boolean mode) {
-        String [] cmdRaw = {
-            "/bin/sh", "-c", "stty -ignbrk -brkint -parmrk -istrip -inlcr -igncr -icrnl -ixon -opost -echo -echonl -icanon -isig -iexten -parenb cs8 min 1 < /dev/tty"
-        };
-        String [] cmdCooked = {
-            "/bin/sh", "-c", "stty sane cooked < /dev/tty"
-        };
-        try {
-            Process process;
-            if (mode) {
-                process = Runtime.getRuntime().exec(cmdRaw);
-            } else {
-                process = Runtime.getRuntime().exec(cmdCooked);
+    private class ImageCache {
+
+        /**
+         * Maximum size of the cache.
+         */
+        private int maxSize = 100;
+
+        /**
+         * The entries stored in the cache.
+         */
+        private HashMap<String, CacheEntry> cache = null;
+
+        /**
+         * CacheEntry is one entry in the cache.
+         */
+        private class CacheEntry {
+            /**
+             * The cache key.
+             */
+            public String key;
+
+            /**
+             * The cache data.
+             */
+            public String data;
+
+            /**
+             * The last time this entry was used.
+             */
+            public long millis = 0;
+
+            /**
+             * Public constructor.
+             *
+             * @param key the cache entry key
+             * @param data the cache entry data
+             */
+            public CacheEntry(final String key, final String data) {
+                this.key = key;
+                this.data = data;
+                this.millis = System.currentTimeMillis();
             }
-            BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream(), "UTF-8"));
-            String line = in.readLine();
-            if ((line != null) && (line.length() > 0)) {
-                System.err.println("WEIRD?! Normal output from stty: " + line);
+        }
+
+        /**
+         * Public constructor.
+         *
+         * @param maxSize the maximum size of the cache
+         */
+        public ImageCache(final int maxSize) {
+            this.maxSize = maxSize;
+            cache = new HashMap<String, CacheEntry>();
+        }
+
+        /**
+         * Make a unique key for a list of cells.
+         *
+         * @param cells the cells
+         * @return the key
+         */
+        private String makeKey(final ArrayList<Cell> cells) {
+            StringBuilder sb = new StringBuilder();
+            for (Cell cell: cells) {
+                sb.append(cell.hashCode());
             }
-            while (true) {
-                BufferedReader err = new BufferedReader(new InputStreamReader(process.getErrorStream(), "UTF-8"));
-                line = err.readLine();
-                if ((line != null) && (line.length() > 0)) {
-                    System.err.println("Error output from stty: " + line);
-                }
-                try {
-                    process.waitFor();
-                    break;
-                } catch (InterruptedException e) {
-                    e.printStackTrace();
-                }
+            return sb.toString();
+        }
+
+        /**
+         * Get an entry from the cache.
+         *
+         * @param cells the list of cells that are the cache key
+         * @return the sixel string representing these cells, or null if this
+         * list of cells is not in the cache
+         */
+        public String get(final ArrayList<Cell> cells) {
+            CacheEntry entry = cache.get(makeKey(cells));
+            if (entry == null) {
+                return null;
             }
-            int rc = process.exitValue();
-            if (rc != 0) {
-                System.err.println("stty returned error code: " + rc);
+            entry.millis = System.currentTimeMillis();
+            return entry.data;
+        }
+
+        /**
+         * Put an entry into the cache.
+         *
+         * @param cells the list of cells that are the cache key
+         * @param data the sixel string representing these cells
+         */
+        public void put(final ArrayList<Cell> cells, final String data) {
+            String key = makeKey(cells);
+
+            // System.err.println("put() " + key + " size " + cache.size());
+
+            assert (!cache.containsKey(key));
+
+            assert (cache.size() <= maxSize);
+            if (cache.size() == maxSize) {
+                // Cache is at limit, evict oldest entry.
+                long oldestTime = Long.MAX_VALUE;
+                String keyToRemove = null;
+                for (CacheEntry entry: cache.values()) {
+                    if ((entry.millis < oldestTime) || (keyToRemove == null)) {
+                        keyToRemove = entry.key;
+                        oldestTime = entry.millis;
+                    }
+                }
+                /*
+                System.err.println("put() remove key = " + keyToRemove +
+                    " size " + cache.size());
+                 */
+                assert (keyToRemove != null);
+                cache.remove(keyToRemove);
+                /*
+                System.err.println("put() removed, size " + cache.size());
+                 */
             }
-        } catch (IOException e) {
-            e.printStackTrace();
+            assert (cache.size() <= maxSize);
+            CacheEntry entry = new CacheEntry(key, data);
+            assert (key.equals(entry.key));
+            cache.put(key, entry);
+            /*
+            System.err.println("put() added key " + key + " " +
+                " size " + cache.size());
+             */
         }
+
     }
 
+    // ------------------------------------------------------------------------
+    // Constructors -----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
     /**
-     * Constructor sets up state for getEvent().
+     * Constructor sets up state for getEvent().  If either windowWidth or
+     * windowHeight are less than 1, the terminal is not resized.
      *
      * @param listener the object this backend needs to wake up when new
      * input comes in
      * @param input an InputStream connected to the remote user, or null for
      * System.in.  If System.in is used, then on non-Windows systems it will
-     * be put in raw mode; shutdown() will (blindly!) put System.in in cooked
-     * mode.  input is always converted to a Reader with UTF-8 encoding.
+     * be put in raw mode; closeTerminal() will (blindly!) put System.in in
+     * cooked mode.  input is always converted to a Reader with UTF-8
+     * encoding.
      * @param output an OutputStream connected to the remote user, or null
      * for System.out.  output is always converted to a Writer with UTF-8
      * encoding.
+     * @param windowWidth the number of text columns to start with
+     * @param windowHeight the number of text rows to start with
      * @throws UnsupportedEncodingException if an exception is thrown when
      * creating the InputStreamReader
      */
     public ECMA48Terminal(final Object listener, final InputStream input,
-        final OutputStream output) throws UnsupportedEncodingException {
-
-        resetParser();
-        mouse1           = false;
-        mouse2           = false;
-        mouse3           = false;
-        stopReaderThread = false;
-        this.listener    = listener;
+        final OutputStream output, final int windowWidth,
+        final int windowHeight) throws UnsupportedEncodingException {
+
+        this(listener, input, output);
+
+        // Send dtterm/xterm sequences, which will probably not work because
+        // allowWindowOps is defaulted to false.
+        if ((windowWidth > 0) && (windowHeight > 0)) {
+            String resizeString = String.format("\033[8;%d;%dt", windowHeight,
+                windowWidth);
+            this.output.write(resizeString);
+            this.output.flush();
+        }
+    }
 
-        if (input == null) {
-            // inputStream = System.in;
-            inputStream = new FileInputStream(FileDescriptor.in);
+    /**
+     * Constructor sets up state for getEvent().
+     *
+     * @param listener the object this backend needs to wake up when new
+     * input comes in
+     * @param input an InputStream connected to the remote user, or null for
+     * System.in.  If System.in is used, then on non-Windows systems it will
+     * be put in raw mode; closeTerminal() will (blindly!) put System.in in
+     * cooked mode.  input is always converted to a Reader with UTF-8
+     * encoding.
+     * @param output an OutputStream connected to the remote user, or null
+     * for System.out.  output is always converted to a Writer with UTF-8
+     * encoding.
+     * @throws UnsupportedEncodingException if an exception is thrown when
+     * creating the InputStreamReader
+     */
+    public ECMA48Terminal(final Object listener, final InputStream input,
+        final OutputStream output) throws UnsupportedEncodingException {
+
+        resetParser();
+        mouse1           = false;
+        mouse2           = false;
+        mouse3           = false;
+        stopReaderThread = false;
+        this.listener    = listener;
+
+        if (input == null) {
+            // inputStream = System.in;
+            inputStream = new FileInputStream(FileDescriptor.in);
             sttyRaw();
             setRawMode = true;
         } else {
@@ -347,8 +1162,18 @@ public final class ECMA48Terminal extends LogicalScreen
                     "UTF-8"));
         }
 
+        // Request Device Attributes
+        this.output.printf("\033[c");
+
+        // Request xterm report window/cell dimensions in pixels
+        this.output.printf("%s", xtermReportPixelDimensions());
+
         // Enable mouse reporting and metaSendsEscape
         this.output.printf("%s%s", mouse(true), xtermMetaSendsEscape(true));
+
+        // Request xterm use the sixel settings we want
+        this.output.printf("%s", xtermSetSixelSettings());
+
         this.output.flush();
 
         // Query the screen size
@@ -360,17 +1185,10 @@ public final class ECMA48Terminal extends LogicalScreen
         windowResize = new TResizeEvent(TResizeEvent.Type.SCREEN,
             sessionInfo.getWindowWidth(), sessionInfo.getWindowHeight());
 
-        // Permit RGB colors only if externally requested
-        if (System.getProperty("jexer.ECMA48.rgbColor") != null) {
-            if (System.getProperty("jexer.ECMA48.rgbColor").equals("true")) {
-                doRgbColor = true;
-            } else {
-                doRgbColor = false;
-            }
-        }
+        reloadOptions();
 
         // Spin up the input reader
-        eventQueue = new LinkedList<TInputEvent>();
+        eventQueue = new ArrayList<TInputEvent>();
         readerThread = new Thread(this);
         readerThread.start();
 
@@ -437,8 +1255,18 @@ public final class ECMA48Terminal extends LogicalScreen
 
         this.output = writer;
 
+        // Request Device Attributes
+        this.output.printf("\033[c");
+
+        // Request xterm report window/cell dimensions in pixels
+        this.output.printf("%s", xtermReportPixelDimensions());
+
         // Enable mouse reporting and metaSendsEscape
         this.output.printf("%s%s", mouse(true), xtermMetaSendsEscape(true));
+
+        // Request xterm use the sixel settings we want
+        this.output.printf("%s", xtermSetSixelSettings());
+
         this.output.flush();
 
         // Query the screen size
@@ -450,17 +1278,10 @@ public final class ECMA48Terminal extends LogicalScreen
         windowResize = new TResizeEvent(TResizeEvent.Type.SCREEN,
             sessionInfo.getWindowWidth(), sessionInfo.getWindowHeight());
 
-        // Permit RGB colors only if externally requested
-        if (System.getProperty("jexer.ECMA48.rgbColor") != null) {
-            if (System.getProperty("jexer.ECMA48.rgbColor").equals("true")) {
-                doRgbColor = true;
-            } else {
-                doRgbColor = false;
-            }
-        }
+        reloadOptions();
 
         // Spin up the input reader
-        eventQueue = new LinkedList<TInputEvent>();
+        eventQueue = new ArrayList<TInputEvent>();
         readerThread = new Thread(this);
         readerThread.start();
 
@@ -486,24 +1307,112 @@ public final class ECMA48Terminal extends LogicalScreen
         this(listener, input, reader, writer, false);
     }
 
+    // ------------------------------------------------------------------------
+    // LogicalScreen ----------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Set the window title.
+     *
+     * @param title the new title
+     */
+    @Override
+    public void setTitle(final String title) {
+        output.write(getSetTitleString(title));
+        flush();
+    }
+
+    /**
+     * Push the logical screen to the physical device.
+     */
+    @Override
+    public void flushPhysical() {
+        StringBuilder sb = new StringBuilder();
+        if ((cursorVisible)
+            && (cursorY >= 0)
+            && (cursorX >= 0)
+            && (cursorY <= height - 1)
+            && (cursorX <= width - 1)
+        ) {
+            flushString(sb);
+            sb.append(cursor(true));
+            sb.append(gotoXY(cursorX, cursorY));
+        } else {
+            sb.append(cursor(false));
+            flushString(sb);
+        }
+        output.write(sb.toString());
+        flush();
+    }
+
+    /**
+     * Resize the physical screen to match the logical screen dimensions.
+     */
+    @Override
+    public void resizeToScreen() {
+        // Send dtterm/xterm sequences, which will probably not work because
+        // allowWindowOps is defaulted to false.
+        String resizeString = String.format("\033[8;%d;%dt", getHeight(),
+            getWidth());
+        this.output.write(resizeString);
+        this.output.flush();
+    }
+
+    // ------------------------------------------------------------------------
+    // TerminalReader ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Check if there are events in the queue.
+     *
+     * @return if true, getEvents() has something to return to the backend
+     */
+    public boolean hasEvents() {
+        synchronized (eventQueue) {
+            return (eventQueue.size() > 0);
+        }
+    }
+
+    /**
+     * Return any events in the IO queue.
+     *
+     * @param queue list to append new events to
+     */
+    public void getEvents(final List<TInputEvent> queue) {
+        synchronized (eventQueue) {
+            if (eventQueue.size() > 0) {
+                synchronized (queue) {
+                    queue.addAll(eventQueue);
+                }
+                eventQueue.clear();
+            }
+        }
+    }
+
     /**
      * Restore terminal to normal state.
      */
     public void closeTerminal() {
 
-        // System.err.println("=== shutdown() ==="); System.err.flush();
+        // System.err.println("=== closeTerminal() ==="); System.err.flush();
 
         // Tell the reader thread to stop looking at input
         stopReaderThread = true;
         try {
             readerThread.join();
         } catch (InterruptedException e) {
-            e.printStackTrace();
+            if (debugToStderr) {
+                e.printStackTrace();
+            }
         }
 
-        // Disable mouse reporting and show cursor
-        output.printf("%s%s%s", mouse(false), cursor(true), normal());
-        output.flush();
+        // Disable mouse reporting and show cursor.  Defensive null check
+        // here in case closeTerminal() is called twice.
+        if (output != null) {
+            output.printf("%s%s%s%s", mouse(false), cursor(true),
+                defaultColor(), xtermResetSixelSettings());
+            output.flush();
+        }
 
         if (setRawMode) {
             sttyCooked();
@@ -512,1024 +1421,2470 @@ public final class ECMA48Terminal extends LogicalScreen
         } else {
             // Shut down the streams, this should wake up the reader thread
             // and make it exit.
-            try {
-                if (input != null) {
+            if (input != null) {
+                try {
                     input.close();
-                    input = null;
-                }
-                if (output != null) {
-                    output.close();
-                    output = null;
+                } catch (IOException e) {
+                    // SQUASH
                 }
-            } catch (IOException e) {
-                e.printStackTrace();
+                input = null;
+            }
+            if (output != null) {
+                output.close();
+                output = null;
             }
         }
     }
 
     /**
-     * Flush output.
+     * Set listener to a different Object.
+     *
+     * @param listener the new listening object that run() wakes up on new
+     * input
      */
-    public void flush() {
-        output.flush();
+    public void setListener(final Object listener) {
+        this.listener = listener;
     }
 
     /**
-     * Perform a somewhat-optimal rendering of a line.
-     *
-     * @param y row coordinate.  0 is the top-most row.
-     * @param sb StringBuilder to write escape sequences to
-     * @param lastAttr cell attributes from the last call to flushLine
+     * Reload options from System properties.
      */
-    private void flushLine(final int y, final StringBuilder sb,
-        CellAttributes lastAttr) {
-
-        int lastX = -1;
-        int textEnd = 0;
-        for (int x = 0; x < width; x++) {
-            Cell lCell = logical[x][y];
-            if (!lCell.isBlank()) {
-                textEnd = x;
-            }
+    public void reloadOptions() {
+        // Permit RGB colors only if externally requested.
+        if (System.getProperty("jexer.ECMA48.rgbColor",
+                "false").equals("true")
+        ) {
+            doRgbColor = true;
+        } else {
+            doRgbColor = false;
         }
-        // Push textEnd to first column beyond the text area
-        textEnd++;
 
-        // DEBUG
-        // reallyCleared = true;
+        // Default to using images for full-width characters.
+        if (System.getProperty("jexer.ECMA48.wideCharImages",
+                "true").equals("true")) {
+            wideCharImages = true;
+        } else {
+            wideCharImages = false;
+        }
 
-        for (int x = 0; x < width; x++) {
-            Cell lCell = logical[x][y];
-            Cell pCell = physical[x][y];
+        // Pull the system properties for sixel output.
+        if (System.getProperty("jexer.ECMA48.sixel", "true").equals("true")) {
+            sixel = true;
+        } else {
+            sixel = false;
+        }
 
-            if (!lCell.equals(pCell) || reallyCleared) {
+        // Palette size
+        int paletteSize = 1024;
+        try {
+            paletteSize = Integer.parseInt(System.getProperty(
+                "jexer.ECMA48.sixelPaletteSize", "1024"));
+            switch (paletteSize) {
+            case 2:
+            case 256:
+            case 512:
+            case 1024:
+            case 2048:
+                sixelPaletteSize = paletteSize;
+                break;
+            default:
+                // Ignore value
+                break;
+            }
+        } catch (NumberFormatException e) {
+            // SQUASH
+        }
 
-                if (debugToStderr) {
-                    System.err.printf("\n--\n");
-                    System.err.printf(" Y: %d X: %d\n", y, x);
-                    System.err.printf("   lCell: %s\n", lCell);
-                    System.err.printf("   pCell: %s\n", pCell);
-                    System.err.printf("    ====    \n");
-                }
+        // Shared palette
+        if (System.getProperty("jexer.ECMA48.sixelSharedPalette",
+                "true").equals("false")) {
+            sixelSharedPalette = false;
+        } else {
+            sixelSharedPalette = true;
+        }
 
-                if (lastAttr == null) {
-                    lastAttr = new CellAttributes();
-                    sb.append(normal());
-                }
+        // Default to not supporting iTerm2 images.
+        if (System.getProperty("jexer.ECMA48.iTerm2Images",
+                "false").equals("true")) {
+            iterm2Images = true;
+        } else {
+            iterm2Images = false;
+        }
 
-                // Place the cell
-                if ((lastX != (x - 1)) || (lastX == -1)) {
-                    // Advancing at least one cell, or the first gotoXY
-                    sb.append(gotoXY(x, y));
-                }
+        // Default to using JPG Jexer images if terminal supports it.
+        String jexerImageStr = System.getProperty("jexer.ECMA48.jexerImages",
+            "jpg").toLowerCase();
+        if (jexerImageStr.equals("false")) {
+            jexerImageOption = JexerImageOption.DISABLED;
+        } else if (jexerImageStr.equals("jpg")) {
+            jexerImageOption = JexerImageOption.JPG;
+        } else if (jexerImageStr.equals("png")) {
+            jexerImageOption = JexerImageOption.PNG;
+        } else if (jexerImageStr.equals("rgb")) {
+            jexerImageOption = JexerImageOption.RGB;
+        }
 
-                assert (lastAttr != null);
+        // Set custom colors
+        setCustomSystemColors();
+    }
 
-                if ((x == textEnd) && (textEnd < width - 1)) {
-                    assert (lCell.isBlank());
+    // ------------------------------------------------------------------------
+    // Runnable ---------------------------------------------------------------
+    // ------------------------------------------------------------------------
 
-                    for (int i = x; i < width; i++) {
-                        assert (logical[i][y].isBlank());
-                        // Physical is always updated
-                        physical[i][y].reset();
-                    }
+    /**
+     * Read function runs on a separate thread.
+     */
+    public void run() {
+        boolean done = false;
+        // available() will often return > 1, so we need to read in chunks to
+        // stay caught up.
+        char [] readBuffer = new char[128];
+        List<TInputEvent> events = new ArrayList<TInputEvent>();
 
-                    // Clear remaining line
-                    sb.append(clearRemainingLine());
-                    lastAttr.reset();
-                    return;
-                }
+        while (!done && !stopReaderThread) {
+            try {
+                // We assume that if inputStream has bytes available, then
+                // input won't block on read().
+                int n = inputStream.available();
 
-                // Now emit only the modified attributes
-                if ((lCell.getForeColor() != lastAttr.getForeColor())
-                    && (lCell.getBackColor() != lastAttr.getBackColor())
-                    && (lCell.isBold() == lastAttr.isBold())
-                    && (lCell.isReverse() == lastAttr.isReverse())
-                    && (lCell.isUnderline() == lastAttr.isUnderline())
-                    && (lCell.isBlink() == lastAttr.isBlink())
-                ) {
-                    // Both colors changed, attributes the same
-                    sb.append(color(lCell.isBold(),
-                            lCell.getForeColor(), lCell.getBackColor()));
+                /*
+                System.err.printf("inputStream.available(): %d\n", n);
+                System.err.flush();
+                */
 
-                    if (debugToStderr) {
-                        System.err.printf("1 Change only fore/back colors\n");
+                if (n > 0) {
+                    if (readBuffer.length < n) {
+                        // The buffer wasn't big enough, make it huger
+                        readBuffer = new char[readBuffer.length * 2];
                     }
-                } else if ((lCell.getForeColor() != lastAttr.getForeColor())
-                    && (lCell.getBackColor() != lastAttr.getBackColor())
-                    && (lCell.isBold() != lastAttr.isBold())
-                    && (lCell.isReverse() != lastAttr.isReverse())
-                    && (lCell.isUnderline() != lastAttr.isUnderline())
-                    && (lCell.isBlink() != lastAttr.isBlink())
-                ) {
-                    // Everything is different
-                    sb.append(color(lCell.getForeColor(),
-                            lCell.getBackColor(),
-                            lCell.isBold(), lCell.isReverse(),
-                            lCell.isBlink(),
-                            lCell.isUnderline()));
 
-                    if (debugToStderr) {
-                        System.err.printf("2 Set all attributes\n");
-                    }
-                } else if ((lCell.getForeColor() != lastAttr.getForeColor())
-                    && (lCell.getBackColor() == lastAttr.getBackColor())
-                    && (lCell.isBold() == lastAttr.isBold())
-                    && (lCell.isReverse() == lastAttr.isReverse())
-                    && (lCell.isUnderline() == lastAttr.isUnderline())
-                    && (lCell.isBlink() == lastAttr.isBlink())
-                ) {
+                    // System.err.printf("BEFORE read()\n"); System.err.flush();
 
-                    // Attributes same, foreColor different
-                    sb.append(color(lCell.isBold(),
-                            lCell.getForeColor(), true));
+                    int rc = input.read(readBuffer, 0, readBuffer.length);
 
-                    if (debugToStderr) {
-                        System.err.printf("3 Change foreColor\n");
-                    }
-                } else if ((lCell.getForeColor() == lastAttr.getForeColor())
-                    && (lCell.getBackColor() != lastAttr.getBackColor())
-                    && (lCell.isBold() == lastAttr.isBold())
-                    && (lCell.isReverse() == lastAttr.isReverse())
-                    && (lCell.isUnderline() == lastAttr.isUnderline())
-                    && (lCell.isBlink() == lastAttr.isBlink())
-                ) {
-                    // Attributes same, backColor different
-                    sb.append(color(lCell.isBold(),
-                            lCell.getBackColor(), false));
-
-                    if (debugToStderr) {
-                        System.err.printf("4 Change backColor\n");
-                    }
-                } else if ((lCell.getForeColor() == lastAttr.getForeColor())
-                    && (lCell.getBackColor() == lastAttr.getBackColor())
-                    && (lCell.isBold() == lastAttr.isBold())
-                    && (lCell.isReverse() == lastAttr.isReverse())
-                    && (lCell.isUnderline() == lastAttr.isUnderline())
-                    && (lCell.isBlink() == lastAttr.isBlink())
-                ) {
-
-                    // All attributes the same, just print the char
-                    // NOP
+                    /*
+                    System.err.printf("AFTER read() %d\n", rc);
+                    System.err.flush();
+                    */
 
-                    if (debugToStderr) {
-                        System.err.printf("5 Only emit character\n");
+                    if (rc == -1) {
+                        // This is EOF
+                        done = true;
+                    } else {
+                        for (int i = 0; i < rc; i++) {
+                            int ch = readBuffer[i];
+                            processChar(events, (char)ch);
+                        }
+                        getIdleEvents(events);
+                        if (events.size() > 0) {
+                            // Add to the queue for the backend thread to
+                            // be able to obtain.
+                            synchronized (eventQueue) {
+                                eventQueue.addAll(events);
+                            }
+                            if (listener != null) {
+                                synchronized (listener) {
+                                    listener.notifyAll();
+                                }
+                            }
+                            events.clear();
+                        }
                     }
                 } else {
-                    // Just reset everything again
-                    sb.append(color(lCell.getForeColor(),
-                            lCell.getBackColor(),
-                            lCell.isBold(),
-                            lCell.isReverse(),
-                            lCell.isBlink(),
-                            lCell.isUnderline()));
-
-                    if (debugToStderr) {
-                        System.err.printf("6 Change all attributes\n");
+                    getIdleEvents(events);
+                    if (events.size() > 0) {
+                        synchronized (eventQueue) {
+                            eventQueue.addAll(events);
+                        }
+                        if (listener != null) {
+                            synchronized (listener) {
+                                listener.notifyAll();
+                            }
+                        }
+                        events.clear();
                     }
-                }
-                // Emit the character
-                sb.append(lCell.getChar());
 
-                // Save the last rendered cell
-                lastX = x;
-                lastAttr.setTo(lCell);
+                    if (output.checkError()) {
+                        // This is EOF.
+                        done = true;
+                    }
 
-                // Physical is always updated
-                physical[x][y].setTo(lCell);
+                    // Wait 20 millis for more data
+                    Thread.sleep(20);
+                }
+                // System.err.println("end while loop"); System.err.flush();
+            } catch (InterruptedException e) {
+                // SQUASH
+            } catch (IOException e) {
+                e.printStackTrace();
+                done = true;
+            }
+        } // while ((done == false) && (stopReaderThread == false))
 
-            } // if (!lCell.equals(pCell) || (reallyCleared == true))
+        // Pass an event up to TApplication to tell it this Backend is done.
+        synchronized (eventQueue) {
+            eventQueue.add(new TCommandEvent(cmBackendDisconnect));
+        }
+        if (listener != null) {
+            synchronized (listener) {
+                listener.notifyAll();
+            }
+        }
 
-        } // for (int x = 0; x < width; x++)
+        // System.err.println("*** run() exiting..."); System.err.flush();
     }
 
+    // ------------------------------------------------------------------------
+    // ECMA48Terminal ---------------------------------------------------------
+    // ------------------------------------------------------------------------
+
     /**
-     * Render the screen to a string that can be emitted to something that
-     * knows how to process ECMA-48/ANSI X3.64 escape sequences.
+     * Get the width of a character cell in pixels.
      *
-     * @return escape sequences string that provides the updates to the
-     * physical screen
+     * @return the width in pixels of a character cell
      */
-    private String flushString() {
-        CellAttributes attr = null;
-
-        StringBuilder sb = new StringBuilder();
-        if (reallyCleared) {
-            attr = new CellAttributes();
-            sb.append(clearAll());
-        }
-
-        for (int y = 0; y < height; y++) {
-            flushLine(y, sb, attr);
-        }
-
-        reallyCleared = false;
-
-        String result = sb.toString();
-        if (debugToStderr) {
-            System.err.printf("flushString(): %s\n", result);
+    public int getTextWidth() {
+        if (sessionInfo.getWindowWidth() > 0) {
+            return (widthPixels / sessionInfo.getWindowWidth());
         }
-        return result;
+        return 16;
     }
 
     /**
-     * Push the logical screen to the physical device.
+     * Get the height of a character cell in pixels.
+     *
+     * @return the height in pixels of a character cell
      */
-    @Override
-    public void flushPhysical() {
-        String result = flushString();
-        if ((cursorVisible)
-            && (cursorY >= 0)
-            && (cursorX >= 0)
-            && (cursorY <= height - 1)
-            && (cursorX <= width - 1)
-        ) {
-            result += cursor(true);
-            result += gotoXY(cursorX, cursorY);
-        } else {
-            result += cursor(false);
+    public int getTextHeight() {
+        if (sessionInfo.getWindowHeight() > 0) {
+            return (heightPixels / sessionInfo.getWindowHeight());
         }
-        output.write(result);
-        flush();
+        return 20;
     }
 
     /**
-     * Set the window title.
+     * Getter for sessionInfo.
      *
-     * @param title the new title
+     * @return the SessionInfo
      */
-    public void setTitle(final String title) {
-        output.write(getSetTitleString(title));
-        flush();
+    public SessionInfo getSessionInfo() {
+        return sessionInfo;
     }
 
     /**
-     * Reset keyboard/mouse input parser.
+     * Get the output writer.
+     *
+     * @return the Writer
      */
-    private void resetParser() {
-        state = ParseState.GROUND;
-        params = new ArrayList<String>();
-        params.clear();
-        params.add("");
+    public PrintWriter getOutput() {
+        return output;
     }
 
     /**
-     * Produce a control character or one of the special ones (ENTER, TAB,
-     * etc.).
+     * Call 'stty' to set cooked mode.
      *
-     * @param ch Unicode code point
-     * @param alt if true, set alt on the TKeypress
-     * @return one TKeypress event, either a control character (e.g. isKey ==
-     * false, ch == 'A', ctrl == true), or a special key (e.g. isKey == true,
-     * fnKey == ESC)
+     * <p>Actually executes '/bin/sh -c stty sane cooked &lt; /dev/tty'
      */
-    private TKeypressEvent controlChar(final char ch, final boolean alt) {
-        // System.err.printf("controlChar: %02x\n", ch);
-
-        switch (ch) {
-        case 0x0D:
-            // Carriage return --> ENTER
-            return new TKeypressEvent(kbEnter, alt, false, false);
-        case 0x0A:
-            // Linefeed --> ENTER
-            return new TKeypressEvent(kbEnter, alt, false, false);
-        case 0x1B:
-            // ESC
-            return new TKeypressEvent(kbEsc, alt, false, false);
-        case '\t':
-            // TAB
-            return new TKeypressEvent(kbTab, alt, false, false);
-        default:
-            // Make all other control characters come back as the alphabetic
-            // character with the ctrl field set.  So SOH would be 'A' +
-            // ctrl.
-            return new TKeypressEvent(false, 0, (char)(ch + 0x40),
-                alt, true, false);
-        }
+    private void sttyCooked() {
+        doStty(false);
     }
 
     /**
-     * Produce special key from CSI Pn ; Pm ; ... ~
+     * Call 'stty' to set raw mode.
      *
-     * @return one KEYPRESS event representing a special key
+     * <p>Actually executes '/bin/sh -c stty -ignbrk -brkint -parmrk -istrip
+     * -inlcr -igncr -icrnl -ixon -opost -echo -echonl -icanon -isig -iexten
+     * -parenb cs8 min 1 &lt; /dev/tty'
      */
-    private TInputEvent csiFnKey() {
-        int key = 0;
-        if (params.size() > 0) {
-            key = Integer.parseInt(params.get(0));
-        }
-        boolean alt = false;
-        boolean ctrl = false;
-        boolean shift = false;
-        if (params.size() > 1) {
-            shift = csiIsShift(params.get(1));
-            alt = csiIsAlt(params.get(1));
-            ctrl = csiIsCtrl(params.get(1));
-        }
-
-        switch (key) {
-        case 1:
-            return new TKeypressEvent(kbHome, alt, ctrl, shift);
-        case 2:
-            return new TKeypressEvent(kbIns, alt, ctrl, shift);
-        case 3:
-            return new TKeypressEvent(kbDel, alt, ctrl, shift);
-        case 4:
-            return new TKeypressEvent(kbEnd, alt, ctrl, shift);
-        case 5:
-            return new TKeypressEvent(kbPgUp, alt, ctrl, shift);
-        case 6:
-            return new TKeypressEvent(kbPgDn, alt, ctrl, shift);
-        case 15:
-            return new TKeypressEvent(kbF5, alt, ctrl, shift);
-        case 17:
-            return new TKeypressEvent(kbF6, alt, ctrl, shift);
-        case 18:
-            return new TKeypressEvent(kbF7, alt, ctrl, shift);
-        case 19:
-            return new TKeypressEvent(kbF8, alt, ctrl, shift);
-        case 20:
-            return new TKeypressEvent(kbF9, alt, ctrl, shift);
-        case 21:
-            return new TKeypressEvent(kbF10, alt, ctrl, shift);
-        case 23:
-            return new TKeypressEvent(kbF11, alt, ctrl, shift);
-        case 24:
-            return new TKeypressEvent(kbF12, alt, ctrl, shift);
-        default:
-            // Unknown
-            return null;
-        }
+    private void sttyRaw() {
+        doStty(true);
     }
 
     /**
-     * Produce mouse events based on "Any event tracking" and UTF-8
-     * coordinates.  See
-     * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking
+     * Call 'stty' to set raw or cooked mode.
      *
-     * @return a MOUSE_MOTION, MOUSE_UP, or MOUSE_DOWN event
+     * @param mode if true, set raw mode, otherwise set cooked mode
      */
-    private TInputEvent parseMouse() {
-        int buttons = params.get(0).charAt(0) - 32;
-        int x = params.get(0).charAt(1) - 32 - 1;
-        int y = params.get(0).charAt(2) - 32 - 1;
-
-        // Clamp X and Y to the physical screen coordinates.
-        if (x >= windowResize.getWidth()) {
-            x = windowResize.getWidth() - 1;
-        }
-        if (y >= windowResize.getHeight()) {
-            y = windowResize.getHeight() - 1;
-        }
-
-        TMouseEvent.Type eventType = TMouseEvent.Type.MOUSE_DOWN;
-        boolean eventMouse1 = false;
-        boolean eventMouse2 = false;
-        boolean eventMouse3 = false;
-        boolean eventMouseWheelUp = false;
-        boolean eventMouseWheelDown = false;
-
-        // System.err.printf("buttons: %04x\r\n", buttons);
-
-        switch (buttons) {
-        case 0:
-            eventMouse1 = true;
-            mouse1 = true;
-            break;
-        case 1:
-            eventMouse2 = true;
-            mouse2 = true;
-            break;
-        case 2:
-            eventMouse3 = true;
-            mouse3 = true;
-            break;
-        case 3:
-            // Release or Move
-            if (!mouse1 && !mouse2 && !mouse3) {
-                eventType = TMouseEvent.Type.MOUSE_MOTION;
+    private void doStty(final boolean mode) {
+        String [] cmdRaw = {
+            "/bin/sh", "-c", "stty -ignbrk -brkint -parmrk -istrip -inlcr -igncr -icrnl -ixon -opost -echo -echonl -icanon -isig -iexten -parenb cs8 min 1 < /dev/tty"
+        };
+        String [] cmdCooked = {
+            "/bin/sh", "-c", "stty sane cooked < /dev/tty"
+        };
+        try {
+            Process process;
+            if (mode) {
+                process = Runtime.getRuntime().exec(cmdRaw);
             } else {
-                eventType = TMouseEvent.Type.MOUSE_UP;
+                process = Runtime.getRuntime().exec(cmdCooked);
             }
-            if (mouse1) {
-                mouse1 = false;
-                eventMouse1 = true;
+            BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream(), "UTF-8"));
+            String line = in.readLine();
+            if ((line != null) && (line.length() > 0)) {
+                System.err.println("WEIRD?! Normal output from stty: " + line);
             }
-            if (mouse2) {
-                mouse2 = false;
-                eventMouse2 = true;
+            while (true) {
+                BufferedReader err = new BufferedReader(new InputStreamReader(process.getErrorStream(), "UTF-8"));
+                line = err.readLine();
+                if ((line != null) && (line.length() > 0)) {
+                    System.err.println("Error output from stty: " + line);
+                }
+                try {
+                    process.waitFor();
+                    break;
+                } catch (InterruptedException e) {
+                    if (debugToStderr) {
+                        e.printStackTrace();
+                    }
+                }
             }
-            if (mouse3) {
-                mouse3 = false;
-                eventMouse3 = true;
+            int rc = process.exitValue();
+            if (rc != 0) {
+                System.err.println("stty returned error code: " + rc);
             }
-            break;
-
-        case 32:
-            // Dragging with mouse1 down
-            eventMouse1 = true;
-            mouse1 = true;
-            eventType = TMouseEvent.Type.MOUSE_MOTION;
-            break;
-
-        case 33:
-            // Dragging with mouse2 down
-            eventMouse2 = true;
-            mouse2 = true;
-            eventType = TMouseEvent.Type.MOUSE_MOTION;
-            break;
-
-        case 34:
-            // Dragging with mouse3 down
-            eventMouse3 = true;
-            mouse3 = true;
-            eventType = TMouseEvent.Type.MOUSE_MOTION;
-            break;
-
-        case 96:
-            // Dragging with mouse2 down after wheelUp
-            eventMouse2 = true;
-            mouse2 = true;
-            eventType = TMouseEvent.Type.MOUSE_MOTION;
-            break;
-
-        case 97:
-            // Dragging with mouse2 down after wheelDown
-            eventMouse2 = true;
-            mouse2 = true;
-            eventType = TMouseEvent.Type.MOUSE_MOTION;
-            break;
-
-        case 64:
-            eventMouseWheelUp = true;
-            break;
-
-        case 65:
-            eventMouseWheelDown = true;
-            break;
-
-        default:
-            // Unknown, just make it motion
-            eventType = TMouseEvent.Type.MOUSE_MOTION;
-            break;
+        } catch (IOException e) {
+            e.printStackTrace();
         }
-        return new TMouseEvent(eventType, x, y, x, y,
-            eventMouse1, eventMouse2, eventMouse3,
-            eventMouseWheelUp, eventMouseWheelDown);
     }
 
     /**
-     * Produce mouse events based on "Any event tracking" and SGR
-     * coordinates.  See
-     * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking
+     * Flush output.
+     */
+    public void flush() {
+        output.flush();
+    }
+
+    /**
+     * Perform a somewhat-optimal rendering of a line.
      *
-     * @param release if true, this was a release ('m')
-     * @return a MOUSE_MOTION, MOUSE_UP, or MOUSE_DOWN event
+     * @param y row coordinate.  0 is the top-most row.
+     * @param sb StringBuilder to write escape sequences to
+     * @param lastAttr cell attributes from the last call to flushLine
      */
-    private TInputEvent parseMouseSGR(final boolean release) {
-        // SGR extended coordinates - mode 1006
-        if (params.size() < 3) {
-            // Invalid position, bail out.
-            return null;
-        }
-        int buttons = Integer.parseInt(params.get(0));
-        int x = Integer.parseInt(params.get(1)) - 1;
-        int y = Integer.parseInt(params.get(2)) - 1;
+    private void flushLine(final int y, final StringBuilder sb,
+        CellAttributes lastAttr) {
 
-        // Clamp X and Y to the physical screen coordinates.
-        if (x >= windowResize.getWidth()) {
-            x = windowResize.getWidth() - 1;
-        }
-        if (y >= windowResize.getHeight()) {
-            y = windowResize.getHeight() - 1;
+        int lastX = -1;
+        int textEnd = 0;
+        for (int x = 0; x < width; x++) {
+            Cell lCell = logical[x][y];
+            if (!lCell.isBlank()) {
+                textEnd = x;
+            }
         }
+        // Push textEnd to first column beyond the text area
+        textEnd++;
 
-        TMouseEvent.Type eventType = TMouseEvent.Type.MOUSE_DOWN;
-        boolean eventMouse1 = false;
-        boolean eventMouse2 = false;
-        boolean eventMouse3 = false;
-        boolean eventMouseWheelUp = false;
-        boolean eventMouseWheelDown = false;
+        // DEBUG
+        // reallyCleared = true;
 
-        if (release) {
-            eventType = TMouseEvent.Type.MOUSE_UP;
-        }
+        boolean hasImage = false;
 
-        switch (buttons) {
-        case 0:
-            eventMouse1 = true;
-            break;
-        case 1:
-            eventMouse2 = true;
-            break;
-        case 2:
-            eventMouse3 = true;
-            break;
-        case 35:
-            // Motion only, no buttons down
-            eventType = TMouseEvent.Type.MOUSE_MOTION;
-            break;
+        for (int x = 0; x < width; x++) {
+            Cell lCell = logical[x][y];
+            Cell pCell = physical[x][y];
 
-        case 32:
-            // Dragging with mouse1 down
-            eventMouse1 = true;
-            eventType = TMouseEvent.Type.MOUSE_MOTION;
-            break;
+            if (!lCell.equals(pCell) || reallyCleared) {
 
-        case 33:
-            // Dragging with mouse2 down
-            eventMouse2 = true;
-            eventType = TMouseEvent.Type.MOUSE_MOTION;
-            break;
+                if (debugToStderr) {
+                    System.err.printf("\n--\n");
+                    System.err.printf(" Y: %d X: %d\n", y, x);
+                    System.err.printf("   lCell: %s\n", lCell);
+                    System.err.printf("   pCell: %s\n", pCell);
+                    System.err.printf("    ====    \n");
+                }
 
-        case 34:
-            // Dragging with mouse3 down
-            eventMouse3 = true;
-            eventType = TMouseEvent.Type.MOUSE_MOTION;
-            break;
+                if (lastAttr == null) {
+                    lastAttr = new CellAttributes();
+                    sb.append(normal());
+                }
 
-        case 96:
-            // Dragging with mouse2 down after wheelUp
-            eventMouse2 = true;
-            eventType = TMouseEvent.Type.MOUSE_MOTION;
-            break;
+                // Place the cell
+                if ((lastX != (x - 1)) || (lastX == -1)) {
+                    // Advancing at least one cell, or the first gotoXY
+                    sb.append(gotoXY(x, y));
+                }
 
-        case 97:
-            // Dragging with mouse2 down after wheelDown
-            eventMouse2 = true;
-            eventType = TMouseEvent.Type.MOUSE_MOTION;
-            break;
+                assert (lastAttr != null);
 
-        case 64:
-            eventMouseWheelUp = true;
-            break;
+                if ((x == textEnd) && (textEnd < width - 1)) {
+                    assert (lCell.isBlank());
 
-        case 65:
-            eventMouseWheelDown = true;
-            break;
+                    for (int i = x; i < width; i++) {
+                        assert (logical[i][y].isBlank());
+                        // Physical is always updated
+                        physical[i][y].reset();
+                    }
 
-        default:
-            // Unknown, bail out
-            return null;
-        }
-        return new TMouseEvent(eventType, x, y, x, y,
-            eventMouse1, eventMouse2, eventMouse3,
-            eventMouseWheelUp, eventMouseWheelDown);
+                    // Clear remaining line
+                    sb.append(clearRemainingLine());
+                    lastAttr.reset();
+                    return;
+                }
+
+                // Image cell: bypass the rest of the loop, it is not
+                // rendered here.
+                if ((wideCharImages && lCell.isImage())
+                    || (!wideCharImages
+                        && lCell.isImage()
+                        && (lCell.getWidth() == Cell.Width.SINGLE))
+                ) {
+                    hasImage = true;
+
+                    // Save the last rendered cell
+                    lastX = x;
+
+                    // Physical is always updated
+                    physical[x][y].setTo(lCell);
+                    continue;
+                }
+
+                assert ((wideCharImages && !lCell.isImage())
+                    || (!wideCharImages
+                        && (!lCell.isImage()
+                            || (lCell.isImage()
+                                && (lCell.getWidth() != Cell.Width.SINGLE)))));
+
+                if (!wideCharImages && (lCell.getWidth() == Cell.Width.RIGHT)) {
+                    continue;
+                }
+
+                if (hasImage) {
+                    hasImage = false;
+                    sb.append(gotoXY(x, y));
+                }
+
+                // Now emit only the modified attributes
+                if ((lCell.getForeColor() != lastAttr.getForeColor())
+                    && (lCell.getBackColor() != lastAttr.getBackColor())
+                    && (!lCell.isRGB())
+                    && (lCell.isBold() == lastAttr.isBold())
+                    && (lCell.isReverse() == lastAttr.isReverse())
+                    && (lCell.isUnderline() == lastAttr.isUnderline())
+                    && (lCell.isBlink() == lastAttr.isBlink())
+                ) {
+                    // Both colors changed, attributes the same
+                    sb.append(color(lCell.isBold(),
+                            lCell.getForeColor(), lCell.getBackColor()));
+
+                    if (debugToStderr) {
+                        System.err.printf("1 Change only fore/back colors\n");
+                    }
+
+                } else if (lCell.isRGB()
+                    && (lCell.getForeColorRGB() != lastAttr.getForeColorRGB())
+                    && (lCell.getBackColorRGB() != lastAttr.getBackColorRGB())
+                    && (lCell.isBold() == lastAttr.isBold())
+                    && (lCell.isReverse() == lastAttr.isReverse())
+                    && (lCell.isUnderline() == lastAttr.isUnderline())
+                    && (lCell.isBlink() == lastAttr.isBlink())
+                ) {
+                    // Both colors changed, attributes the same
+                    sb.append(colorRGB(lCell.getForeColorRGB(),
+                            lCell.getBackColorRGB()));
+
+                    if (debugToStderr) {
+                        System.err.printf("1 Change only fore/back colors (RGB)\n");
+                    }
+                } else if ((lCell.getForeColor() != lastAttr.getForeColor())
+                    && (lCell.getBackColor() != lastAttr.getBackColor())
+                    && (!lCell.isRGB())
+                    && (lCell.isBold() != lastAttr.isBold())
+                    && (lCell.isReverse() != lastAttr.isReverse())
+                    && (lCell.isUnderline() != lastAttr.isUnderline())
+                    && (lCell.isBlink() != lastAttr.isBlink())
+                ) {
+                    // Everything is different
+                    sb.append(color(lCell.getForeColor(),
+                            lCell.getBackColor(),
+                            lCell.isBold(), lCell.isReverse(),
+                            lCell.isBlink(),
+                            lCell.isUnderline()));
+
+                    if (debugToStderr) {
+                        System.err.printf("2 Set all attributes\n");
+                    }
+                } else if ((lCell.getForeColor() != lastAttr.getForeColor())
+                    && (lCell.getBackColor() == lastAttr.getBackColor())
+                    && (!lCell.isRGB())
+                    && (lCell.isBold() == lastAttr.isBold())
+                    && (lCell.isReverse() == lastAttr.isReverse())
+                    && (lCell.isUnderline() == lastAttr.isUnderline())
+                    && (lCell.isBlink() == lastAttr.isBlink())
+                ) {
+
+                    // Attributes same, foreColor different
+                    sb.append(color(lCell.isBold(),
+                            lCell.getForeColor(), true));
+
+                    if (debugToStderr) {
+                        System.err.printf("3 Change foreColor\n");
+                    }
+                } else if (lCell.isRGB()
+                    && (lCell.getForeColorRGB() != lastAttr.getForeColorRGB())
+                    && (lCell.getBackColorRGB() == lastAttr.getBackColorRGB())
+                    && (lCell.getForeColorRGB() >= 0)
+                    && (lCell.getBackColorRGB() >= 0)
+                    && (lCell.isBold() == lastAttr.isBold())
+                    && (lCell.isReverse() == lastAttr.isReverse())
+                    && (lCell.isUnderline() == lastAttr.isUnderline())
+                    && (lCell.isBlink() == lastAttr.isBlink())
+                ) {
+                    // Attributes same, foreColor different
+                    sb.append(colorRGB(lCell.getForeColorRGB(), true));
+
+                    if (debugToStderr) {
+                        System.err.printf("3 Change foreColor (RGB)\n");
+                    }
+                } else if ((lCell.getForeColor() == lastAttr.getForeColor())
+                    && (lCell.getBackColor() != lastAttr.getBackColor())
+                    && (!lCell.isRGB())
+                    && (lCell.isBold() == lastAttr.isBold())
+                    && (lCell.isReverse() == lastAttr.isReverse())
+                    && (lCell.isUnderline() == lastAttr.isUnderline())
+                    && (lCell.isBlink() == lastAttr.isBlink())
+                ) {
+                    // Attributes same, backColor different
+                    sb.append(color(lCell.isBold(),
+                            lCell.getBackColor(), false));
+
+                    if (debugToStderr) {
+                        System.err.printf("4 Change backColor\n");
+                    }
+                } else if (lCell.isRGB()
+                    && (lCell.getForeColorRGB() == lastAttr.getForeColorRGB())
+                    && (lCell.getBackColorRGB() != lastAttr.getBackColorRGB())
+                    && (lCell.isBold() == lastAttr.isBold())
+                    && (lCell.isReverse() == lastAttr.isReverse())
+                    && (lCell.isUnderline() == lastAttr.isUnderline())
+                    && (lCell.isBlink() == lastAttr.isBlink())
+                ) {
+                    // Attributes same, foreColor different
+                    sb.append(colorRGB(lCell.getBackColorRGB(), false));
+
+                    if (debugToStderr) {
+                        System.err.printf("4 Change backColor (RGB)\n");
+                    }
+                } else if ((lCell.getForeColor() == lastAttr.getForeColor())
+                    && (lCell.getBackColor() == lastAttr.getBackColor())
+                    && (lCell.getForeColorRGB() == lastAttr.getForeColorRGB())
+                    && (lCell.getBackColorRGB() == lastAttr.getBackColorRGB())
+                    && (lCell.isBold() == lastAttr.isBold())
+                    && (lCell.isReverse() == lastAttr.isReverse())
+                    && (lCell.isUnderline() == lastAttr.isUnderline())
+                    && (lCell.isBlink() == lastAttr.isBlink())
+                ) {
+
+                    // All attributes the same, just print the char
+                    // NOP
+
+                    if (debugToStderr) {
+                        System.err.printf("5 Only emit character\n");
+                    }
+                } else {
+                    // Just reset everything again
+                    if (!lCell.isRGB()) {
+                        sb.append(color(lCell.getForeColor(),
+                                lCell.getBackColor(),
+                                lCell.isBold(),
+                                lCell.isReverse(),
+                                lCell.isBlink(),
+                                lCell.isUnderline()));
+
+                        if (debugToStderr) {
+                            System.err.printf("6 Change all attributes\n");
+                        }
+                    } else {
+                        sb.append(colorRGB(lCell.getForeColorRGB(),
+                                lCell.getBackColorRGB(),
+                                lCell.isBold(),
+                                lCell.isReverse(),
+                                lCell.isBlink(),
+                                lCell.isUnderline()));
+                        if (debugToStderr) {
+                            System.err.printf("6 Change all attributes (RGB)\n");
+                        }
+                    }
+
+                }
+                // Emit the character
+                if (wideCharImages
+                    // Don't emit the right-half of full-width chars.
+                    || (!wideCharImages
+                        && (lCell.getWidth() != Cell.Width.RIGHT))
+                ) {
+                    sb.append(Character.toChars(lCell.getChar()));
+                }
+
+                // Save the last rendered cell
+                lastX = x;
+                lastAttr.setTo(lCell);
+
+                // Physical is always updated
+                physical[x][y].setTo(lCell);
+
+            } // if (!lCell.equals(pCell) || (reallyCleared == true))
+
+        } // for (int x = 0; x < width; x++)
     }
 
     /**
-     * Return any events in the IO queue.
+     * Render the screen to a string that can be emitted to something that
+     * knows how to process ECMA-48/ANSI X3.64 escape sequences.
      *
-     * @param queue list to append new events to
+     * @param sb StringBuilder to write escape sequences to
+     * @return escape sequences string that provides the updates to the
+     * physical screen
      */
-    public void getEvents(final List<TInputEvent> queue) {
-        synchronized (eventQueue) {
-            if (eventQueue.size() > 0) {
-                synchronized (queue) {
-                    queue.addAll(eventQueue);
+    private String flushString(final StringBuilder sb) {
+        CellAttributes attr = null;
+
+        if (reallyCleared) {
+            attr = new CellAttributes();
+            sb.append(clearAll());
+        }
+
+        /*
+         * For images support, draw all of the image output first, and then
+         * draw everything else afterwards.  This works OK, but performance
+         * is still a drag on larger pictures.
+         */
+        for (int y = 0; y < height; y++) {
+            for (int x = 0; x < width; x++) {
+                // If physical had non-image data that is now image data, the
+                // entire row must be redrawn.
+                Cell lCell = logical[x][y];
+                Cell pCell = physical[x][y];
+                if (lCell.isImage() && !pCell.isImage()) {
+                    unsetImageRow(y);
+                    break;
                 }
-                eventQueue.clear();
             }
         }
-    }
-
-    /**
-     * Return any events in the IO queue due to timeout.
-     *
-     * @param queue list to append new events to
-     */
-    private void getIdleEvents(final List<TInputEvent> queue) {
-        long nowTime = System.currentTimeMillis();
+        for (int y = 0; y < height; y++) {
+            for (int x = 0; x < width; x++) {
+                Cell lCell = logical[x][y];
+                Cell pCell = physical[x][y];
 
-        // Check for new window size
-        long windowSizeDelay = nowTime - windowSizeTime;
-        if (windowSizeDelay > 1000) {
-            sessionInfo.queryWindowSize();
-            int newWidth = sessionInfo.getWindowWidth();
-            int newHeight = sessionInfo.getWindowHeight();
+                if (!lCell.isImage()
+                    || (!wideCharImages
+                        && (lCell.getWidth() != Cell.Width.SINGLE))
+                ) {
+                    continue;
+                }
 
-            if ((newWidth != windowResize.getWidth())
-                || (newHeight != windowResize.getHeight())
-            ) {
+                int left = x;
+                int right = x;
+                while ((right < width)
+                    && (logical[right][y].isImage())
+                    && (!logical[right][y].equals(physical[right][y])
+                        || reallyCleared)
+                ) {
+                    right++;
+                }
+                ArrayList<Cell> cellsToDraw = new ArrayList<Cell>();
+                for (int i = 0; i < (right - x); i++) {
+                    assert (logical[x + i][y].isImage());
+                    cellsToDraw.add(logical[x + i][y]);
 
-                if (debugToStderr) {
-                    System.err.println("Screen size changed, old size " +
-                        windowResize);
-                    System.err.println("                     new size " +
-                        newWidth + " x " + newHeight);
+                    // Physical is always updated.
+                    physical[x + i][y].setTo(lCell);
+                }
+                if (cellsToDraw.size() > 0) {
+                    if (iterm2Images) {
+                        sb.append(toIterm2Image(x, y, cellsToDraw));
+                    } else if (jexerImageOption != JexerImageOption.DISABLED) {
+                        sb.append(toJexerImage(x, y, cellsToDraw));
+                    } else {
+                        sb.append(toSixel(x, y, cellsToDraw));
+                    }
                 }
 
-                TResizeEvent event = new TResizeEvent(TResizeEvent.Type.SCREEN,
-                    newWidth, newHeight);
-                windowResize = new TResizeEvent(TResizeEvent.Type.SCREEN,
-                    newWidth, newHeight);
-                queue.add(event);
+                x = right;
             }
-            windowSizeTime = nowTime;
         }
 
-        // ESCDELAY type timeout
-        if (state == ParseState.ESCAPE) {
-            long escDelay = nowTime - escapeTime;
-            if (escDelay > 100) {
-                // After 0.1 seconds, assume a true escape character
-                queue.add(controlChar((char)0x1B, false));
-                resetParser();
-            }
+        // Draw the text part now.
+        for (int y = 0; y < height; y++) {
+            flushLine(y, sb, attr);
         }
-    }
 
-    /**
-     * Returns true if the CSI parameter for a keyboard command means that
-     * shift was down.
-     */
-    private boolean csiIsShift(final String x) {
-        if ((x.equals("2"))
-            || (x.equals("4"))
-            || (x.equals("6"))
-            || (x.equals("8"))
-        ) {
-            return true;
+        reallyCleared = false;
+
+        String result = sb.toString();
+        if (debugToStderr) {
+            System.err.printf("flushString(): %s\n", result);
         }
-        return false;
+        return result;
     }
 
     /**
-     * Returns true if the CSI parameter for a keyboard command means that
-     * alt was down.
+     * Reset keyboard/mouse input parser.
      */
-    private boolean csiIsAlt(final String x) {
-        if ((x.equals("3"))
-            || (x.equals("4"))
-            || (x.equals("7"))
-            || (x.equals("8"))
-        ) {
-            return true;
-        }
-        return false;
+    private void resetParser() {
+        state = ParseState.GROUND;
+        params = new ArrayList<String>();
+        params.clear();
+        params.add("");
+        decPrivateModeFlag = false;
     }
 
     /**
-     * Returns true if the CSI parameter for a keyboard command means that
-     * ctrl was down.
-     */
-    private boolean csiIsCtrl(final String x) {
-        if ((x.equals("5"))
-            || (x.equals("6"))
-            || (x.equals("7"))
-            || (x.equals("8"))
-        ) {
-            return true;
+     * Produce a control character or one of the special ones (ENTER, TAB,
+     * etc.).
+     *
+     * @param ch Unicode code point
+     * @param alt if true, set alt on the TKeypress
+     * @return one TKeypress event, either a control character (e.g. isKey ==
+     * false, ch == 'A', ctrl == true), or a special key (e.g. isKey == true,
+     * fnKey == ESC)
+     */
+    private TKeypressEvent controlChar(final char ch, final boolean alt) {
+        // System.err.printf("controlChar: %02x\n", ch);
+
+        switch (ch) {
+        case 0x0D:
+            // Carriage return --> ENTER
+            return new TKeypressEvent(kbEnter, alt, false, false);
+        case 0x0A:
+            // Linefeed --> ENTER
+            return new TKeypressEvent(kbEnter, alt, false, false);
+        case 0x1B:
+            // ESC
+            return new TKeypressEvent(kbEsc, alt, false, false);
+        case '\t':
+            // TAB
+            return new TKeypressEvent(kbTab, alt, false, false);
+        default:
+            // Make all other control characters come back as the alphabetic
+            // character with the ctrl field set.  So SOH would be 'A' +
+            // ctrl.
+            return new TKeypressEvent(false, 0, (char)(ch + 0x40),
+                alt, true, false);
         }
-        return false;
     }
 
     /**
-     * Parses the next character of input to see if an InputEvent is
-     * fully here.
+     * Produce special key from CSI Pn ; Pm ; ... ~
      *
-     * @param events list to append new events to
-     * @param ch Unicode code point
+     * @return one KEYPRESS event representing a special key
      */
-    private void processChar(final List<TInputEvent> events, final char ch) {
-
-        // ESCDELAY type timeout
-        long nowTime = System.currentTimeMillis();
-        if (state == ParseState.ESCAPE) {
-            long escDelay = nowTime - escapeTime;
-            if (escDelay > 250) {
-                // After 0.25 seconds, assume a true escape character
-                events.add(controlChar((char)0x1B, false));
-                resetParser();
-            }
+    private TInputEvent csiFnKey() {
+        int key = 0;
+        if (params.size() > 0) {
+            key = Integer.parseInt(params.get(0));
         }
-
-        // TKeypress fields
-        boolean ctrl = false;
         boolean alt = false;
+        boolean ctrl = false;
         boolean shift = false;
+        if (params.size() > 1) {
+            shift = csiIsShift(params.get(1));
+            alt = csiIsAlt(params.get(1));
+            ctrl = csiIsCtrl(params.get(1));
+        }
 
-        // System.err.printf("state: %s ch %c\r\n", state, ch);
+        switch (key) {
+        case 1:
+            return new TKeypressEvent(kbHome, alt, ctrl, shift);
+        case 2:
+            return new TKeypressEvent(kbIns, alt, ctrl, shift);
+        case 3:
+            return new TKeypressEvent(kbDel, alt, ctrl, shift);
+        case 4:
+            return new TKeypressEvent(kbEnd, alt, ctrl, shift);
+        case 5:
+            return new TKeypressEvent(kbPgUp, alt, ctrl, shift);
+        case 6:
+            return new TKeypressEvent(kbPgDn, alt, ctrl, shift);
+        case 15:
+            return new TKeypressEvent(kbF5, alt, ctrl, shift);
+        case 17:
+            return new TKeypressEvent(kbF6, alt, ctrl, shift);
+        case 18:
+            return new TKeypressEvent(kbF7, alt, ctrl, shift);
+        case 19:
+            return new TKeypressEvent(kbF8, alt, ctrl, shift);
+        case 20:
+            return new TKeypressEvent(kbF9, alt, ctrl, shift);
+        case 21:
+            return new TKeypressEvent(kbF10, alt, ctrl, shift);
+        case 23:
+            return new TKeypressEvent(kbF11, alt, ctrl, shift);
+        case 24:
+            return new TKeypressEvent(kbF12, alt, ctrl, shift);
+        default:
+            // Unknown
+            return null;
+        }
+    }
 
-        switch (state) {
-        case GROUND:
+    /**
+     * Produce mouse events based on "Any event tracking" and UTF-8
+     * coordinates.  See
+     * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking
+     *
+     * @return a MOUSE_MOTION, MOUSE_UP, or MOUSE_DOWN event
+     */
+    private TInputEvent parseMouse() {
+        int buttons = params.get(0).charAt(0) - 32;
+        int x = params.get(0).charAt(1) - 32 - 1;
+        int y = params.get(0).charAt(2) - 32 - 1;
 
-            if (ch == 0x1B) {
-                state = ParseState.ESCAPE;
-                escapeTime = nowTime;
-                return;
-            }
+        // Clamp X and Y to the physical screen coordinates.
+        if (x >= windowResize.getWidth()) {
+            x = windowResize.getWidth() - 1;
+        }
+        if (y >= windowResize.getHeight()) {
+            y = windowResize.getHeight() - 1;
+        }
 
-            if (ch <= 0x1F) {
-                // Control character
-                events.add(controlChar(ch, false));
-                resetParser();
-                return;
-            }
+        TMouseEvent.Type eventType = TMouseEvent.Type.MOUSE_DOWN;
+        boolean eventMouse1 = false;
+        boolean eventMouse2 = false;
+        boolean eventMouse3 = false;
+        boolean eventMouseWheelUp = false;
+        boolean eventMouseWheelDown = false;
+        boolean eventAlt = false;
+        boolean eventCtrl = false;
+        boolean eventShift = false;
 
-            if (ch >= 0x20) {
-                // Normal character
-                events.add(new TKeypressEvent(false, 0, ch,
-                        false, false, false));
-                resetParser();
-                return;
-            }
+        // System.err.printf("buttons: %04x\r\n", buttons);
 
+        switch (buttons & 0xE3) {
+        case 0:
+            eventMouse1 = true;
+            mouse1 = true;
             break;
-
-        case ESCAPE:
-            if (ch <= 0x1F) {
-                // ALT-Control character
-                events.add(controlChar(ch, true));
-                resetParser();
-                return;
+        case 1:
+            eventMouse2 = true;
+            mouse2 = true;
+            break;
+        case 2:
+            eventMouse3 = true;
+            mouse3 = true;
+            break;
+        case 3:
+            // Release or Move
+            if (!mouse1 && !mouse2 && !mouse3) {
+                eventType = TMouseEvent.Type.MOUSE_MOTION;
+            } else {
+                eventType = TMouseEvent.Type.MOUSE_UP;
             }
-
-            if (ch == 'O') {
-                // This will be one of the function keys
-                state = ParseState.ESCAPE_INTERMEDIATE;
-                return;
+            if (mouse1) {
+                mouse1 = false;
+                eventMouse1 = true;
             }
-
-            // '[' goes to CSI_ENTRY
-            if (ch == '[') {
-                state = ParseState.CSI_ENTRY;
-                return;
+            if (mouse2) {
+                mouse2 = false;
+                eventMouse2 = true;
             }
-
-            // Everything else is assumed to be Alt-keystroke
-            if ((ch >= 'A') && (ch <= 'Z')) {
-                shift = true;
+            if (mouse3) {
+                mouse3 = false;
+                eventMouse3 = true;
             }
-            alt = true;
-            events.add(new TKeypressEvent(false, 0, ch, alt, ctrl, shift));
-            resetParser();
-            return;
+            break;
 
-        case ESCAPE_INTERMEDIATE:
-            if ((ch >= 'P') && (ch <= 'S')) {
-                // Function key
-                switch (ch) {
-                case 'P':
-                    events.add(new TKeypressEvent(kbF1));
-                    break;
-                case 'Q':
-                    events.add(new TKeypressEvent(kbF2));
-                    break;
-                case 'R':
-                    events.add(new TKeypressEvent(kbF3));
-                    break;
-                case 'S':
-                    events.add(new TKeypressEvent(kbF4));
-                    break;
-                default:
-                    break;
-                }
-                resetParser();
-                return;
-            }
+        case 32:
+            // Dragging with mouse1 down
+            eventMouse1 = true;
+            mouse1 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
 
-            // Unknown keystroke, ignore
-            resetParser();
-            return;
+        case 33:
+            // Dragging with mouse2 down
+            eventMouse2 = true;
+            mouse2 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
 
-        case CSI_ENTRY:
-            // Numbers - parameter values
-            if ((ch >= '0') && (ch <= '9')) {
-                params.set(params.size() - 1,
-                    params.get(params.size() - 1) + ch);
-                state = ParseState.CSI_PARAM;
-                return;
-            }
-            // Parameter separator
-            if (ch == ';') {
-                params.add("");
-                return;
-            }
+        case 34:
+            // Dragging with mouse3 down
+            eventMouse3 = true;
+            mouse3 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
 
-            if ((ch >= 0x30) && (ch <= 0x7E)) {
-                switch (ch) {
-                case 'A':
+        case 96:
+            // Dragging with mouse2 down after wheelUp
+            eventMouse2 = true;
+            mouse2 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 97:
+            // Dragging with mouse2 down after wheelDown
+            eventMouse2 = true;
+            mouse2 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 64:
+            eventMouseWheelUp = true;
+            break;
+
+        case 65:
+            eventMouseWheelDown = true;
+            break;
+
+        default:
+            // Unknown, just make it motion
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+        }
+
+        if ((buttons & 0x04) != 0) {
+            eventShift = true;
+        }
+        if ((buttons & 0x08) != 0) {
+            eventAlt = true;
+        }
+        if ((buttons & 0x10) != 0) {
+            eventCtrl = true;
+        }
+
+        return new TMouseEvent(eventType, x, y, x, y,
+            eventMouse1, eventMouse2, eventMouse3,
+            eventMouseWheelUp, eventMouseWheelDown,
+            eventAlt, eventCtrl, eventShift);
+    }
+
+    /**
+     * Produce mouse events based on "Any event tracking" and SGR
+     * coordinates.  See
+     * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking
+     *
+     * @param release if true, this was a release ('m')
+     * @return a MOUSE_MOTION, MOUSE_UP, or MOUSE_DOWN event
+     */
+    private TInputEvent parseMouseSGR(final boolean release) {
+        // SGR extended coordinates - mode 1006
+        if (params.size() < 3) {
+            // Invalid position, bail out.
+            return null;
+        }
+        int buttons = Integer.parseInt(params.get(0));
+        int x = Integer.parseInt(params.get(1)) - 1;
+        int y = Integer.parseInt(params.get(2)) - 1;
+
+        // Clamp X and Y to the physical screen coordinates.
+        if (x >= windowResize.getWidth()) {
+            x = windowResize.getWidth() - 1;
+        }
+        if (y >= windowResize.getHeight()) {
+            y = windowResize.getHeight() - 1;
+        }
+
+        TMouseEvent.Type eventType = TMouseEvent.Type.MOUSE_DOWN;
+        boolean eventMouse1 = false;
+        boolean eventMouse2 = false;
+        boolean eventMouse3 = false;
+        boolean eventMouseWheelUp = false;
+        boolean eventMouseWheelDown = false;
+        boolean eventAlt = false;
+        boolean eventCtrl = false;
+        boolean eventShift = false;
+
+        if (release) {
+            eventType = TMouseEvent.Type.MOUSE_UP;
+        }
+
+        switch (buttons & 0xE3) {
+        case 0:
+            eventMouse1 = true;
+            break;
+        case 1:
+            eventMouse2 = true;
+            break;
+        case 2:
+            eventMouse3 = true;
+            break;
+        case 35:
+            // Motion only, no buttons down
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 32:
+            // Dragging with mouse1 down
+            eventMouse1 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 33:
+            // Dragging with mouse2 down
+            eventMouse2 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 34:
+            // Dragging with mouse3 down
+            eventMouse3 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 96:
+            // Dragging with mouse2 down after wheelUp
+            eventMouse2 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 97:
+            // Dragging with mouse2 down after wheelDown
+            eventMouse2 = true;
+            eventType = TMouseEvent.Type.MOUSE_MOTION;
+            break;
+
+        case 64:
+            eventMouseWheelUp = true;
+            break;
+
+        case 65:
+            eventMouseWheelDown = true;
+            break;
+
+        default:
+            // Unknown, bail out
+            return null;
+        }
+
+        if ((buttons & 0x04) != 0) {
+            eventShift = true;
+        }
+        if ((buttons & 0x08) != 0) {
+            eventAlt = true;
+        }
+        if ((buttons & 0x10) != 0) {
+            eventCtrl = true;
+        }
+
+        return new TMouseEvent(eventType, x, y, x, y,
+            eventMouse1, eventMouse2, eventMouse3,
+            eventMouseWheelUp, eventMouseWheelDown,
+            eventAlt, eventCtrl, eventShift);
+    }
+
+    /**
+     * Return any events in the IO queue due to timeout.
+     *
+     * @param queue list to append new events to
+     */
+    private void getIdleEvents(final List<TInputEvent> queue) {
+        long nowTime = System.currentTimeMillis();
+
+        // Check for new window size
+        long windowSizeDelay = nowTime - windowSizeTime;
+        if (windowSizeDelay > 1000) {
+            int oldTextWidth = getTextWidth();
+            int oldTextHeight = getTextHeight();
+
+            sessionInfo.queryWindowSize();
+            int newWidth = sessionInfo.getWindowWidth();
+            int newHeight = sessionInfo.getWindowHeight();
+
+            if ((newWidth != windowResize.getWidth())
+                || (newHeight != windowResize.getHeight())
+            ) {
+
+                // Request xterm report window dimensions in pixels again.
+                // Between now and then, ensure that the reported text cell
+                // size is the same by setting widthPixels and heightPixels
+                // to match the new dimensions.
+                widthPixels = oldTextWidth * newWidth;
+                heightPixels = oldTextHeight * newHeight;
+
+                if (debugToStderr) {
+                    System.err.println("Screen size changed, old size " +
+                        windowResize);
+                    System.err.println("                     new size " +
+                        newWidth + " x " + newHeight);
+                    System.err.println("                   old pixels " +
+                        oldTextWidth + " x " + oldTextHeight);
+                    System.err.println("                   new pixels " +
+                        getTextWidth() + " x " + getTextHeight());
+                }
+
+                this.output.printf("%s", xtermReportPixelDimensions());
+                this.output.flush();
+
+                TResizeEvent event = new TResizeEvent(TResizeEvent.Type.SCREEN,
+                    newWidth, newHeight);
+                windowResize = new TResizeEvent(TResizeEvent.Type.SCREEN,
+                    newWidth, newHeight);
+                queue.add(event);
+            }
+            windowSizeTime = nowTime;
+        }
+
+        // ESCDELAY type timeout
+        if (state == ParseState.ESCAPE) {
+            long escDelay = nowTime - escapeTime;
+            if (escDelay > 100) {
+                // After 0.1 seconds, assume a true escape character
+                queue.add(controlChar((char)0x1B, false));
+                resetParser();
+            }
+        }
+    }
+
+    /**
+     * Returns true if the CSI parameter for a keyboard command means that
+     * shift was down.
+     */
+    private boolean csiIsShift(final String x) {
+        if ((x.equals("2"))
+            || (x.equals("4"))
+            || (x.equals("6"))
+            || (x.equals("8"))
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Returns true if the CSI parameter for a keyboard command means that
+     * alt was down.
+     */
+    private boolean csiIsAlt(final String x) {
+        if ((x.equals("3"))
+            || (x.equals("4"))
+            || (x.equals("7"))
+            || (x.equals("8"))
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Returns true if the CSI parameter for a keyboard command means that
+     * ctrl was down.
+     */
+    private boolean csiIsCtrl(final String x) {
+        if ((x.equals("5"))
+            || (x.equals("6"))
+            || (x.equals("7"))
+            || (x.equals("8"))
+        ) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Parses the next character of input to see if an InputEvent is
+     * fully here.
+     *
+     * @param events list to append new events to
+     * @param ch Unicode code point
+     */
+    private void processChar(final List<TInputEvent> events, final char ch) {
+
+        // ESCDELAY type timeout
+        long nowTime = System.currentTimeMillis();
+        if (state == ParseState.ESCAPE) {
+            long escDelay = nowTime - escapeTime;
+            if (escDelay > 250) {
+                // After 0.25 seconds, assume a true escape character
+                events.add(controlChar((char)0x1B, false));
+                resetParser();
+            }
+        }
+
+        // TKeypress fields
+        boolean ctrl = false;
+        boolean alt = false;
+        boolean shift = false;
+
+        // System.err.printf("state: %s ch %c\r\n", state, ch);
+
+        switch (state) {
+        case GROUND:
+
+            if (ch == 0x1B) {
+                state = ParseState.ESCAPE;
+                escapeTime = nowTime;
+                return;
+            }
+
+            if (ch <= 0x1F) {
+                // Control character
+                events.add(controlChar(ch, false));
+                resetParser();
+                return;
+            }
+
+            if (ch >= 0x20) {
+                // Normal character
+                events.add(new TKeypressEvent(false, 0, ch,
+                        false, false, false));
+                resetParser();
+                return;
+            }
+
+            break;
+
+        case ESCAPE:
+            if (ch <= 0x1F) {
+                // ALT-Control character
+                events.add(controlChar(ch, true));
+                resetParser();
+                return;
+            }
+
+            if (ch == 'O') {
+                // This will be one of the function keys
+                state = ParseState.ESCAPE_INTERMEDIATE;
+                return;
+            }
+
+            // '[' goes to CSI_ENTRY
+            if (ch == '[') {
+                state = ParseState.CSI_ENTRY;
+                return;
+            }
+
+            // Everything else is assumed to be Alt-keystroke
+            if ((ch >= 'A') && (ch <= 'Z')) {
+                shift = true;
+            }
+            alt = true;
+            events.add(new TKeypressEvent(false, 0, ch, alt, ctrl, shift));
+            resetParser();
+            return;
+
+        case ESCAPE_INTERMEDIATE:
+            if ((ch >= 'P') && (ch <= 'S')) {
+                // Function key
+                switch (ch) {
+                case 'P':
+                    events.add(new TKeypressEvent(kbF1));
+                    break;
+                case 'Q':
+                    events.add(new TKeypressEvent(kbF2));
+                    break;
+                case 'R':
+                    events.add(new TKeypressEvent(kbF3));
+                    break;
+                case 'S':
+                    events.add(new TKeypressEvent(kbF4));
+                    break;
+                default:
+                    break;
+                }
+                resetParser();
+                return;
+            }
+
+            // Unknown keystroke, ignore
+            resetParser();
+            return;
+
+        case CSI_ENTRY:
+            // Numbers - parameter values
+            if ((ch >= '0') && (ch <= '9')) {
+                params.set(params.size() - 1,
+                    params.get(params.size() - 1) + ch);
+                state = ParseState.CSI_PARAM;
+                return;
+            }
+            // Parameter separator
+            if (ch == ';') {
+                params.add("");
+                return;
+            }
+
+            if ((ch >= 0x30) && (ch <= 0x7E)) {
+                switch (ch) {
+                case 'A':
+                    // Up
+                    events.add(new TKeypressEvent(kbUp, alt, ctrl, shift));
+                    resetParser();
+                    return;
+                case 'B':
+                    // Down
+                    events.add(new TKeypressEvent(kbDown, alt, ctrl, shift));
+                    resetParser();
+                    return;
+                case 'C':
+                    // Right
+                    events.add(new TKeypressEvent(kbRight, alt, ctrl, shift));
+                    resetParser();
+                    return;
+                case 'D':
+                    // Left
+                    events.add(new TKeypressEvent(kbLeft, alt, ctrl, shift));
+                    resetParser();
+                    return;
+                case 'H':
+                    // Home
+                    events.add(new TKeypressEvent(kbHome));
+                    resetParser();
+                    return;
+                case 'F':
+                    // End
+                    events.add(new TKeypressEvent(kbEnd));
+                    resetParser();
+                    return;
+                case 'Z':
+                    // CBT - Cursor backward X tab stops (default 1)
+                    events.add(new TKeypressEvent(kbBackTab));
+                    resetParser();
+                    return;
+                case 'M':
+                    // Mouse position
+                    state = ParseState.MOUSE;
+                    return;
+                case '<':
+                    // Mouse position, SGR (1006) coordinates
+                    state = ParseState.MOUSE_SGR;
+                    return;
+                case '?':
+                    // DEC private mode flag
+                    decPrivateModeFlag = true;
+                    return;
+                default:
+                    break;
+                }
+            }
+
+            // Unknown keystroke, ignore
+            resetParser();
+            return;
+
+        case MOUSE_SGR:
+            // Numbers - parameter values
+            if ((ch >= '0') && (ch <= '9')) {
+                params.set(params.size() - 1,
+                    params.get(params.size() - 1) + ch);
+                return;
+            }
+            // Parameter separator
+            if (ch == ';') {
+                params.add("");
+                return;
+            }
+
+            switch (ch) {
+            case 'M':
+                // Generate a mouse press event
+                TInputEvent event = parseMouseSGR(false);
+                if (event != null) {
+                    events.add(event);
+                }
+                resetParser();
+                return;
+            case 'm':
+                // Generate a mouse release event
+                event = parseMouseSGR(true);
+                if (event != null) {
+                    events.add(event);
+                }
+                resetParser();
+                return;
+            default:
+                break;
+            }
+
+            // Unknown keystroke, ignore
+            resetParser();
+            return;
+
+        case CSI_PARAM:
+            // Numbers - parameter values
+            if ((ch >= '0') && (ch <= '9')) {
+                params.set(params.size() - 1,
+                    params.get(params.size() - 1) + ch);
+                state = ParseState.CSI_PARAM;
+                return;
+            }
+            // Parameter separator
+            if (ch == ';') {
+                params.add("");
+                return;
+            }
+
+            if (ch == '~') {
+                events.add(csiFnKey());
+                resetParser();
+                return;
+            }
+
+            if ((ch >= 0x30) && (ch <= 0x7E)) {
+                switch (ch) {
+                case 'A':
                     // Up
+                    if (params.size() > 1) {
+                        shift = csiIsShift(params.get(1));
+                        alt = csiIsAlt(params.get(1));
+                        ctrl = csiIsCtrl(params.get(1));
+                    }
                     events.add(new TKeypressEvent(kbUp, alt, ctrl, shift));
                     resetParser();
                     return;
                 case 'B':
                     // Down
+                    if (params.size() > 1) {
+                        shift = csiIsShift(params.get(1));
+                        alt = csiIsAlt(params.get(1));
+                        ctrl = csiIsCtrl(params.get(1));
+                    }
                     events.add(new TKeypressEvent(kbDown, alt, ctrl, shift));
                     resetParser();
                     return;
                 case 'C':
                     // Right
+                    if (params.size() > 1) {
+                        shift = csiIsShift(params.get(1));
+                        alt = csiIsAlt(params.get(1));
+                        ctrl = csiIsCtrl(params.get(1));
+                    }
                     events.add(new TKeypressEvent(kbRight, alt, ctrl, shift));
                     resetParser();
                     return;
                 case 'D':
                     // Left
+                    if (params.size() > 1) {
+                        shift = csiIsShift(params.get(1));
+                        alt = csiIsAlt(params.get(1));
+                        ctrl = csiIsCtrl(params.get(1));
+                    }
                     events.add(new TKeypressEvent(kbLeft, alt, ctrl, shift));
                     resetParser();
                     return;
                 case 'H':
                     // Home
-                    events.add(new TKeypressEvent(kbHome));
+                    if (params.size() > 1) {
+                        shift = csiIsShift(params.get(1));
+                        alt = csiIsAlt(params.get(1));
+                        ctrl = csiIsCtrl(params.get(1));
+                    }
+                    events.add(new TKeypressEvent(kbHome, alt, ctrl, shift));
                     resetParser();
                     return;
                 case 'F':
                     // End
-                    events.add(new TKeypressEvent(kbEnd));
+                    if (params.size() > 1) {
+                        shift = csiIsShift(params.get(1));
+                        alt = csiIsAlt(params.get(1));
+                        ctrl = csiIsCtrl(params.get(1));
+                    }
+                    events.add(new TKeypressEvent(kbEnd, alt, ctrl, shift));
                     resetParser();
                     return;
-                case 'Z':
-                    // CBT - Cursor backward X tab stops (default 1)
-                    events.add(new TKeypressEvent(kbBackTab));
+                case 'c':
+                    // Device Attributes
+                    if (decPrivateModeFlag == false) {
+                        break;
+                    }
+                    boolean reportsJexerImages = false;
+                    boolean reportsIterm2Images = false;
+                    for (String x: params) {
+                        if (x.equals("4")) {
+                            // Terminal reports sixel support
+                            if (debugToStderr) {
+                                System.err.println("Device Attributes: sixel");
+                            }
+                        }
+                        if (x.equals("444")) {
+                            // Terminal reports Jexer images support
+                            if (debugToStderr) {
+                                System.err.println("Device Attributes: Jexer images");
+                            }
+                            reportsJexerImages = true;
+                        }
+                        if (x.equals("1337")) {
+                            // Terminal reports iTerm2 images support
+                            if (debugToStderr) {
+                                System.err.println("Device Attributes: iTerm2 images");
+                            }
+                            reportsIterm2Images = true;
+                        }
+                    }
+                    if (reportsJexerImages == false) {
+                        // Terminal does not support Jexer images, disable
+                        // them.
+                        jexerImageOption = JexerImageOption.DISABLED;
+                    }
+                    if (reportsIterm2Images == false) {
+                        // Terminal does not support iTerm2 images, disable
+                        // them.
+                        iterm2Images = false;
+                    }
                     resetParser();
                     return;
-                case 'M':
-                    // Mouse position
-                    state = ParseState.MOUSE;
-                    return;
-                case '<':
-                    // Mouse position, SGR (1006) coordinates
-                    state = ParseState.MOUSE_SGR;
+                case 't':
+                    // windowOps
+                    if ((params.size() > 2) && (params.get(0).equals("4"))) {
+                        if (debugToStderr) {
+                            System.err.printf("windowOp pixels: " +
+                                "height %s width %s\n",
+                                params.get(1), params.get(2));
+                        }
+                        try {
+                            widthPixels = Integer.parseInt(params.get(2));
+                            heightPixels = Integer.parseInt(params.get(1));
+                        } catch (NumberFormatException e) {
+                            if (debugToStderr) {
+                                e.printStackTrace();
+                            }
+                        }
+                        if (widthPixels <= 0) {
+                            widthPixels = 640;
+                        }
+                        if (heightPixels <= 0) {
+                            heightPixels = 400;
+                        }
+                    }
+                    if ((params.size() > 2) && (params.get(0).equals("6"))) {
+                        if (debugToStderr) {
+                            System.err.printf("windowOp text cell pixels: " +
+                                "height %s width %s\n",
+                                params.get(1), params.get(2));
+                        }
+                        try {
+                            widthPixels = width * Integer.parseInt(params.get(2));
+                            heightPixels = height * Integer.parseInt(params.get(1));
+                        } catch (NumberFormatException e) {
+                            if (debugToStderr) {
+                                e.printStackTrace();
+                            }
+                        }
+                        if (widthPixels <= 0) {
+                            widthPixels = 640;
+                        }
+                        if (heightPixels <= 0) {
+                            heightPixels = 400;
+                        }
+                    }
+                    resetParser();
                     return;
                 default:
                     break;
                 }
             }
 
-            // Unknown keystroke, ignore
-            resetParser();
-            return;
+            // Unknown keystroke, ignore
+            resetParser();
+            return;
+
+        case MOUSE:
+            params.set(0, params.get(params.size() - 1) + ch);
+            if (params.get(0).length() == 3) {
+                // We have enough to generate a mouse event
+                events.add(parseMouse());
+                resetParser();
+            }
+            return;
+
+        default:
+            break;
+        }
+
+        // This "should" be impossible to reach
+        return;
+    }
+
+    /**
+     * Request (u)xterm to use the sixel settings we need:
+     *
+     *   - enable sixel scrolling
+     *
+     *   - disable private color registers (so that we can use one common
+     *     palette) if sixelSharedPalette is set
+     *
+     * @return the string to emit to xterm
+     */
+    private String xtermSetSixelSettings() {
+        if (sixelSharedPalette == true) {
+            return "\033[?80h\033[?1070l";
+        } else {
+            return "\033[?80h\033[?1070h";
+        }
+    }
+
+    /**
+     * Restore (u)xterm its default sixel settings:
+     *
+     *   - enable sixel scrolling
+     *
+     *   - enable private color registers
+     *
+     * @return the string to emit to xterm
+     */
+    private String xtermResetSixelSettings() {
+        return "\033[?80h\033[?1070h";
+    }
+
+    /**
+     * Request (u)xterm to report the current window and cell size dimensions
+     * in pixels.
+     *
+     * @return the string to emit to xterm
+     */
+    private String xtermReportPixelDimensions() {
+        // We will ask for both window and text cell dimensions, and
+        // hopefully one of them will work.
+        return "\033[14t\033[16t";
+    }
+
+    /**
+     * Tell (u)xterm that we want alt- keystrokes to send escape + character
+     * rather than set the 8th bit.  Anyone who wants UTF8 should want this
+     * enabled.
+     *
+     * @param on if true, enable metaSendsEscape
+     * @return the string to emit to xterm
+     */
+    private String xtermMetaSendsEscape(final boolean on) {
+        if (on) {
+            return "\033[?1036h\033[?1034l";
+        }
+        return "\033[?1036l";
+    }
+
+    /**
+     * Create an xterm OSC sequence to change the window title.
+     *
+     * @param title the new title
+     * @return the string to emit to xterm
+     */
+    private String getSetTitleString(final String title) {
+        return "\033]2;" + title + "\007";
+    }
+
+    // ------------------------------------------------------------------------
+    // Sixel output support ---------------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Get the number of colors in the sixel palette.
+     *
+     * @return the palette size
+     */
+    public int getSixelPaletteSize() {
+        return sixelPaletteSize;
+    }
+
+    /**
+     * Set the number of colors in the sixel palette.
+     *
+     * @param paletteSize the new palette size
+     */
+    public void setSixelPaletteSize(final int paletteSize) {
+        if (paletteSize == sixelPaletteSize) {
+            return;
+        }
+
+        switch (paletteSize) {
+        case 2:
+        case 256:
+        case 512:
+        case 1024:
+        case 2048:
+            break;
+        default:
+            throw new IllegalArgumentException("Unsupported sixel palette " +
+                " size: " + paletteSize);
+        }
+
+        // Don't step on the screen refresh thread.
+        synchronized (this) {
+            sixelPaletteSize = paletteSize;
+            palette = null;
+            sixelCache = null;
+            clearPhysical();
+        }
+    }
+
+    /**
+     * Start a sixel string for display one row's worth of bitmap data.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @return the string to emit to an ANSI / ECMA-style terminal
+     */
+    private String startSixel(final int x, final int y) {
+        StringBuilder sb = new StringBuilder();
+
+        assert (sixel == true);
+
+        // Place the cursor
+        sb.append(gotoXY(x, y));
+
+        // DCS
+        sb.append("\033Pq");
+
+        if (palette == null) {
+            palette = new SixelPalette();
+            if (sixelSharedPalette == true) {
+                palette.emitPalette(sb, null);
+            }
+        }
+
+        return sb.toString();
+    }
 
-        case MOUSE_SGR:
-            // Numbers - parameter values
-            if ((ch >= '0') && (ch <= '9')) {
-                params.set(params.size() - 1,
-                    params.get(params.size() - 1) + ch);
-                return;
+    /**
+     * End a sixel string for display one row's worth of bitmap data.
+     *
+     * @return the string to emit to an ANSI / ECMA-style terminal
+     */
+    private String endSixel() {
+        assert (sixel == true);
+
+        // ST
+        return ("\033\\");
+    }
+
+    /**
+     * Create a sixel string representing a row of several cells containing
+     * bitmap data.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param cells the cells containing the bitmap data
+     * @return the string to emit to an ANSI / ECMA-style terminal
+     */
+    private String toSixel(final int x, final int y,
+        final ArrayList<Cell> cells) {
+
+        StringBuilder sb = new StringBuilder();
+
+        assert (cells != null);
+        assert (cells.size() > 0);
+        assert (cells.get(0).getImage() != null);
+
+        if (sixel == false) {
+            sb.append(normal());
+            sb.append(gotoXY(x, y));
+            for (int i = 0; i < cells.size(); i++) {
+                sb.append(' ');
             }
-            // Parameter separator
-            if (ch == ';') {
-                params.add("");
-                return;
+            return sb.toString();
+        }
+
+        if (y == height - 1) {
+            // We are on the bottom row.  If scrolling mode is enabled
+            // (default), then VT320/xterm will scroll the entire screen if
+            // we draw any pixels here.  Do not draw the image, bail out
+            // instead.
+            sb.append(normal());
+            sb.append(gotoXY(x, y));
+            for (int j = 0; j < cells.size(); j++) {
+                sb.append(' ');
             }
+            return sb.toString();
+        }
 
-            switch (ch) {
-            case 'M':
-                // Generate a mouse press event
-                TInputEvent event = parseMouseSGR(false);
-                if (event != null) {
-                    events.add(event);
-                }
-                resetParser();
-                return;
-            case 'm':
-                // Generate a mouse release event
-                event = parseMouseSGR(true);
-                if (event != null) {
-                    events.add(event);
-                }
-                resetParser();
-                return;
-            default:
-                break;
+        if (sixelCache == null) {
+            sixelCache = new ImageCache(height * 10);
+        }
+
+        // Save and get rows to/from the cache that do NOT have inverted
+        // cells.
+        boolean saveInCache = true;
+        for (Cell cell: cells) {
+            if (cell.isInvertedImage()) {
+                saveInCache = false;
+            }
+        }
+        if (saveInCache) {
+            String cachedResult = sixelCache.get(cells);
+            if (cachedResult != null) {
+                // System.err.println("CACHE HIT");
+                sb.append(startSixel(x, y));
+                sb.append(cachedResult);
+                sb.append(endSixel());
+                return sb.toString();
             }
+            // System.err.println("CACHE MISS");
+        }
 
-            // Unknown keystroke, ignore
-            resetParser();
-            return;
+        BufferedImage image = cellsToImage(cells);
+        int fullHeight = image.getHeight();
 
-        case CSI_PARAM:
-            // Numbers - parameter values
-            if ((ch >= '0') && (ch <= '9')) {
-                params.set(params.size() - 1,
-                    params.get(params.size() - 1) + ch);
-                state = ParseState.CSI_PARAM;
-                return;
+        // Dither the image.  It is ok to lose the original here.
+        if (palette == null) {
+            palette = new SixelPalette();
+            if (sixelSharedPalette == true) {
+                palette.emitPalette(sb, null);
             }
-            // Parameter separator
-            if (ch == ';') {
-                params.add("");
-                return;
+        }
+        image = palette.ditherImage(image);
+
+        // Collect the raster information
+        int rasterHeight = 0;
+        int rasterWidth = image.getWidth();
+
+        if (sixelSharedPalette == false) {
+            // Emit the palette, but only for the colors actually used by
+            // these cells.
+            boolean [] usedColors = new boolean[sixelPaletteSize];
+            for (int imageX = 0; imageX < image.getWidth(); imageX++) {
+                for (int imageY = 0; imageY < image.getHeight(); imageY++) {
+                    usedColors[image.getRGB(imageX, imageY)] = true;
+                }
             }
+            palette.emitPalette(sb, usedColors);
+        }
 
-            if (ch == '~') {
-                events.add(csiFnKey());
-                resetParser();
-                return;
+        // Render the entire row of cells.
+        for (int currentRow = 0; currentRow < fullHeight; currentRow += 6) {
+            int [][] sixels = new int[image.getWidth()][6];
+
+            // See which colors are actually used in this band of sixels.
+            for (int imageX = 0; imageX < image.getWidth(); imageX++) {
+                for (int imageY = 0;
+                     (imageY < 6) && (imageY + currentRow < fullHeight);
+                     imageY++) {
+
+                    int colorIdx = image.getRGB(imageX, imageY + currentRow);
+                    assert (colorIdx >= 0);
+                    assert (colorIdx < sixelPaletteSize);
+
+                    sixels[imageX][imageY] = colorIdx;
+                }
             }
 
-            if ((ch >= 0x30) && (ch <= 0x7E)) {
-                switch (ch) {
-                case 'A':
-                    // Up
-                    if (params.size() > 1) {
-                        shift = csiIsShift(params.get(1));
-                        alt = csiIsAlt(params.get(1));
-                        ctrl = csiIsCtrl(params.get(1));
-                    }
-                    events.add(new TKeypressEvent(kbUp, alt, ctrl, shift));
-                    resetParser();
-                    return;
-                case 'B':
-                    // Down
-                    if (params.size() > 1) {
-                        shift = csiIsShift(params.get(1));
-                        alt = csiIsAlt(params.get(1));
-                        ctrl = csiIsCtrl(params.get(1));
-                    }
-                    events.add(new TKeypressEvent(kbDown, alt, ctrl, shift));
-                    resetParser();
-                    return;
-                case 'C':
-                    // Right
-                    if (params.size() > 1) {
-                        shift = csiIsShift(params.get(1));
-                        alt = csiIsAlt(params.get(1));
-                        ctrl = csiIsCtrl(params.get(1));
+            for (int i = 0; i < sixelPaletteSize; i++) {
+                boolean isUsed = false;
+                for (int imageX = 0; imageX < image.getWidth(); imageX++) {
+                    for (int j = 0; j < 6; j++) {
+                        if (sixels[imageX][j] == i) {
+                            isUsed = true;
+                        }
                     }
-                    events.add(new TKeypressEvent(kbRight, alt, ctrl, shift));
-                    resetParser();
-                    return;
-                case 'D':
-                    // Left
-                    if (params.size() > 1) {
-                        shift = csiIsShift(params.get(1));
-                        alt = csiIsAlt(params.get(1));
-                        ctrl = csiIsCtrl(params.get(1));
+                }
+                if (isUsed == false) {
+                    continue;
+                }
+
+                // Set to the beginning of scan line for the next set of
+                // colored pixels, and select the color.
+                sb.append(String.format("$#%d", i));
+
+                int oldData = -1;
+                int oldDataCount = 0;
+                for (int imageX = 0; imageX < image.getWidth(); imageX++) {
+
+                    // Add up all the pixels that match this color.
+                    int data = 0;
+                    for (int j = 0;
+                         (j < 6) && (currentRow + j < fullHeight);
+                         j++) {
+
+                        if (sixels[imageX][j] == i) {
+                            switch (j) {
+                            case 0:
+                                data += 1;
+                                break;
+                            case 1:
+                                data += 2;
+                                break;
+                            case 2:
+                                data += 4;
+                                break;
+                            case 3:
+                                data += 8;
+                                break;
+                            case 4:
+                                data += 16;
+                                break;
+                            case 5:
+                                data += 32;
+                                break;
+                            }
+                            if ((currentRow + j + 1) > rasterHeight) {
+                                rasterHeight = currentRow + j + 1;
+                            }
+                        }
                     }
-                    events.add(new TKeypressEvent(kbLeft, alt, ctrl, shift));
-                    resetParser();
-                    return;
-                case 'H':
-                    // Home
-                    if (params.size() > 1) {
-                        shift = csiIsShift(params.get(1));
-                        alt = csiIsAlt(params.get(1));
-                        ctrl = csiIsCtrl(params.get(1));
+                    assert (data >= 0);
+                    assert (data < 64);
+                    data += 63;
+
+                    if (data == oldData) {
+                        oldDataCount++;
+                    } else {
+                        if (oldDataCount == 1) {
+                            sb.append((char) oldData);
+                        } else if (oldDataCount > 1) {
+                            sb.append(String.format("!%d", oldDataCount));
+                            sb.append((char) oldData);
+                        }
+                        oldDataCount = 1;
+                        oldData = data;
                     }
-                    events.add(new TKeypressEvent(kbHome, alt, ctrl, shift));
-                    resetParser();
-                    return;
-                case 'F':
-                    // End
-                    if (params.size() > 1) {
-                        shift = csiIsShift(params.get(1));
-                        alt = csiIsAlt(params.get(1));
-                        ctrl = csiIsCtrl(params.get(1));
+
+                } // for (int imageX = 0; imageX < image.getWidth(); imageX++)
+
+                // Emit the last sequence.
+                if (oldDataCount == 1) {
+                    sb.append((char) oldData);
+                } else if (oldDataCount > 1) {
+                    sb.append(String.format("!%d", oldDataCount));
+                    sb.append((char) oldData);
+                }
+
+            } // for (int i = 0; i < sixelPaletteSize; i++)
+
+            // Advance to the next scan line.
+            sb.append("-");
+
+        } // for (int currentRow = 0; currentRow < imageHeight; currentRow += 6)
+
+        // Kill the very last "-", because it is unnecessary.
+        sb.deleteCharAt(sb.length() - 1);
+
+        // Add the raster information
+        sb.insert(0, String.format("\"1;1;%d;%d", rasterWidth, rasterHeight));
+
+        if (saveInCache) {
+            // This row is OK to save into the cache.
+            sixelCache.put(cells, sb.toString());
+        }
+
+        return (startSixel(x, y) + sb.toString() + endSixel());
+    }
+
+    /**
+     * Get the sixel support flag.
+     *
+     * @return true if this terminal is emitting sixel
+     */
+    public boolean hasSixel() {
+        return sixel;
+    }
+
+    /**
+     * Convert a horizontal range of cell's image data into a single
+     * contigous image, rescaled and anti-aliased to match the current text
+     * cell size.
+     *
+     * @param cells the cells containing image data
+     * @return the image resized to the current text cell size
+     */
+    private BufferedImage cellsToImage(final List<Cell> cells) {
+        int imageWidth = cells.get(0).getImage().getWidth();
+        int imageHeight = cells.get(0).getImage().getHeight();
+
+        // Piece cells.get(x).getImage() pieces together into one larger
+        // image for final rendering.
+        int totalWidth = 0;
+        int fullWidth = cells.size() * imageWidth;
+        int fullHeight = imageHeight;
+        for (int i = 0; i < cells.size(); i++) {
+            totalWidth += cells.get(i).getImage().getWidth();
+        }
+
+        BufferedImage image = new BufferedImage(fullWidth,
+            fullHeight, BufferedImage.TYPE_INT_ARGB);
+
+        int [] rgbArray;
+        for (int i = 0; i < cells.size() - 1; i++) {
+            int tileWidth = imageWidth;
+            int tileHeight = imageHeight;
+
+            if (false && cells.get(i).isInvertedImage()) {
+                // I used to put an all-white cell over the cursor, don't do
+                // that anymore.
+                rgbArray = new int[imageWidth * imageHeight];
+                for (int j = 0; j < rgbArray.length; j++) {
+                    rgbArray[j] = 0xFFFFFF;
+                }
+            } else {
+                try {
+                    rgbArray = cells.get(i).getImage().getRGB(0, 0,
+                        tileWidth, tileHeight, null, 0, tileWidth);
+                } catch (Exception e) {
+                    throw new RuntimeException("image " + imageWidth + "x" +
+                        imageHeight +
+                        "tile " + tileWidth + "x" +
+                        tileHeight +
+                        " cells.get(i).getImage() " +
+                        cells.get(i).getImage() +
+                        " i " + i +
+                        " fullWidth " + fullWidth +
+                        " fullHeight " + fullHeight, e);
+                }
+            }
+
+            /*
+            System.err.printf("calling image.setRGB(): %d %d %d %d %d\n",
+                i * imageWidth, 0, imageWidth, imageHeight,
+                0, imageWidth);
+            System.err.printf("   fullWidth %d fullHeight %d cells.size() %d textWidth %d\n",
+                fullWidth, fullHeight, cells.size(), getTextWidth());
+             */
+
+            image.setRGB(i * imageWidth, 0, tileWidth, tileHeight,
+                rgbArray, 0, tileWidth);
+            if (tileHeight < fullHeight) {
+                int backgroundColor = cells.get(i).getBackground().getRGB();
+                for (int imageX = 0; imageX < image.getWidth(); imageX++) {
+                    for (int imageY = imageHeight; imageY < fullHeight;
+                         imageY++) {
+
+                        image.setRGB(imageX, imageY, backgroundColor);
                     }
-                    events.add(new TKeypressEvent(kbEnd, alt, ctrl, shift));
-                    resetParser();
-                    return;
-                default:
-                    break;
                 }
             }
+        }
+        totalWidth -= ((cells.size() - 1) * imageWidth);
+        if (false && cells.get(cells.size() - 1).isInvertedImage()) {
+            // I used to put an all-white cell over the cursor, don't do that
+            // anymore.
+            rgbArray = new int[totalWidth * imageHeight];
+            for (int j = 0; j < rgbArray.length; j++) {
+                rgbArray[j] = 0xFFFFFF;
+            }
+        } else {
+            try {
+                rgbArray = cells.get(cells.size() - 1).getImage().getRGB(0, 0,
+                    totalWidth, imageHeight, null, 0, totalWidth);
+            } catch (Exception e) {
+                throw new RuntimeException("image " + imageWidth + "x" +
+                    imageHeight + " cells.get(cells.size() - 1).getImage() " +
+                    cells.get(cells.size() - 1).getImage(), e);
+            }
+        }
+        image.setRGB((cells.size() - 1) * imageWidth, 0, totalWidth,
+            imageHeight, rgbArray, 0, totalWidth);
+
+        if (totalWidth < imageWidth) {
+            int backgroundColor = cells.get(cells.size() - 1).getBackground().getRGB();
+
+            for (int imageX = image.getWidth() - totalWidth;
+                 imageX < image.getWidth(); imageX++) {
+
+                for (int imageY = 0; imageY < fullHeight; imageY++) {
+                    image.setRGB(imageX, imageY, backgroundColor);
+                }
+            }
+        }
+
+        if ((image.getWidth() != cells.size() * getTextWidth())
+            || (image.getHeight() != getTextHeight())
+        ) {
+            // Rescale the image to fit the text cells it is going into.
+            BufferedImage newImage;
+            newImage = new BufferedImage(cells.size() * getTextWidth(),
+                getTextHeight(), BufferedImage.TYPE_INT_ARGB);
+
+            Graphics gr = newImage.getGraphics();
+            if (gr instanceof Graphics2D) {
+                ((Graphics2D) gr).setRenderingHint(RenderingHints.KEY_ANTIALIASING,
+                    RenderingHints.VALUE_ANTIALIAS_ON);
+                ((Graphics2D) gr).setRenderingHint(RenderingHints.KEY_RENDERING,
+                    RenderingHints.VALUE_RENDER_QUALITY);
+            }
+            gr.drawImage(image, 0, 0, newImage.getWidth(),
+                newImage.getHeight(), null, null);
+            gr.dispose();
+            image = newImage;
+        }
+
+        return image;
+    }
+
+    // ------------------------------------------------------------------------
+    // End sixel output support -----------------------------------------------
+    // ------------------------------------------------------------------------
+
+    // ------------------------------------------------------------------------
+    // iTerm2 image output support --------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Create an iTerm2 images string representing a row of several cells
+     * containing bitmap data.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param cells the cells containing the bitmap data
+     * @return the string to emit to an ANSI / ECMA-style terminal
+     */
+    private String toIterm2Image(final int x, final int y,
+        final ArrayList<Cell> cells) {
+
+        StringBuilder sb = new StringBuilder();
+
+        assert (cells != null);
+        assert (cells.size() > 0);
+        assert (cells.get(0).getImage() != null);
+
+        if (iterm2Images == false) {
+            sb.append(normal());
+            sb.append(gotoXY(x, y));
+            for (int i = 0; i < cells.size(); i++) {
+                sb.append(' ');
+            }
+            return sb.toString();
+        }
+
+        if (iterm2Cache == null) {
+            iterm2Cache = new ImageCache(height * 10);
+        }
+
+        // Save and get rows to/from the cache that do NOT have inverted
+        // cells.
+        boolean saveInCache = true;
+        for (Cell cell: cells) {
+            if (cell.isInvertedImage()) {
+                saveInCache = false;
+            }
+        }
+        if (saveInCache) {
+            String cachedResult = iterm2Cache.get(cells);
+            if (cachedResult != null) {
+                // System.err.println("CACHE HIT");
+                sb.append(gotoXY(x, y));
+                sb.append(cachedResult);
+                return sb.toString();
+            }
+            // System.err.println("CACHE MISS");
+        }
+
+        BufferedImage image = cellsToImage(cells);
+        int fullHeight = image.getHeight();
+
+        /*
+         * From https://iterm2.com/documentation-images.html:
+         *
+         * Protocol
+         *
+         * iTerm2 extends the xterm protocol with a set of proprietary escape
+         * sequences. In general, the pattern is:
+         *
+         * ESC ] 1337 ; key = value ^G
+         *
+         * Whitespace is shown here for ease of reading: in practice, no
+         * spaces should be used.
+         *
+         * For file transfer and inline images, the code is:
+         *
+         * ESC ] 1337 ; File = [optional arguments] : base-64 encoded file contents ^G
+         *
+         * The optional arguments are formatted as key=value with a semicolon
+         * between each key-value pair. They are described below:
+         *
+         * Key         Description of value
+         * name         base-64 encoded filename. Defaults to "Unnamed file".
+         * size         File size in bytes. Optional; this is only used by the
+         *              progress indicator.
+         * width        Width to render. See notes below.
+         * height       Height to render. See notes below.
+         * preserveAspectRatio If set to 0, then the image's inherent aspect
+         *                     ratio will not be respected; otherwise, it
+         *                     will fill the specified width and height as
+         *                     much as possible without stretching. Defaults
+         *                     to 1.
+         * inline If set to 1, the file will be displayed inline. Otherwise,
+         *        it will be downloaded with no visual representation in the
+         *        terminal session. Defaults to 0.
+         *
+         * The width and height are given as a number followed by a unit, or
+         * the word "auto".
+         *
+         * N: N character cells.
+         * Npx: N pixels.
+         * N%: N percent of the session's width or height.
+         * auto: The image's inherent size will be used to determine an
+         *       appropriate dimension.
+         *
+         */
+
+        // File contents can be several image formats.  We will use PNG.
+        ByteArrayOutputStream pngOutputStream = new ByteArrayOutputStream(1024);
+        try {
+            if (!ImageIO.write(image.getSubimage(0, 0, image.getWidth(),
+                        Math.min(image.getHeight(), fullHeight)),
+                    "PNG", pngOutputStream)
+            ) {
+                // We failed to render image, bail out.
+                return "";
+            }
+        } catch (IOException e) {
+            // We failed to render image, bail out.
+            return "";
+        }
+
+        sb.append("\033]1337;File=");
+        /*
+        sb.append(String.format("width=$d;height=1;preserveAspectRatio=1;",
+                cells.size()));
+         */
+        /*
+        sb.append(String.format("width=$dpx;height=%dpx;preserveAspectRatio=1;",
+                image.getWidth(), Math.min(image.getHeight(),
+                    getTextHeight())));
+         */
+        sb.append("inline=1:");
+        sb.append(StringUtils.toBase64(pngOutputStream.toByteArray()));
+        sb.append("\007");
+
+        if (saveInCache) {
+            // This row is OK to save into the cache.
+            iterm2Cache.put(cells, sb.toString());
+        }
+
+        return (gotoXY(x, y) + sb.toString());
+    }
+
+    /**
+     * Get the iTerm2 images support flag.
+     *
+     * @return true if this terminal is emitting iTerm2 images
+     */
+    public boolean hasIterm2Images() {
+        return iterm2Images;
+    }
+
+    // ------------------------------------------------------------------------
+    // End iTerm2 image output support ----------------------------------------
+    // ------------------------------------------------------------------------
+
+    // ------------------------------------------------------------------------
+    // Jexer image output support ---------------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Create a Jexer images string representing a row of several cells
+     * containing bitmap data.
+     *
+     * @param x column coordinate.  0 is the left-most column.
+     * @param y row coordinate.  0 is the top-most row.
+     * @param cells the cells containing the bitmap data
+     * @return the string to emit to an ANSI / ECMA-style terminal
+     */
+    private String toJexerImage(final int x, final int y,
+        final ArrayList<Cell> cells) {
+
+        StringBuilder sb = new StringBuilder();
+
+        assert (cells != null);
+        assert (cells.size() > 0);
+        assert (cells.get(0).getImage() != null);
+
+        if (jexerImageOption == JexerImageOption.DISABLED) {
+            sb.append(normal());
+            sb.append(gotoXY(x, y));
+            for (int i = 0; i < cells.size(); i++) {
+                sb.append(' ');
+            }
+            return sb.toString();
+        }
+
+        if (jexerCache == null) {
+            jexerCache = new ImageCache(height * 10);
+        }
+
+        // Save and get rows to/from the cache that do NOT have inverted
+        // cells.
+        boolean saveInCache = true;
+        for (Cell cell: cells) {
+            if (cell.isInvertedImage()) {
+                saveInCache = false;
+            }
+        }
+        if (saveInCache) {
+            String cachedResult = jexerCache.get(cells);
+            if (cachedResult != null) {
+                // System.err.println("CACHE HIT");
+                sb.append(gotoXY(x, y));
+                sb.append(cachedResult);
+                return sb.toString();
+            }
+            // System.err.println("CACHE MISS");
+        }
+
+        BufferedImage image = cellsToImage(cells);
+        int fullHeight = image.getHeight();
+
+        if (jexerImageOption == JexerImageOption.PNG) {
+            // Encode as PNG
+            ByteArrayOutputStream pngOutputStream = new ByteArrayOutputStream(1024);
+            try {
+                if (!ImageIO.write(image.getSubimage(0, 0, image.getWidth(),
+                            Math.min(image.getHeight(), fullHeight)),
+                        "PNG", pngOutputStream)
+                ) {
+                    // We failed to render image, bail out.
+                    return "";
+                }
+            } catch (IOException e) {
+                // We failed to render image, bail out.
+                return "";
+            }
 
-            // Unknown keystroke, ignore
-            resetParser();
-            return;
+            sb.append("\033]444;1;0;");
+            sb.append(StringUtils.toBase64(pngOutputStream.toByteArray()));
+            sb.append("\007");
 
-        case MOUSE:
-            params.set(0, params.get(params.size() - 1) + ch);
-            if (params.get(0).length() == 3) {
-                // We have enough to generate a mouse event
-                events.add(parseMouse());
-                resetParser();
+        } else if (jexerImageOption == JexerImageOption.JPG) {
+
+            // Encode as JPG
+            ByteArrayOutputStream jpgOutputStream = new ByteArrayOutputStream(1024);
+
+            // Convert from ARGB to RGB, otherwise the JPG encode will fail.
+            BufferedImage jpgImage = new BufferedImage(image.getWidth(),
+                image.getHeight(), BufferedImage.TYPE_INT_RGB);
+            int [] pixels = new int[image.getWidth() * image.getHeight()];
+            image.getRGB(0, 0, image.getWidth(), image.getHeight(), pixels,
+                0, image.getWidth());
+            jpgImage.setRGB(0, 0, image.getWidth(), image.getHeight(), pixels,
+                0, image.getWidth());
+
+            try {
+                if (!ImageIO.write(jpgImage.getSubimage(0, 0,
+                            jpgImage.getWidth(),
+                            Math.min(jpgImage.getHeight(), fullHeight)),
+                        "JPG", jpgOutputStream)
+                ) {
+                    // We failed to render image, bail out.
+                    return "";
+                }
+            } catch (IOException e) {
+                // We failed to render image, bail out.
+                return "";
             }
-            return;
 
-        default:
-            break;
+            sb.append("\033]444;2;0;");
+            sb.append(StringUtils.toBase64(jpgOutputStream.toByteArray()));
+            sb.append("\007");
+
+        } else if (jexerImageOption == JexerImageOption.RGB) {
+
+            // RGB
+            sb.append(String.format("\033]444;0;%d;%d;0;", image.getWidth(),
+                    Math.min(image.getHeight(), fullHeight)));
+
+            byte [] bytes = new byte[image.getWidth() * image.getHeight() * 3];
+            int stride = image.getWidth();
+            for (int px = 0; px < stride; px++) {
+                for (int py = 0; py < image.getHeight(); py++) {
+                    int rgb = image.getRGB(px, py);
+                    bytes[(py * stride * 3) + (px * 3)]     = (byte) ((rgb >>> 16) & 0xFF);
+                    bytes[(py * stride * 3) + (px * 3) + 1] = (byte) ((rgb >>>  8) & 0xFF);
+                    bytes[(py * stride * 3) + (px * 3) + 2] = (byte) ( rgb         & 0xFF);
+                }
+            }
+            sb.append(StringUtils.toBase64(bytes));
+            sb.append("\007");
         }
 
-        // This "should" be impossible to reach
-        return;
+        if (saveInCache) {
+            // This row is OK to save into the cache.
+            jexerCache.put(cells, sb.toString());
+        }
+
+        return (gotoXY(x, y) + sb.toString());
     }
 
     /**
-     * Tell (u)xterm that we want alt- keystrokes to send escape + character
-     * rather than set the 8th bit.  Anyone who wants UTF8 should want this
-     * enabled.
+     * Get the Jexer images support flag.
      *
-     * @param on if true, enable metaSendsEscape
-     * @return the string to emit to xterm
+     * @return true if this terminal is emitting Jexer images
      */
-    private String xtermMetaSendsEscape(final boolean on) {
-        if (on) {
-            return "\033[?1036h\033[?1034l";
+    public boolean hasJexerImages() {
+        return (jexerImageOption != JexerImageOption.DISABLED);
+    }
+
+    // ------------------------------------------------------------------------
+    // End Jexer image output support -----------------------------------------
+    // ------------------------------------------------------------------------
+
+    /**
+     * Setup system colors to match DOS color palette.
+     */
+    private void setDOSColors() {
+        MYBLACK         = new java.awt.Color(0x00, 0x00, 0x00);
+        MYRED           = new java.awt.Color(0xa8, 0x00, 0x00);
+        MYGREEN         = new java.awt.Color(0x00, 0xa8, 0x00);
+        MYYELLOW        = new java.awt.Color(0xa8, 0x54, 0x00);
+        MYBLUE          = new java.awt.Color(0x00, 0x00, 0xa8);
+        MYMAGENTA       = new java.awt.Color(0xa8, 0x00, 0xa8);
+        MYCYAN          = new java.awt.Color(0x00, 0xa8, 0xa8);
+        MYWHITE         = new java.awt.Color(0xa8, 0xa8, 0xa8);
+        MYBOLD_BLACK    = new java.awt.Color(0x54, 0x54, 0x54);
+        MYBOLD_RED      = new java.awt.Color(0xfc, 0x54, 0x54);
+        MYBOLD_GREEN    = new java.awt.Color(0x54, 0xfc, 0x54);
+        MYBOLD_YELLOW   = new java.awt.Color(0xfc, 0xfc, 0x54);
+        MYBOLD_BLUE     = new java.awt.Color(0x54, 0x54, 0xfc);
+        MYBOLD_MAGENTA  = new java.awt.Color(0xfc, 0x54, 0xfc);
+        MYBOLD_CYAN     = new java.awt.Color(0x54, 0xfc, 0xfc);
+        MYBOLD_WHITE    = new java.awt.Color(0xfc, 0xfc, 0xfc);
+    }
+
+    /**
+     * Setup ECMA48 colors to match those provided in system properties.
+     */
+    private void setCustomSystemColors() {
+        setDOSColors();
+
+        MYBLACK   = getCustomColor("jexer.ECMA48.color0", MYBLACK);
+        MYRED     = getCustomColor("jexer.ECMA48.color1", MYRED);
+        MYGREEN   = getCustomColor("jexer.ECMA48.color2", MYGREEN);
+        MYYELLOW  = getCustomColor("jexer.ECMA48.color3", MYYELLOW);
+        MYBLUE    = getCustomColor("jexer.ECMA48.color4", MYBLUE);
+        MYMAGENTA = getCustomColor("jexer.ECMA48.color5", MYMAGENTA);
+        MYCYAN    = getCustomColor("jexer.ECMA48.color6", MYCYAN);
+        MYWHITE   = getCustomColor("jexer.ECMA48.color7", MYWHITE);
+        MYBOLD_BLACK   = getCustomColor("jexer.ECMA48.color8", MYBOLD_BLACK);
+        MYBOLD_RED     = getCustomColor("jexer.ECMA48.color9", MYBOLD_RED);
+        MYBOLD_GREEN   = getCustomColor("jexer.ECMA48.color10", MYBOLD_GREEN);
+        MYBOLD_YELLOW  = getCustomColor("jexer.ECMA48.color11", MYBOLD_YELLOW);
+        MYBOLD_BLUE    = getCustomColor("jexer.ECMA48.color12", MYBOLD_BLUE);
+        MYBOLD_MAGENTA = getCustomColor("jexer.ECMA48.color13", MYBOLD_MAGENTA);
+        MYBOLD_CYAN    = getCustomColor("jexer.ECMA48.color14", MYBOLD_CYAN);
+        MYBOLD_WHITE   = getCustomColor("jexer.ECMA48.color15", MYBOLD_WHITE);
+    }
+
+    /**
+     * Setup one system color to match the RGB value provided in system
+     * properties.
+     *
+     * @param key the system property key
+     * @param defaultColor the default color to return if key is not set, or
+     * incorrect
+     * @return a color from the RGB string, or defaultColor
+     */
+    private java.awt.Color getCustomColor(final String key,
+        final java.awt.Color defaultColor) {
+
+        String rgb = System.getProperty(key);
+        if (rgb == null) {
+            return defaultColor;
         }
-        return "\033[?1036l";
+        if (rgb.startsWith("#")) {
+            rgb = rgb.substring(1);
+        }
+        int rgbInt = 0;
+        try {
+            rgbInt = Integer.parseInt(rgb, 16);
+        } catch (NumberFormatException e) {
+            return defaultColor;
+        }
+        java.awt.Color color = new java.awt.Color((rgbInt & 0xFF0000) >>> 16,
+            (rgbInt & 0x00FF00) >>> 8,
+            (rgbInt & 0x0000FF));
+
+        return color;
     }
 
     /**
-     * Create an xterm OSC sequence to change the window title.
+     * Create a T.416 RGB parameter sequence for a custom system color.
      *
-     * @param title the new title
-     * @return the string to emit to xterm
+     * @param color one of the MYBLACK, MYBOLD_BLUE, etc. colors
+     * @return the color portion of the string to emit to an ANSI /
+     * ECMA-style terminal
      */
-    private String getSetTitleString(final String title) {
-        return "\033]2;" + title + "\007";
+    private String systemColorRGB(final java.awt.Color color) {
+        return String.format("%d;%d;%d", color.getRed(), color.getGreen(),
+            color.getBlue());
     }
 
     /**
@@ -1547,6 +3902,55 @@ public final class ECMA48Terminal extends LogicalScreen
                 rgbColor(bold, color, foreground);
     }
 
+    /**
+     * Create a T.416 RGB parameter sequence for a single color change.
+     *
+     * @param colorRGB a 24-bit RGB value for foreground color
+     * @param foreground if true, this is a foreground color
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[42m"
+     */
+    private String colorRGB(final int colorRGB, final boolean foreground) {
+
+        int colorRed     = (colorRGB >>> 16) & 0xFF;
+        int colorGreen   = (colorRGB >>>  8) & 0xFF;
+        int colorBlue    =  colorRGB         & 0xFF;
+
+        StringBuilder sb = new StringBuilder();
+        if (foreground) {
+            sb.append("\033[38;2;");
+        } else {
+            sb.append("\033[48;2;");
+        }
+        sb.append(String.format("%d;%d;%dm", colorRed, colorGreen, colorBlue));
+        return sb.toString();
+    }
+
+    /**
+     * Create a T.416 RGB parameter sequence for both foreground and
+     * background color change.
+     *
+     * @param foreColorRGB a 24-bit RGB value for foreground color
+     * @param backColorRGB a 24-bit RGB value for foreground color
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[42m"
+     */
+    private String colorRGB(final int foreColorRGB, final int backColorRGB) {
+        int foreColorRed     = (foreColorRGB >>> 16) & 0xFF;
+        int foreColorGreen   = (foreColorRGB >>>  8) & 0xFF;
+        int foreColorBlue    =  foreColorRGB         & 0xFF;
+        int backColorRed     = (backColorRGB >>> 16) & 0xFF;
+        int backColorGreen   = (backColorRGB >>>  8) & 0xFF;
+        int backColorBlue    =  backColorRGB         & 0xFF;
+
+        StringBuilder sb = new StringBuilder();
+        sb.append(String.format("\033[38;2;%d;%d;%dm",
+                foreColorRed, foreColorGreen, foreColorBlue));
+        sb.append(String.format("\033[48;2;%d;%d;%dm",
+                backColorRed, backColorGreen, backColorBlue));
+        return sb.toString();
+    }
+
     /**
      * Create a T.416 RGB parameter sequence for a single color change.
      *
@@ -1566,21 +3970,21 @@ public final class ECMA48Terminal extends LogicalScreen
             // Bold implies foreground only
             sb.append("38;2;");
             if (color.equals(Color.BLACK)) {
-                sb.append("84;84;84");
+                sb.append(systemColorRGB(MYBOLD_BLACK));
             } else if (color.equals(Color.RED)) {
-                sb.append("252;84;84");
+                sb.append(systemColorRGB(MYBOLD_RED));
             } else if (color.equals(Color.GREEN)) {
-                sb.append("84;252;84");
+                sb.append(systemColorRGB(MYBOLD_GREEN));
             } else if (color.equals(Color.YELLOW)) {
-                sb.append("252;252;84");
+                sb.append(systemColorRGB(MYBOLD_YELLOW));
             } else if (color.equals(Color.BLUE)) {
-                sb.append("84;84;252");
+                sb.append(systemColorRGB(MYBOLD_BLUE));
             } else if (color.equals(Color.MAGENTA)) {
-                sb.append("252;84;252");
+                sb.append(systemColorRGB(MYBOLD_MAGENTA));
             } else if (color.equals(Color.CYAN)) {
-                sb.append("84;252;252");
+                sb.append(systemColorRGB(MYBOLD_CYAN));
             } else if (color.equals(Color.WHITE)) {
-                sb.append("252;252;252");
+                sb.append(systemColorRGB(MYBOLD_WHITE));
             }
         } else {
             if (foreground) {
@@ -1589,21 +3993,21 @@ public final class ECMA48Terminal extends LogicalScreen
                 sb.append("48;2;");
             }
             if (color.equals(Color.BLACK)) {
-                sb.append("0;0;0");
+                sb.append(systemColorRGB(MYBLACK));
             } else if (color.equals(Color.RED)) {
-                sb.append("168;0;0");
+                sb.append(systemColorRGB(MYRED));
             } else if (color.equals(Color.GREEN)) {
-                sb.append("0;168;0");
+                sb.append(systemColorRGB(MYGREEN));
             } else if (color.equals(Color.YELLOW)) {
-                sb.append("168;84;0");
+                sb.append(systemColorRGB(MYYELLOW));
             } else if (color.equals(Color.BLUE)) {
-                sb.append("0;0;168");
+                sb.append(systemColorRGB(MYBLUE));
             } else if (color.equals(Color.MAGENTA)) {
-                sb.append("168;0;168");
+                sb.append(systemColorRGB(MYMAGENTA));
             } else if (color.equals(Color.CYAN)) {
-                sb.append("0;168;168");
+                sb.append(systemColorRGB(MYCYAN));
             } else if (color.equals(Color.WHITE)) {
-                sb.append("168;168;168");
+                sb.append(systemColorRGB(MYWHITE));
             }
         }
         sb.append("m");
@@ -1769,7 +4173,78 @@ public final class ECMA48Terminal extends LogicalScreen
     }
 
     /**
-     * Create a SGR parameter sequence to reset to defaults.
+     * Create a SGR parameter sequence for foreground, background, and
+     * several attributes.  This sequence first resets all attributes to
+     * default, then sets attributes as per the parameters.
+     *
+     * @param foreColorRGB a 24-bit RGB value for foreground color
+     * @param backColorRGB a 24-bit RGB value for foreground color
+     * @param bold if true, set bold
+     * @param reverse if true, set reverse
+     * @param blink if true, set blink
+     * @param underline if true, set underline
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[0;1;31;42m"
+     */
+    private String colorRGB(final int foreColorRGB, final int backColorRGB,
+        final boolean bold, final boolean reverse, final boolean blink,
+        final boolean underline) {
+
+        int foreColorRed     = (foreColorRGB >>> 16) & 0xFF;
+        int foreColorGreen   = (foreColorRGB >>>  8) & 0xFF;
+        int foreColorBlue    =  foreColorRGB         & 0xFF;
+        int backColorRed     = (backColorRGB >>> 16) & 0xFF;
+        int backColorGreen   = (backColorRGB >>>  8) & 0xFF;
+        int backColorBlue    =  backColorRGB         & 0xFF;
+
+        StringBuilder sb = new StringBuilder();
+        if        (  bold &&  reverse &&  blink && !underline ) {
+            sb.append("\033[0;1;7;5;");
+        } else if (  bold &&  reverse && !blink && !underline ) {
+            sb.append("\033[0;1;7;");
+        } else if ( !bold &&  reverse &&  blink && !underline ) {
+            sb.append("\033[0;7;5;");
+        } else if (  bold && !reverse &&  blink && !underline ) {
+            sb.append("\033[0;1;5;");
+        } else if (  bold && !reverse && !blink && !underline ) {
+            sb.append("\033[0;1;");
+        } else if ( !bold &&  reverse && !blink && !underline ) {
+            sb.append("\033[0;7;");
+        } else if ( !bold && !reverse &&  blink && !underline) {
+            sb.append("\033[0;5;");
+        } else if (  bold &&  reverse &&  blink &&  underline ) {
+            sb.append("\033[0;1;7;5;4;");
+        } else if (  bold &&  reverse && !blink &&  underline ) {
+            sb.append("\033[0;1;7;4;");
+        } else if ( !bold &&  reverse &&  blink &&  underline ) {
+            sb.append("\033[0;7;5;4;");
+        } else if (  bold && !reverse &&  blink &&  underline ) {
+            sb.append("\033[0;1;5;4;");
+        } else if (  bold && !reverse && !blink &&  underline ) {
+            sb.append("\033[0;1;4;");
+        } else if ( !bold &&  reverse && !blink &&  underline ) {
+            sb.append("\033[0;7;4;");
+        } else if ( !bold && !reverse &&  blink &&  underline) {
+            sb.append("\033[0;5;4;");
+        } else if ( !bold && !reverse && !blink &&  underline) {
+            sb.append("\033[0;4;");
+        } else {
+            assert (!bold && !reverse && !blink && !underline);
+            sb.append("\033[0;");
+        }
+
+        sb.append("m\033[38;2;");
+        sb.append(String.format("%d;%d;%d", foreColorRed, foreColorGreen,
+                foreColorBlue));
+        sb.append("m\033[48;2;");
+        sb.append(String.format("%d;%d;%d", backColorRed, backColorGreen,
+                backColorBlue));
+        sb.append("m");
+        return sb.toString();
+    }
+
+    /**
+     * Create a SGR parameter sequence to reset to VT100 defaults.
      *
      * @return the string to emit to an ANSI / ECMA-style terminal,
      * e.g. "\033[0m"
@@ -1778,6 +4253,29 @@ public final class ECMA48Terminal extends LogicalScreen
         return normal(true) + rgbColor(false, Color.WHITE, Color.BLACK);
     }
 
+    /**
+     * Create a SGR parameter sequence to reset to ECMA-48 default
+     * foreground/background.
+     *
+     * @return the string to emit to an ANSI / ECMA-style terminal,
+     * e.g. "\033[0m"
+     */
+    private String defaultColor() {
+        /*
+         * VT100 normal.
+         * Normal (neither bold nor faint).
+         * Not italicized.
+         * Not underlined.
+         * Steady (not blinking).
+         * Positive (not inverse).
+         * Visible (not hidden).
+         * Not crossed-out.
+         * Default foreground color.
+         * Default background color.
+         */
+        return "\033[0;22;23;24;25;27;28;29;39;49m";
+    }
+
     /**
      * Create a SGR parameter sequence to reset to defaults.
      *
@@ -1852,6 +4350,11 @@ public final class ECMA48Terminal extends LogicalScreen
      *
      * Note that this also sets the alternate/primary screen buffer.
      *
+     * Finally, also emit a Privacy Message sequence that Jexer recognizes to
+     * mean "hide the mouse pointer."  We have to use our own sequence to do
+     * this because there is no standard in xterm for unilaterally hiding the
+     * pointer all the time (regardless of typing).
+     *
      * @param on If true, enable mouse report and use the alternate screen
      * buffer.  If false disable mouse reporting and use the primary screen
      * buffer.
@@ -1859,96 +4362,9 @@ public final class ECMA48Terminal extends LogicalScreen
      */
     private String mouse(final boolean on) {
         if (on) {
-            return "\033[?1002;1003;1005;1006h\033[?1049h";
+            return "\033[?1002;1003;1005;1006h\033[?1049h\033^hideMousePointer\033\\";
         }
-        return "\033[?1002;1003;1006;1005l\033[?1049l";
-    }
-
-    /**
-     * Read function runs on a separate thread.
-     */
-    public void run() {
-        boolean done = false;
-        // available() will often return > 1, so we need to read in chunks to
-        // stay caught up.
-        char [] readBuffer = new char[128];
-        List<TInputEvent> events = new LinkedList<TInputEvent>();
-
-        while (!done && !stopReaderThread) {
-            try {
-                // We assume that if inputStream has bytes available, then
-                // input won't block on read().
-                int n = inputStream.available();
-
-                /*
-                System.err.printf("inputStream.available(): %d\n", n);
-                System.err.flush();
-                */
-
-                if (n > 0) {
-                    if (readBuffer.length < n) {
-                        // The buffer wasn't big enough, make it huger
-                        readBuffer = new char[readBuffer.length * 2];
-                    }
-
-                    // System.err.printf("BEFORE read()\n"); System.err.flush();
-
-                    int rc = input.read(readBuffer, 0, readBuffer.length);
-
-                    /*
-                    System.err.printf("AFTER read() %d\n", rc);
-                    System.err.flush();
-                    */
-
-                    if (rc == -1) {
-                        // This is EOF
-                        done = true;
-                    } else {
-                        for (int i = 0; i < rc; i++) {
-                            int ch = readBuffer[i];
-                            processChar(events, (char)ch);
-                        }
-                        getIdleEvents(events);
-                        if (events.size() > 0) {
-                            // Add to the queue for the backend thread to
-                            // be able to obtain.
-                            synchronized (eventQueue) {
-                                eventQueue.addAll(events);
-                            }
-                            if (listener != null) {
-                                synchronized (listener) {
-                                    listener.notifyAll();
-                                }
-                            }
-                            events.clear();
-                        }
-                    }
-                } else {
-                    getIdleEvents(events);
-                    if (events.size() > 0) {
-                        synchronized (eventQueue) {
-                            eventQueue.addAll(events);
-                        }
-                        if (listener != null) {
-                            synchronized (listener) {
-                                listener.notifyAll();
-                            }
-                        }
-                        events.clear();
-                    }
-
-                    // Wait 20 millis for more data
-                    Thread.sleep(20);
-                }
-                // System.err.println("end while loop"); System.err.flush();
-            } catch (InterruptedException e) {
-                // SQUASH
-            } catch (IOException e) {
-                e.printStackTrace();
-                done = true;
-            }
-        } // while ((done == false) && (stopReaderThread == false))
-        // System.err.println("*** run() exiting..."); System.err.flush();
+        return "\033[?1002;1003;1006;1005l\033[?1049l\033^showMousePointer\033\\";
     }
 
 }